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,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
|
+
}
|