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,26 @@
1
+ 'use strict';
2
+
3
+ import type { Core } from '@strapi/strapi';
4
+ import { getConfig } from '../../config';
5
+
6
+ /** Full URL of the protected resource, e.g. `http://localhost:1337/mcp`. */
7
+ export function canonicalResourceUrl(strapi: Core.Strapi): string {
8
+ return getConfig(strapi).resourceUrl;
9
+ }
10
+
11
+ /**
12
+ * The OAuth Authorization Server issuer — the *origin* of the resource URL,
13
+ * with no path. The MCP server lives at `/mcp` but the OAuth server lives at
14
+ * the host root, so `aud` (= resource) and `iss` (= origin) are different.
15
+ *
16
+ * Returns e.g. `http://localhost:1337` for resource `http://localhost:1337/mcp`.
17
+ */
18
+ export function authorizationServerUrl(strapi: Core.Strapi): string {
19
+ const u = new URL(canonicalResourceUrl(strapi));
20
+ return `${u.protocol}//${u.host}`;
21
+ }
22
+
23
+ export function audienceMatches(strapi: Core.Strapi, aud: unknown): boolean {
24
+ if (typeof aud !== 'string') return false;
25
+ return aud === canonicalResourceUrl(strapi);
26
+ }
@@ -0,0 +1,78 @@
1
+ 'use strict';
2
+
3
+ import { randomBytes, createHash } from 'crypto';
4
+ import type { Core } from '@strapi/strapi';
5
+ import { getConfig } from '../../config';
6
+
7
+ const UID = 'plugin::mcp-server.oauth-auth-code';
8
+
9
+ export interface AuthCodeRow {
10
+ id: number;
11
+ codeHash: string;
12
+ clientId: string;
13
+ adminUserId: string;
14
+ scope: string;
15
+ redirectUri: string;
16
+ codeChallenge: string;
17
+ codeChallengeMethod: 'S256';
18
+ resource: string;
19
+ used: boolean;
20
+ expiresAt: string;
21
+ }
22
+
23
+ export default ({ strapi }: { strapi: Core.Strapi }) => {
24
+ const sha256 = (s: string) => createHash('sha256').update(s).digest('hex');
25
+
26
+ return {
27
+ async issue(input: {
28
+ clientId: string;
29
+ adminUserId: string;
30
+ scope: string;
31
+ redirectUri: string;
32
+ codeChallenge: string;
33
+ resource: string;
34
+ }): Promise<string> {
35
+ const cfg = getConfig(strapi);
36
+ const code = randomBytes(32).toString('base64url');
37
+ const expiresAt = new Date(Date.now() + cfg.oauth.authCodeTtlSec * 1000);
38
+ await strapi.db.query(UID).create({
39
+ data: {
40
+ codeHash: sha256(code),
41
+ clientId: input.clientId,
42
+ adminUserId: input.adminUserId,
43
+ scope: input.scope,
44
+ redirectUri: input.redirectUri,
45
+ codeChallenge: input.codeChallenge,
46
+ codeChallengeMethod: 'S256',
47
+ resource: input.resource,
48
+ used: false,
49
+ expiresAt,
50
+ },
51
+ });
52
+ return code;
53
+ },
54
+
55
+ /**
56
+ * Single-use, race-safe: read-then-update-where-used-false. Returns the
57
+ * row if it was the consumer; null otherwise (already used, expired, or
58
+ * not found). Caller is responsible for triggering family revocation on
59
+ * a "used" replay.
60
+ */
61
+ async consume(code: string): Promise<AuthCodeRow | 'replayed' | null> {
62
+ const codeHash = sha256(code);
63
+ const row = (await strapi.db.query(UID).findOne({ where: { codeHash } })) as AuthCodeRow | null;
64
+ if (!row) return null;
65
+ if (new Date(row.expiresAt).getTime() < Date.now()) return null;
66
+ if (row.used) return 'replayed';
67
+
68
+ // Atomic-ish: update where used=false, then check rowcount via re-read.
69
+ await strapi.db.query(UID).update({
70
+ where: { id: row.id, used: false },
71
+ data: { used: true },
72
+ });
73
+ const after = (await strapi.db.query(UID).findOne({ where: { id: row.id } })) as AuthCodeRow | null;
74
+ if (!after || !after.used) return null;
75
+ return after;
76
+ },
77
+ };
78
+ };
@@ -0,0 +1,386 @@
1
+ 'use strict';
2
+
3
+ import type { Core } from '@strapi/strapi';
4
+ import { createHash, randomBytes, timingSafeEqual } from 'crypto';
5
+ import { ALL_SCOPES, parseScope, type Scope } from './scopes';
6
+
7
+ const UID = 'plugin::mcp-server.oauth-client';
8
+
9
+ export interface ClientRecord {
10
+ id: number;
11
+ clientId: string;
12
+ clientName: string;
13
+ clientSecretHash: string | null;
14
+ isConfidential: boolean;
15
+ redirectUris: string[];
16
+ grantTypes: string[];
17
+ scopes: Scope[];
18
+ tokenEndpointAuthMethod: 'none' | 'client_secret_basic' | 'client_secret_post';
19
+ /**
20
+ * Admin who granted consent to this client. NULL until first consent. UI
21
+ * creation does NOT populate this — creation is captured in createdByAdminId.
22
+ */
23
+ ownerAdminId: string | null;
24
+ /**
25
+ * Admin who made the client appear in the table. For UI-created clients,
26
+ * the admin who clicked Create. For DCR-created clients, the admin who
27
+ * first granted consent (DCR itself is unauthenticated). NULL only on a
28
+ * DCR client between its register call and its first consent.
29
+ */
30
+ createdByAdminId: string | null;
31
+ disabled: boolean;
32
+ createdAt: string | null;
33
+ lastUsedAt: string | null;
34
+ }
35
+
36
+ export default ({ strapi }: { strapi: Core.Strapi }) => {
37
+ const sha256 = (s: string) => createHash('sha256').update(s).digest('hex');
38
+
39
+ return {
40
+ async findActive(clientId: string): Promise<ClientRecord | null> {
41
+ const row = await strapi.db.query(UID).findOne({ where: { clientId, disabled: false } });
42
+ if (!row) return null;
43
+ return normalize(row);
44
+ },
45
+
46
+ /**
47
+ * Validate that a presented `redirect_uri` matches one the client has
48
+ * registered. Exact-match for non-loopback URIs (open-redirect mitigation).
49
+ *
50
+ * Loopback URIs (RFC 8252 §7.3) match leniently: scheme + host + path must
51
+ * match, **port is ignored**. Reason: native CLI/IDE clients pick a free
52
+ * loopback port at runtime, so requiring a fixed port forces operators to
53
+ * either pin the client to a specific port (UX bug) or pre-register every
54
+ * possible port (impractical). Loopback ports cannot leak the auth code to
55
+ * another machine — they all resolve to localhost — so port leniency
56
+ * doesn't widen the attack surface. `localhost`, `127.0.0.1`, and `[::1]`
57
+ * are treated as equivalent hosts; clients vary on which they emit.
58
+ */
59
+ isAllowedRedirectUri(client: ClientRecord, redirectUri: string): boolean {
60
+ if (typeof redirectUri !== 'string' || !redirectUri) return false;
61
+ let presented: URL;
62
+ try {
63
+ presented = new URL(redirectUri);
64
+ } catch {
65
+ return false;
66
+ }
67
+ if (client.redirectUris.includes(redirectUri)) return true;
68
+ if (!isLoopbackUrl(presented)) return false;
69
+ return client.redirectUris.some((registered) => {
70
+ let r: URL;
71
+ try {
72
+ r = new URL(registered);
73
+ } catch {
74
+ return false;
75
+ }
76
+ if (!isLoopbackUrl(r)) return false;
77
+ return r.protocol === presented.protocol && r.pathname === presented.pathname;
78
+ });
79
+ },
80
+
81
+ /** Verify a posted client_secret with constant-time compare. */
82
+ verifySecret(client: ClientRecord, presentedSecret: string | undefined): boolean {
83
+ if (!client.isConfidential || !client.clientSecretHash) {
84
+ return !presentedSecret;
85
+ }
86
+ if (typeof presentedSecret !== 'string' || presentedSecret.length === 0) return false;
87
+ const a = Buffer.from(sha256(presentedSecret));
88
+ const b = Buffer.from(client.clientSecretHash);
89
+ if (a.length !== b.length) return false;
90
+ try {
91
+ return timingSafeEqual(a, b);
92
+ } catch {
93
+ return false;
94
+ }
95
+ },
96
+
97
+ async create(input: {
98
+ clientName: string;
99
+ redirectUris: string[];
100
+ scopes: Scope[];
101
+ isConfidential: boolean;
102
+ grantTypes?: string[];
103
+ /** Admin who clicked Create. UI passes this; DCR omits it. */
104
+ createdByAdminId?: string;
105
+ }): Promise<{ client: ClientRecord; clientSecret?: string }> {
106
+ validateRedirectUris(input.redirectUris);
107
+ const filteredScopes = input.scopes.filter((s) =>
108
+ (ALL_SCOPES as readonly string[]).includes(s)
109
+ );
110
+ if (filteredScopes.length === 0) {
111
+ throw new Error('at least one valid scope is required');
112
+ }
113
+ const clientId = randomBytes(16).toString('hex');
114
+ let clientSecret: string | undefined;
115
+ let clientSecretHash: string | null = null;
116
+ if (input.isConfidential) {
117
+ clientSecret = randomBytes(32).toString('base64url');
118
+ clientSecretHash = sha256(clientSecret);
119
+ }
120
+ const created = await strapi.db.query(UID).create({
121
+ data: {
122
+ clientId,
123
+ clientName: input.clientName,
124
+ clientSecretHash,
125
+ isConfidential: input.isConfidential,
126
+ redirectUris: input.redirectUris,
127
+ grantTypes: input.grantTypes ?? ['authorization_code', 'refresh_token'],
128
+ scopes: filteredScopes,
129
+ tokenEndpointAuthMethod: input.isConfidential ? 'client_secret_basic' : 'none',
130
+ ownerAdminId: null,
131
+ createdByAdminId: input.createdByAdminId ?? null,
132
+ disabled: false,
133
+ },
134
+ });
135
+ return { client: normalize(created), clientSecret };
136
+ },
137
+
138
+ async update(
139
+ clientId: string,
140
+ patch: Partial<{
141
+ clientName: string;
142
+ redirectUris: string[];
143
+ scopes: Scope[];
144
+ disabled: boolean;
145
+ }>
146
+ ): Promise<ClientRecord | null> {
147
+ if (patch.redirectUris) validateRedirectUris(patch.redirectUris);
148
+ if (patch.scopes) {
149
+ patch.scopes = patch.scopes.filter((s) => (ALL_SCOPES as readonly string[]).includes(s));
150
+ }
151
+ const row = await strapi.db.query(UID).update({ where: { clientId }, data: patch });
152
+ return row ? normalize(row) : null;
153
+ },
154
+
155
+ async list(): Promise<ClientRecord[]> {
156
+ const rows = await strapi.db.query(UID).findMany({ orderBy: { id: 'desc' }, limit: 200 });
157
+ return rows.map(normalize);
158
+ },
159
+
160
+ async touchLastUsed(clientId: string): Promise<void> {
161
+ try {
162
+ await strapi.db.query(UID).update({ where: { clientId }, data: { lastUsedAt: new Date() } });
163
+ } catch {
164
+ /* non-fatal */
165
+ }
166
+ },
167
+
168
+ /**
169
+ * Record the consenting admin as this client's owner. Also backfills
170
+ * createdByAdminId on DCR-registered clients (which have no creator at
171
+ * registration time) so the Clients UI can show "created by". For
172
+ * UI-created clients, createdByAdminId is set at creation and left
173
+ * untouched here.
174
+ *
175
+ * No-op for the field if it's already set — a second consent from a
176
+ * different admin doesn't overwrite the first owner.
177
+ */
178
+ async setOwner(clientId: string, adminUserId: string): Promise<void> {
179
+ try {
180
+ const row = (await strapi.db.query(UID).findOne({ where: { clientId } })) as {
181
+ ownerAdminId?: string | null;
182
+ createdByAdminId?: string | null;
183
+ } | null;
184
+ if (!row) return;
185
+ const patch: Record<string, string> = {};
186
+ if (!row.ownerAdminId) patch.ownerAdminId = adminUserId;
187
+ if (!row.createdByAdminId) patch.createdByAdminId = adminUserId;
188
+ if (Object.keys(patch).length === 0) return;
189
+ await strapi.db.query(UID).update({ where: { clientId }, data: patch });
190
+ } catch {
191
+ /* non-fatal */
192
+ }
193
+ },
194
+
195
+ /**
196
+ * Delete sibling DCR-orphan clients that match `reference` on name +
197
+ * redirect URIs, have no owner, and have no consents / auth codes /
198
+ * refresh tokens. Called after a consent grant succeeds — when an MCP
199
+ * library hits `/oauth/register` multiple times during connect (RFC 7591
200
+ * issues a fresh client per call, no idempotency), only the registration
201
+ * that reached consent matters; the others are deletable.
202
+ *
203
+ * Redirect URIs are compared port-agnostically for loopback (per RFC 8252
204
+ * §7.3 — native CLI/IDE clients pick a fresh free port each launch, so
205
+ * `http://localhost:54321/callback` and `http://localhost:54322/callback`
206
+ * are the same logical URI). Same canonicalization as isAllowedRedirectUri.
207
+ */
208
+ async purgeOrphansLike(reference: {
209
+ clientId: string;
210
+ clientName: string;
211
+ redirectUris: string[];
212
+ }): Promise<number> {
213
+ const candidates = (await strapi.db.query(UID).findMany({
214
+ where: { clientName: reference.clientName, createdByAdminId: null },
215
+ limit: 200,
216
+ })) as Array<Record<string, unknown>>;
217
+ const targetSig = canonicalUriSetSig(reference.redirectUris);
218
+ const toPurge: string[] = [];
219
+ for (const row of candidates) {
220
+ if (row.clientId === reference.clientId) continue;
221
+ const uris = Array.isArray(row.redirectUris) ? (row.redirectUris as string[]) : [];
222
+ if (canonicalUriSetSig(uris) !== targetSig) continue;
223
+ if (await hasAnyRelated(strapi, row.clientId as string)) continue;
224
+ toPurge.push(row.clientId as string);
225
+ }
226
+ if (toPurge.length === 0) return 0;
227
+ // Audit writes are buffered; flush so the orphan's just-recorded
228
+ // dcr.register row is on disk before we delete it.
229
+ await strapi.plugin('mcp-server').service('audit').drain();
230
+ for (const clientId of toPurge) {
231
+ await strapi.db
232
+ .query('plugin::mcp-server.audit-log')
233
+ .deleteMany({ where: { clientId, tool: 'oauth.dcr.register' } });
234
+ await this.delete(clientId);
235
+ }
236
+ return toPurge.length;
237
+ },
238
+
239
+ /**
240
+ * Backstop sweep: drop any unowned client older than `olderThanMs` that
241
+ * never produced a consent, auth code, or refresh token. Runs from the
242
+ * nightly cron so accumulated orphans from incomplete DCR attempts don't
243
+ * pollute the Clients UI long-term.
244
+ */
245
+ async purgeOrphans(olderThanMs: number): Promise<number> {
246
+ const cutoff = new Date(Date.now() - olderThanMs);
247
+ const candidates = (await strapi.db.query(UID).findMany({
248
+ where: { createdByAdminId: null, createdAt: { $lt: cutoff } },
249
+ limit: 500,
250
+ })) as Array<Record<string, unknown>>;
251
+ const toPurge: string[] = [];
252
+ for (const row of candidates) {
253
+ if (await hasAnyRelated(strapi, row.clientId as string)) continue;
254
+ toPurge.push(row.clientId as string);
255
+ }
256
+ if (toPurge.length === 0) return 0;
257
+ await strapi.plugin('mcp-server').service('audit').drain();
258
+ for (const clientId of toPurge) {
259
+ await strapi.db
260
+ .query('plugin::mcp-server.audit-log')
261
+ .deleteMany({ where: { clientId, tool: 'oauth.dcr.register' } });
262
+ await this.delete(clientId);
263
+ }
264
+ return toPurge.length;
265
+ },
266
+
267
+ async delete(clientId: string): Promise<boolean> {
268
+ const row = await strapi.db.query(UID).delete({ where: { clientId } });
269
+ // Cascade-clean any auth codes / refresh tokens / consents tied to this
270
+ // client so a re-registered client can't accidentally inherit state.
271
+ await strapi.db
272
+ .query('plugin::mcp-server.oauth-auth-code')
273
+ .deleteMany({ where: { clientId } });
274
+ await strapi.db
275
+ .query('plugin::mcp-server.oauth-refresh-token')
276
+ .deleteMany({ where: { clientId } });
277
+ await strapi.db
278
+ .query('plugin::mcp-server.oauth-consent')
279
+ .deleteMany({ where: { clientId } });
280
+ return Boolean(row);
281
+ },
282
+ };
283
+ };
284
+
285
+ function normalize(row: Record<string, unknown>): ClientRecord {
286
+ return {
287
+ id: row.id as number,
288
+ clientId: row.clientId as string,
289
+ clientName: row.clientName as string,
290
+ clientSecretHash: (row.clientSecretHash as string | null) ?? null,
291
+ isConfidential: !!row.isConfidential,
292
+ redirectUris: Array.isArray(row.redirectUris) ? (row.redirectUris as string[]) : [],
293
+ grantTypes: Array.isArray(row.grantTypes) ? (row.grantTypes as string[]) : [],
294
+ scopes: parseScope(
295
+ Array.isArray(row.scopes) ? (row.scopes as string[]).join(' ') : (row.scopes as string)
296
+ ),
297
+ tokenEndpointAuthMethod:
298
+ (row.tokenEndpointAuthMethod as ClientRecord['tokenEndpointAuthMethod']) ?? 'none',
299
+ ownerAdminId: (row.ownerAdminId as string | null) ?? null,
300
+ createdByAdminId: (row.createdByAdminId as string | null) ?? null,
301
+ disabled: !!row.disabled,
302
+ createdAt: rowDateField(row, 'createdAt'),
303
+ lastUsedAt: rowDateField(row, 'lastUsedAt'),
304
+ };
305
+ }
306
+
307
+ /**
308
+ * Per RFC 8252 §7.3 — a redirect URI is "loopback" if its host is localhost or
309
+ * one of the literal IPv4/IPv6 loopback addresses. Port matching is deliberately
310
+ * not part of the check; that's what loopback leniency is for.
311
+ */
312
+ function isLoopbackUrl(u: URL): boolean {
313
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
314
+ const host = u.hostname.toLowerCase();
315
+ return host === 'localhost' || host === '127.0.0.1' || host === '[::1]' || host === '::1';
316
+ }
317
+
318
+ function rowDateField(row: Record<string, unknown>, key: string): string | null {
319
+ const value = row[key];
320
+ if (!value) return null;
321
+ if (value instanceof Date) return value.toISOString();
322
+ return typeof value === 'string' ? value : null;
323
+ }
324
+
325
+ /**
326
+ * Build a port-agnostic, host-canonicalized signature of a redirect URI set
327
+ * for orphan matching. Loopback hosts (`localhost`, `127.0.0.1`, `[::1]`) all
328
+ * collapse to `localhost` and the port is dropped — same rule the redirect-URI
329
+ * allowlist uses, so two registrations from the same MCP client only diff by
330
+ * a fresh loopback port still match.
331
+ */
332
+ function canonicalUriSetSig(uris: string[]): string {
333
+ const canon = uris.map(canonicalizeUri).filter((s) => s.length > 0);
334
+ return JSON.stringify(canon.sort());
335
+ }
336
+
337
+ function canonicalizeUri(uri: string): string {
338
+ let u: URL;
339
+ try {
340
+ u = new URL(uri);
341
+ } catch {
342
+ return uri;
343
+ }
344
+ const host = u.hostname.toLowerCase();
345
+ const isLoopback =
346
+ (u.protocol === 'http:' || u.protocol === 'https:') &&
347
+ (host === 'localhost' || host === '127.0.0.1' || host === '[::1]' || host === '::1');
348
+ if (isLoopback) {
349
+ return `${u.protocol}//localhost${u.pathname}${u.search}`;
350
+ }
351
+ return `${u.protocol}//${host}${u.port ? ':' + u.port : ''}${u.pathname}${u.search}`;
352
+ }
353
+
354
+ async function hasAnyRelated(strapi: Core.Strapi, clientId: string): Promise<boolean> {
355
+ const checks = await Promise.all([
356
+ strapi.db.query('plugin::mcp-server.oauth-consent').count({ where: { clientId } }),
357
+ strapi.db.query('plugin::mcp-server.oauth-auth-code').count({ where: { clientId } }),
358
+ strapi.db.query('plugin::mcp-server.oauth-refresh-token').count({ where: { clientId } }),
359
+ ]);
360
+ return checks.some((n) => (n ?? 0) > 0);
361
+ }
362
+
363
+ function validateRedirectUris(uris: string[]): void {
364
+ if (!Array.isArray(uris) || uris.length === 0) {
365
+ throw new Error('redirectUris must be a non-empty array');
366
+ }
367
+ for (const u of uris) {
368
+ if (typeof u !== 'string') throw new Error('redirectUri must be a string');
369
+ let parsed: URL;
370
+ try {
371
+ parsed = new URL(u);
372
+ } catch {
373
+ throw new Error(`invalid redirectUri: ${u}`);
374
+ }
375
+ if (parsed.protocol !== 'https:' && parsed.hostname !== 'localhost' && parsed.hostname !== '127.0.0.1' && parsed.protocol !== 'http:') {
376
+ // permit non-http(s) custom schemes (e.g. vscode://) — but no javascript:
377
+ if (parsed.protocol === 'javascript:' || parsed.protocol === 'data:') {
378
+ throw new Error(`unsafe redirectUri scheme: ${parsed.protocol}`);
379
+ }
380
+ }
381
+ if (parsed.protocol === 'http:' && parsed.hostname !== 'localhost' && parsed.hostname !== '127.0.0.1') {
382
+ throw new Error(`http:// redirectUri only allowed for loopback: ${u}`);
383
+ }
384
+ if (parsed.hash) throw new Error('redirectUri cannot include a fragment');
385
+ }
386
+ }
@@ -0,0 +1,38 @@
1
+ 'use strict';
2
+
3
+ import type { Core } from '@strapi/strapi';
4
+ import { getConfig } from '../../config';
5
+ import { scopeString, type Scope } from './scopes';
6
+
7
+ const UID = 'plugin::mcp-server.oauth-consent';
8
+
9
+ export default ({ strapi }: { strapi: Core.Strapi }) => ({
10
+ /**
11
+ * Check whether a (client, admin, scope-set) consent is still active.
12
+ * Returns false when rememberDays is 0 — i.e. always prompt.
13
+ */
14
+ async hasActiveConsent(
15
+ clientId: string,
16
+ adminUserId: string,
17
+ scopes: Scope[]
18
+ ): Promise<boolean> {
19
+ const cfg = getConfig(strapi);
20
+ if (cfg.oauth.consent.rememberDays <= 0) return false;
21
+ const row = await strapi.db.query(UID).findOne({
22
+ where: { clientId, adminUserId, scope: scopeString(scopes) },
23
+ });
24
+ if (!row) return false;
25
+ return new Date(row.expiresAt).getTime() > Date.now();
26
+ },
27
+
28
+ async record(clientId: string, adminUserId: string, scopes: Scope[]): Promise<void> {
29
+ const cfg = getConfig(strapi);
30
+ const grantedAt = new Date();
31
+ const expiresAt = new Date(
32
+ grantedAt.getTime() + cfg.oauth.consent.rememberDays * 86400 * 1000
33
+ );
34
+ await strapi.db.query(UID).create({
35
+ data: { clientId, adminUserId, scope: scopeString(scopes), grantedAt, expiresAt },
36
+ });
37
+ },
38
+ });
@@ -0,0 +1,32 @@
1
+ 'use strict';
2
+
3
+ import type { Core } from '@strapi/strapi';
4
+ import { authorizationServerUrl } from './audience';
5
+
6
+ /**
7
+ * RFC 6749 / 9728 token-error payload — used by 4xx responses on /oauth/*
8
+ * and /mcp. Never include token bodies or PII in error_description.
9
+ */
10
+ export interface OAuthErrorPayload {
11
+ error: string;
12
+ error_description?: string;
13
+ error_uri?: string;
14
+ }
15
+
16
+ export function bearerChallenge(
17
+ strapi: Core.Strapi,
18
+ opts: { error?: string; error_description?: string; scope?: string } = {}
19
+ ): string {
20
+ const asUrl = authorizationServerUrl(strapi);
21
+ const parts: string[] = ['Bearer realm="mcp"'];
22
+ if (opts.error) parts.push(`error="${opts.error}"`);
23
+ if (opts.error_description) {
24
+ // strip quotes and CRLF to keep the header well-formed
25
+ const safe = opts.error_description.replace(/["\r\n]/g, '');
26
+ parts.push(`error_description="${safe}"`);
27
+ }
28
+ if (opts.scope) parts.push(`scope="${opts.scope}"`);
29
+ // Points at our actual route, which is mounted at the host root, not under /mcp.
30
+ parts.push(`resource_metadata="${asUrl}/.well-known/oauth-protected-resource"`);
31
+ return parts.join(', ');
32
+ }
@@ -0,0 +1,34 @@
1
+ 'use strict';
2
+
3
+ import { createHash, timingSafeEqual } from 'crypto';
4
+
5
+ const VERIFIER_RE = /^[A-Za-z0-9\-._~]{43,128}$/;
6
+
7
+ export function base64url(buf: Buffer | Uint8Array): string {
8
+ return Buffer.from(buf).toString('base64url');
9
+ }
10
+
11
+ /** RFC 7636 §4.1 — code_verifier charset and length. */
12
+ export function isValidVerifier(verifier: string): boolean {
13
+ return typeof verifier === 'string' && VERIFIER_RE.test(verifier);
14
+ }
15
+
16
+ /** SHA-256(code_verifier) → base64url. Only S256 is supported (plain is rejected upstream). */
17
+ export function s256Challenge(verifier: string): string {
18
+ return base64url(createHash('sha256').update(verifier).digest());
19
+ }
20
+
21
+ /**
22
+ * Constant-time comparison of the S256-hashed verifier against the stored
23
+ * challenge. Inputs must be base64url strings; mismatched lengths fail fast.
24
+ */
25
+ export function verifyS256(verifier: string, storedChallenge: string): boolean {
26
+ if (!isValidVerifier(verifier)) return false;
27
+ const computed = s256Challenge(verifier);
28
+ if (computed.length !== storedChallenge.length) return false;
29
+ try {
30
+ return timingSafeEqual(Buffer.from(computed), Buffer.from(storedChallenge));
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
@@ -0,0 +1,42 @@
1
+ 'use strict';
2
+
3
+ export const ALL_SCOPES = [
4
+ 'strapi:content:read',
5
+ 'strapi:content:write',
6
+ 'strapi:media:read',
7
+ 'strapi:media:write',
8
+ ] as const;
9
+
10
+ export type Scope = (typeof ALL_SCOPES)[number];
11
+
12
+ export const SCOPE_LABELS: Record<Scope, string> = {
13
+ 'strapi:content:read': 'Read content (list types, schemas, entries)',
14
+ 'strapi:content:write': 'Create and update content entries (draft only)',
15
+ 'strapi:media:read': 'List media files',
16
+ 'strapi:media:write': 'Upload media files',
17
+ };
18
+
19
+ export function parseScope(input: unknown): Scope[] {
20
+ if (typeof input !== 'string') return [];
21
+ const parts = input
22
+ .split(/\s+/)
23
+ .map((s) => s.trim())
24
+ .filter(Boolean);
25
+ const out: Scope[] = [];
26
+ for (const p of parts) {
27
+ if ((ALL_SCOPES as readonly string[]).includes(p)) out.push(p as Scope);
28
+ }
29
+ return [...new Set(out)];
30
+ }
31
+
32
+ export function scopeString(scopes: Scope[]): string {
33
+ return [...new Set(scopes)].sort().join(' ');
34
+ }
35
+
36
+ export function isSubsetOf(requested: Scope[], allowed: Scope[]): boolean {
37
+ return requested.every((s) => allowed.includes(s));
38
+ }
39
+
40
+ export function hasScope(granted: Scope[], required: Scope): boolean {
41
+ return granted.includes(required);
42
+ }