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,166 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import { generateKeyPair, exportJWK, importJWK, type JWK, type KeyLike } from 'jose';
|
|
4
|
+
import {
|
|
5
|
+
createCipheriv,
|
|
6
|
+
createDecipheriv,
|
|
7
|
+
hkdfSync,
|
|
8
|
+
randomBytes,
|
|
9
|
+
} from 'crypto';
|
|
10
|
+
import type { Core } from '@strapi/strapi';
|
|
11
|
+
|
|
12
|
+
const UID = 'plugin::mcp-server.oauth-signing-key';
|
|
13
|
+
|
|
14
|
+
export interface ActiveKey {
|
|
15
|
+
kid: string;
|
|
16
|
+
alg: string;
|
|
17
|
+
publicJwk: JWK;
|
|
18
|
+
privateKey: KeyLike;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface KeyRow {
|
|
22
|
+
id: number;
|
|
23
|
+
kid: string;
|
|
24
|
+
alg: string;
|
|
25
|
+
publicJwk: JWK;
|
|
26
|
+
privateJwkEncrypted: string;
|
|
27
|
+
retiredAt: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let cached: ActiveKey | null = null;
|
|
31
|
+
|
|
32
|
+
export default ({ strapi }: { strapi: Core.Strapi }) => ({
|
|
33
|
+
/**
|
|
34
|
+
* Generate-on-first-boot. Idempotent: if an active (non-retired) key exists
|
|
35
|
+
* AND can be decrypted, load and cache it. If a row exists but can't be
|
|
36
|
+
* decrypted (e.g. legacy null/garbled value from a prior boot before this
|
|
37
|
+
* fix), the row is dropped and a new key is minted in its place.
|
|
38
|
+
*/
|
|
39
|
+
async ensureActiveKey(): Promise<ActiveKey> {
|
|
40
|
+
if (cached) return cached;
|
|
41
|
+
|
|
42
|
+
const existing = (await strapi.db.query(UID).findOne({
|
|
43
|
+
where: { retiredAt: { $null: true } },
|
|
44
|
+
orderBy: { id: 'desc' },
|
|
45
|
+
})) as KeyRow | null;
|
|
46
|
+
|
|
47
|
+
if (existing) {
|
|
48
|
+
try {
|
|
49
|
+
const decrypted = decryptBlob(strapi, existing.privateJwkEncrypted);
|
|
50
|
+
const jwk = JSON.parse(decrypted) as JWK;
|
|
51
|
+
const privateKey = (await importJWK(jwk, existing.alg)) as KeyLike;
|
|
52
|
+
cached = {
|
|
53
|
+
kid: existing.kid,
|
|
54
|
+
alg: existing.alg,
|
|
55
|
+
publicJwk: { ...existing.publicJwk, kid: existing.kid, alg: existing.alg, use: 'sig' },
|
|
56
|
+
privateKey,
|
|
57
|
+
};
|
|
58
|
+
return cached;
|
|
59
|
+
} catch (err) {
|
|
60
|
+
strapi.log.warn(
|
|
61
|
+
`[mcp-server] existing signing key kid=${existing.kid} could not be decrypted (${
|
|
62
|
+
(err as Error).message
|
|
63
|
+
}); discarding and regenerating`
|
|
64
|
+
);
|
|
65
|
+
await strapi.db.query(UID).delete({ where: { id: existing.id } });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const { publicKey, privateKey } = await generateKeyPair('RS256', { modulusLength: 2048 });
|
|
70
|
+
const publicJwk = await exportJWK(publicKey);
|
|
71
|
+
const privateJwk = await exportJWK(privateKey);
|
|
72
|
+
const kid = randomBytes(16).toString('hex');
|
|
73
|
+
publicJwk.kid = kid;
|
|
74
|
+
publicJwk.alg = 'RS256';
|
|
75
|
+
publicJwk.use = 'sig';
|
|
76
|
+
|
|
77
|
+
const encrypted = encryptBlob(strapi, JSON.stringify(privateJwk));
|
|
78
|
+
await strapi.db.query(UID).create({
|
|
79
|
+
data: {
|
|
80
|
+
kid,
|
|
81
|
+
alg: 'RS256',
|
|
82
|
+
publicJwk,
|
|
83
|
+
privateJwkEncrypted: encrypted,
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
cached = { kid, alg: 'RS256', publicJwk, privateKey };
|
|
88
|
+
strapi.log.info(`[mcp-server] minted OAuth signing key kid=${kid}`);
|
|
89
|
+
return cached;
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
async getActiveKey(): Promise<ActiveKey> {
|
|
93
|
+
return cached ?? this.ensureActiveKey();
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
/** JWKS endpoint payload — public keys only, includes retired-but-not-purged. */
|
|
97
|
+
async publicJwks(): Promise<{ keys: JWK[] }> {
|
|
98
|
+
const rows = (await strapi.db.query(UID).findMany({
|
|
99
|
+
orderBy: { id: 'desc' },
|
|
100
|
+
limit: 10,
|
|
101
|
+
})) as KeyRow[];
|
|
102
|
+
return {
|
|
103
|
+
keys: rows.map((r) => ({ ...r.publicJwk, kid: r.kid, alg: r.alg, use: 'sig' })),
|
|
104
|
+
};
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
invalidateCache(): void {
|
|
108
|
+
cached = null;
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Derive a 32-byte AES-256 key from APP_KEYS + ADMIN_JWT_SECRET via HKDF.
|
|
114
|
+
* This is self-contained — we don't rely on Strapi's admin encryption service,
|
|
115
|
+
* which requires extra config and is silently nullable in some setups.
|
|
116
|
+
*
|
|
117
|
+
* Threat model: rotating APP_KEYS invalidates stored signing keys; we handle
|
|
118
|
+
* that by regenerating on decrypt failure (see ensureActiveKey above).
|
|
119
|
+
*/
|
|
120
|
+
function deriveKey(strapi: Core.Strapi): Buffer {
|
|
121
|
+
const appKeys = (strapi.config.get('app.keys') as string[] | undefined) ?? [];
|
|
122
|
+
const adminSecret =
|
|
123
|
+
(strapi.config.get('admin.auth.secret') as string | undefined) ?? '';
|
|
124
|
+
const material = `${appKeys.join('|')}|${adminSecret}`;
|
|
125
|
+
if (material === '|') {
|
|
126
|
+
throw new Error(
|
|
127
|
+
'[mcp-server] APP_KEYS and ADMIN_JWT_SECRET must be configured for signing-key encryption'
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
return Buffer.from(
|
|
131
|
+
hkdfSync('sha256', material, 'strapi-mcp-server-salt', 'oauth-signing-key:v1', 32)
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function encryptBlob(strapi: Core.Strapi, plaintext: string): string {
|
|
136
|
+
const key = deriveKey(strapi);
|
|
137
|
+
const iv = randomBytes(12);
|
|
138
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
139
|
+
const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
140
|
+
const tag = cipher.getAuthTag();
|
|
141
|
+
return `v1:${iv.toString('base64url')}:${tag.toString('base64url')}:${enc.toString('base64url')}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function decryptBlob(strapi: Core.Strapi, blob: string): string {
|
|
145
|
+
if (!blob || typeof blob !== 'string') {
|
|
146
|
+
throw new Error('encrypted blob is null or non-string');
|
|
147
|
+
}
|
|
148
|
+
const parts = blob.split(':');
|
|
149
|
+
if (parts.length !== 4 || parts[0] !== 'v1') {
|
|
150
|
+
throw new Error('unrecognized blob format');
|
|
151
|
+
}
|
|
152
|
+
const key = deriveKey(strapi);
|
|
153
|
+
const iv = Buffer.from(parts[1], 'base64url');
|
|
154
|
+
const tag = Buffer.from(parts[2], 'base64url');
|
|
155
|
+
const enc = Buffer.from(parts[3], 'base64url');
|
|
156
|
+
// Pin authTagLength to 16 bytes (GCM's full 128-bit tag) so an attacker who
|
|
157
|
+
// can forge the stored blob can't downgrade to a shorter tag and brute-force
|
|
158
|
+
// it. encryptBlob always emits a 16-byte tag via getAuthTag(); reject any
|
|
159
|
+
// blob that doesn't match before handing it to setAuthTag.
|
|
160
|
+
if (tag.length !== 16) {
|
|
161
|
+
throw new Error('GCM auth tag has unexpected length');
|
|
162
|
+
}
|
|
163
|
+
const decipher = createDecipheriv('aes-256-gcm', key, iv, { authTagLength: 16 });
|
|
164
|
+
decipher.setAuthTag(tag);
|
|
165
|
+
return Buffer.concat([decipher.update(enc), decipher.final()]).toString('utf8');
|
|
166
|
+
}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
SignJWT,
|
|
5
|
+
jwtVerify,
|
|
6
|
+
type JWTPayload,
|
|
7
|
+
createLocalJWKSet,
|
|
8
|
+
createRemoteJWKSet,
|
|
9
|
+
type JWTVerifyGetKey,
|
|
10
|
+
} from 'jose';
|
|
11
|
+
import { createHash, randomBytes } from 'crypto';
|
|
12
|
+
import type { Core } from '@strapi/strapi';
|
|
13
|
+
import { getConfig } from '../../config';
|
|
14
|
+
import { authorizationServerUrl, canonicalResourceUrl } from './audience';
|
|
15
|
+
import { ALL_SCOPES, scopeString, parseScope, type Scope } from './scopes';
|
|
16
|
+
|
|
17
|
+
// Remote-JWKS cache for external AS mode. jose's createRemoteJWKSet has its
|
|
18
|
+
// own internal HTTP cache (~10 min default); we just avoid recreating the
|
|
19
|
+
// callable on every request.
|
|
20
|
+
let externalJwksCache: { uri: string; jwks: JWTVerifyGetKey } | null = null;
|
|
21
|
+
function getExternalJwks(uri: string): JWTVerifyGetKey {
|
|
22
|
+
if (!externalJwksCache || externalJwksCache.uri !== uri) {
|
|
23
|
+
externalJwksCache = { uri, jwks: createRemoteJWKSet(new URL(uri)) };
|
|
24
|
+
}
|
|
25
|
+
return externalJwksCache.jwks;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const REFRESH_UID = 'plugin::mcp-server.oauth-refresh-token';
|
|
29
|
+
const REVOKE_UID = 'plugin::mcp-server.oauth-revocation';
|
|
30
|
+
|
|
31
|
+
export interface MintResult {
|
|
32
|
+
accessToken: string;
|
|
33
|
+
refreshToken: string;
|
|
34
|
+
accessTokenExpiresAt: Date;
|
|
35
|
+
refreshTokenExpiresAt: Date;
|
|
36
|
+
jti: string;
|
|
37
|
+
familyId: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface VerifiedClaims {
|
|
41
|
+
sub: string;
|
|
42
|
+
scope: Scope[];
|
|
43
|
+
clientId: string;
|
|
44
|
+
jti: string;
|
|
45
|
+
exp: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface RefreshRow {
|
|
49
|
+
id: number;
|
|
50
|
+
tokenHash: string;
|
|
51
|
+
familyId: string;
|
|
52
|
+
parentJti: string | null;
|
|
53
|
+
clientId: string;
|
|
54
|
+
adminUserId: string;
|
|
55
|
+
scope: string;
|
|
56
|
+
rotatedTo: string | null;
|
|
57
|
+
revoked: boolean;
|
|
58
|
+
expiresAt: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export default ({ strapi }: { strapi: Core.Strapi }) => {
|
|
62
|
+
const sha256 = (s: string) => createHash('sha256').update(s).digest('hex');
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
/**
|
|
66
|
+
* Mint a fresh access+refresh pair. New family — caller passes a stable
|
|
67
|
+
* familyId if this is a refresh rotation.
|
|
68
|
+
*/
|
|
69
|
+
async mint(opts: {
|
|
70
|
+
adminUserId: string;
|
|
71
|
+
clientId: string;
|
|
72
|
+
scope: Scope[];
|
|
73
|
+
familyId?: string;
|
|
74
|
+
parentJti?: string;
|
|
75
|
+
}): Promise<MintResult> {
|
|
76
|
+
const cfg = getConfig(strapi);
|
|
77
|
+
const key = await strapi.plugin('mcp-server').service('signing-keys').getActiveKey();
|
|
78
|
+
const now = Math.floor(Date.now() / 1000);
|
|
79
|
+
const jti = randomBytes(16).toString('hex');
|
|
80
|
+
const issuer = authorizationServerUrl(strapi);
|
|
81
|
+
const audience = canonicalResourceUrl(strapi);
|
|
82
|
+
|
|
83
|
+
const payload: JWTPayload = {
|
|
84
|
+
scope: scopeString(opts.scope),
|
|
85
|
+
client_id: opts.clientId,
|
|
86
|
+
azp: opts.clientId,
|
|
87
|
+
jti,
|
|
88
|
+
};
|
|
89
|
+
const accessToken = await new SignJWT(payload)
|
|
90
|
+
.setProtectedHeader({ alg: key.alg, kid: key.kid, typ: 'at+jwt' })
|
|
91
|
+
.setIssuer(issuer)
|
|
92
|
+
.setSubject(opts.adminUserId)
|
|
93
|
+
.setAudience(audience)
|
|
94
|
+
.setIssuedAt(now)
|
|
95
|
+
.setExpirationTime(now + cfg.oauth.accessTokenTtlSec)
|
|
96
|
+
.sign(key.privateKey);
|
|
97
|
+
|
|
98
|
+
const refreshSecret = randomBytes(32).toString('base64url');
|
|
99
|
+
const familyId = opts.familyId ?? randomBytes(16).toString('hex');
|
|
100
|
+
const refreshExp = new Date((now + cfg.oauth.refreshTokenTtlSec) * 1000);
|
|
101
|
+
|
|
102
|
+
await strapi.db.query(REFRESH_UID).create({
|
|
103
|
+
data: {
|
|
104
|
+
tokenHash: sha256(refreshSecret),
|
|
105
|
+
familyId,
|
|
106
|
+
parentJti: opts.parentJti ?? null,
|
|
107
|
+
clientId: opts.clientId,
|
|
108
|
+
adminUserId: opts.adminUserId,
|
|
109
|
+
scope: scopeString(opts.scope),
|
|
110
|
+
revoked: false,
|
|
111
|
+
rotatedTo: null,
|
|
112
|
+
expiresAt: refreshExp,
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
accessToken,
|
|
118
|
+
refreshToken: refreshSecret,
|
|
119
|
+
accessTokenExpiresAt: new Date((now + cfg.oauth.accessTokenTtlSec) * 1000),
|
|
120
|
+
refreshTokenExpiresAt: refreshExp,
|
|
121
|
+
jti,
|
|
122
|
+
familyId,
|
|
123
|
+
};
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Verify an access JWT. Two modes, picked from `oauth.mode`:
|
|
128
|
+
*
|
|
129
|
+
* - **embedded** (default): verify against the plugin's own JWKS, check
|
|
130
|
+
* iss/aud/exp + revocation list. `sub` is already a Strapi admin user id.
|
|
131
|
+
* - **external**: verify against the configured external AS's JWKS, check
|
|
132
|
+
* iss/exp. Map the JWT's email-style claim back to a Strapi admin
|
|
133
|
+
* user (the JWT's `sub` is the external identity, NOT a Strapi id, so
|
|
134
|
+
* we resolve it server-side and present a Strapi admin id to callers).
|
|
135
|
+
*
|
|
136
|
+
* Throws Error('invalid_token' | 'expired') on failure.
|
|
137
|
+
*/
|
|
138
|
+
async verifyAccessToken(token: string): Promise<VerifiedClaims> {
|
|
139
|
+
const cfg = getConfig(strapi);
|
|
140
|
+
if (cfg.oauth.mode === 'external') {
|
|
141
|
+
return verifyExternal(strapi, token);
|
|
142
|
+
}
|
|
143
|
+
return verifyEmbedded(strapi, token);
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Atomically consume a refresh token: returns the previous row only if it
|
|
148
|
+
* was still rotatable. Reuse (rotatedTo set, or revoked) returns null AND
|
|
149
|
+
* triggers family-wide revocation.
|
|
150
|
+
*/
|
|
151
|
+
async consumeRefresh(refreshSecret: string): Promise<RefreshRow | null> {
|
|
152
|
+
const hash = sha256(refreshSecret);
|
|
153
|
+
const row = (await strapi.db.query(REFRESH_UID).findOne({
|
|
154
|
+
where: { tokenHash: hash },
|
|
155
|
+
})) as RefreshRow | null;
|
|
156
|
+
if (!row) return null;
|
|
157
|
+
|
|
158
|
+
if (new Date(row.expiresAt).getTime() < Date.now()) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
if (row.revoked || row.rotatedTo) {
|
|
162
|
+
// Reuse detection: nuke the whole family.
|
|
163
|
+
await this.revokeFamily(row.familyId);
|
|
164
|
+
strapi.log.warn(
|
|
165
|
+
`[mcp-server] refresh-token reuse detected family=${row.familyId} client=${row.clientId} — family revoked`
|
|
166
|
+
);
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
return row;
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
async markRotated(parentRowId: number, newRefreshHash: string): Promise<void> {
|
|
173
|
+
await strapi.db
|
|
174
|
+
.query(REFRESH_UID)
|
|
175
|
+
.update({ where: { id: parentRowId }, data: { rotatedTo: newRefreshHash } });
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
async revokeFamily(familyId: string): Promise<void> {
|
|
179
|
+
await strapi.db.query(REFRESH_UID).updateMany({
|
|
180
|
+
where: { familyId },
|
|
181
|
+
data: { revoked: true },
|
|
182
|
+
});
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
async revokeRefresh(refreshSecret: string): Promise<void> {
|
|
186
|
+
const hash = sha256(refreshSecret);
|
|
187
|
+
const row = (await strapi.db
|
|
188
|
+
.query(REFRESH_UID)
|
|
189
|
+
.findOne({ where: { tokenHash: hash } })) as RefreshRow | null;
|
|
190
|
+
if (row) await this.revokeFamily(row.familyId);
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
async revokeAccessJti(jti: string, expiresAt: Date): Promise<void> {
|
|
194
|
+
try {
|
|
195
|
+
await strapi.db.query(REVOKE_UID).create({ data: { jti, expiresAt } });
|
|
196
|
+
} catch {
|
|
197
|
+
// unique constraint collision — already revoked, ignore
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
async revokeAllForUser(adminUserId: string): Promise<void> {
|
|
202
|
+
await strapi.db.query(REFRESH_UID).updateMany({
|
|
203
|
+
where: { adminUserId, revoked: false },
|
|
204
|
+
data: { revoked: true },
|
|
205
|
+
});
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
/** Daily purge — drops expired refresh tokens and revocation entries. */
|
|
209
|
+
async purgeExpired(): Promise<void> {
|
|
210
|
+
const now = new Date();
|
|
211
|
+
await strapi.db.query(REFRESH_UID).deleteMany({ where: { expiresAt: { $lt: now } } });
|
|
212
|
+
await strapi.db.query(REVOKE_UID).deleteMany({ where: { expiresAt: { $lt: now } } });
|
|
213
|
+
await strapi.db
|
|
214
|
+
.query('plugin::mcp-server.oauth-auth-code')
|
|
215
|
+
.deleteMany({ where: { expiresAt: { $lt: now } } });
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
hash(s: string): string {
|
|
219
|
+
return sha256(s);
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
async function verifyEmbedded(
|
|
225
|
+
strapi: Core.Strapi,
|
|
226
|
+
token: string
|
|
227
|
+
): Promise<VerifiedClaims> {
|
|
228
|
+
const sk = strapi.plugin('mcp-server').service('signing-keys');
|
|
229
|
+
const jwks = createLocalJWKSet(await sk.publicJwks());
|
|
230
|
+
const issuer = authorizationServerUrl(strapi);
|
|
231
|
+
const audience = canonicalResourceUrl(strapi);
|
|
232
|
+
|
|
233
|
+
let claims: JWTPayload;
|
|
234
|
+
try {
|
|
235
|
+
const { payload } = await jwtVerify(token, jwks, { issuer, audience });
|
|
236
|
+
claims = payload;
|
|
237
|
+
} catch (err) {
|
|
238
|
+
const code = (err as { code?: string }).code;
|
|
239
|
+
if (code === 'ERR_JWT_EXPIRED') throw new Error('expired');
|
|
240
|
+
throw new Error('invalid_token');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const jti = typeof claims.jti === 'string' ? claims.jti : '';
|
|
244
|
+
if (!jti) throw new Error('invalid_token');
|
|
245
|
+
|
|
246
|
+
const revoked = await strapi.db.query(REVOKE_UID).findOne({ where: { jti } });
|
|
247
|
+
if (revoked) throw new Error('invalid_token');
|
|
248
|
+
|
|
249
|
+
const sub = typeof claims.sub === 'string' ? claims.sub : '';
|
|
250
|
+
const clientId = typeof claims.client_id === 'string' ? claims.client_id : '';
|
|
251
|
+
if (!sub || !clientId) throw new Error('invalid_token');
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
sub,
|
|
255
|
+
scope: parseScope(claims.scope),
|
|
256
|
+
clientId,
|
|
257
|
+
jti,
|
|
258
|
+
exp: typeof claims.exp === 'number' ? claims.exp : 0,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function verifyExternal(
|
|
263
|
+
strapi: Core.Strapi,
|
|
264
|
+
token: string
|
|
265
|
+
): Promise<VerifiedClaims> {
|
|
266
|
+
const cfg = getConfig(strapi);
|
|
267
|
+
const ext = cfg.oauth.external;
|
|
268
|
+
if (!ext) throw new Error('invalid_token');
|
|
269
|
+
const jwks = getExternalJwks(ext.jwksUri);
|
|
270
|
+
|
|
271
|
+
let claims: JWTPayload;
|
|
272
|
+
try {
|
|
273
|
+
const { payload } = await jwtVerify(token, jwks, {
|
|
274
|
+
issuer: ext.issuer,
|
|
275
|
+
// No audience check in external mode — external AS owns aud.
|
|
276
|
+
});
|
|
277
|
+
claims = payload;
|
|
278
|
+
} catch (err) {
|
|
279
|
+
const code = (err as { code?: string }).code;
|
|
280
|
+
if (code === 'ERR_JWT_EXPIRED') throw new Error('expired');
|
|
281
|
+
throw new Error('invalid_token');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const lookupClaim = ext.adminLookupClaim ?? 'email';
|
|
285
|
+
const lookupValue = (claims as Record<string, unknown>)[lookupClaim];
|
|
286
|
+
if (typeof lookupValue !== 'string' || !lookupValue) {
|
|
287
|
+
throw new Error('invalid_token');
|
|
288
|
+
}
|
|
289
|
+
const adminWhere =
|
|
290
|
+
lookupClaim === 'email' ? { email: lookupValue } : { username: lookupValue };
|
|
291
|
+
const admin = (await strapi.db
|
|
292
|
+
.query('admin::user')
|
|
293
|
+
.findOne({ where: adminWhere })) as
|
|
294
|
+
| { id: number; isActive?: boolean; blocked?: boolean }
|
|
295
|
+
| null;
|
|
296
|
+
if (!admin || admin.isActive === false || admin.blocked) {
|
|
297
|
+
throw new Error('invalid_token');
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const clientId =
|
|
301
|
+
typeof claims.azp === 'string'
|
|
302
|
+
? claims.azp
|
|
303
|
+
: typeof claims.client_id === 'string'
|
|
304
|
+
? claims.client_id
|
|
305
|
+
: 'external';
|
|
306
|
+
|
|
307
|
+
// Scope handling in external mode:
|
|
308
|
+
// - enforceScopes: true → require strapi:* scopes in the JWT (operator
|
|
309
|
+
// must define them as Client Scopes in the IdP)
|
|
310
|
+
// - enforceScopes: false (default) → grant the full surface, leaving
|
|
311
|
+
// granular control to Strapi RBAC + per-tool toggles. Keeps setup
|
|
312
|
+
// cross-IdP portable without per-vendor scope registration.
|
|
313
|
+
const scope: Scope[] = ext.enforceScopes
|
|
314
|
+
? parseScope(claims.scope)
|
|
315
|
+
: [...ALL_SCOPES];
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
sub: String(admin.id),
|
|
319
|
+
scope,
|
|
320
|
+
clientId,
|
|
321
|
+
jti: typeof claims.jti === 'string' ? claims.jti : '',
|
|
322
|
+
exp: typeof claims.exp === 'number' ? claims.exp : 0,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import type { Core } from '@strapi/strapi';
|
|
4
|
+
|
|
5
|
+
const INTERNAL_UID = /^(admin::|strapi::|plugin::users-permissions\.(role|permission)|plugin::i18n\.locale|plugin::upload\.(folder|file)$|plugin::mcp-server\.)/;
|
|
6
|
+
|
|
7
|
+
export interface PrincipalContext {
|
|
8
|
+
user: { id: number | string; isActive?: boolean };
|
|
9
|
+
permissions: unknown[];
|
|
10
|
+
isSuperAdmin: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default ({ strapi }: { strapi: Core.Strapi }) => ({
|
|
14
|
+
/**
|
|
15
|
+
* Load an admin user and their permissions. Used both at JWT verification time
|
|
16
|
+
* (to confirm the principal still exists and is active) and at tool-call time
|
|
17
|
+
* for RBAC enforcement.
|
|
18
|
+
*
|
|
19
|
+
* We bypass `admin::user.findOne(...)` because its `populate: ['roles']` path
|
|
20
|
+
* triggered a Knex "Undefined binding" error in some Strapi installs. Going
|
|
21
|
+
* direct to `strapi.db.query` is more predictable and gives us exactly the
|
|
22
|
+
* shape we need (user with roles relation).
|
|
23
|
+
*/
|
|
24
|
+
async loadPrincipal(adminUserId: string | number): Promise<PrincipalContext | null> {
|
|
25
|
+
const id = typeof adminUserId === 'string' ? Number(adminUserId) || adminUserId : adminUserId;
|
|
26
|
+
|
|
27
|
+
const user = await strapi.db.query('admin::user').findOne({
|
|
28
|
+
where: { id },
|
|
29
|
+
populate: { roles: true },
|
|
30
|
+
});
|
|
31
|
+
if (!user || user.isActive === false || user.blocked) return null;
|
|
32
|
+
|
|
33
|
+
const roleSvc = strapi.service('admin::role');
|
|
34
|
+
let isSuperAdmin = false;
|
|
35
|
+
try {
|
|
36
|
+
isSuperAdmin = (await roleSvc.hasSuperAdminRole(user)) === true;
|
|
37
|
+
} catch {
|
|
38
|
+
// Fallback: check role code locally.
|
|
39
|
+
isSuperAdmin =
|
|
40
|
+
Array.isArray(user.roles) &&
|
|
41
|
+
user.roles.some((r: { code?: string }) => r.code === 'strapi-super-admin');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const permSvc = strapi.service('admin::permission');
|
|
45
|
+
let permissions: unknown[] = [];
|
|
46
|
+
try {
|
|
47
|
+
permissions = await permSvc.findUserPermissions({ user });
|
|
48
|
+
} catch (err) {
|
|
49
|
+
strapi.log.warn('[mcp-server] findUserPermissions failed', err as Error);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { user, permissions, isSuperAdmin };
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Content-manager-equivalent RBAC check for a UID + action.
|
|
57
|
+
* action: 'read' | 'create' | 'update' | 'delete' | 'publish'
|
|
58
|
+
* Internal UIDs are denied outright regardless of role.
|
|
59
|
+
*/
|
|
60
|
+
async canActionOnUid(
|
|
61
|
+
principal: PrincipalContext,
|
|
62
|
+
uid: string,
|
|
63
|
+
action: 'read' | 'create' | 'update' | 'delete' | 'publish'
|
|
64
|
+
): Promise<boolean> {
|
|
65
|
+
if (INTERNAL_UID.test(uid)) return false;
|
|
66
|
+
if (principal.isSuperAdmin) return true;
|
|
67
|
+
|
|
68
|
+
const actionId = `plugin::content-manager.explorer.${action}`;
|
|
69
|
+
return (principal.permissions as Array<{ action: string; subject: string | null }>).some(
|
|
70
|
+
(p) => p.action === actionId && (p.subject === uid || p.subject === null)
|
|
71
|
+
);
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
isInternalUid(uid: string): boolean {
|
|
75
|
+
return INTERNAL_UID.test(uid);
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
/** Returns allowed UIDs (collectionType + singleType, minus the denylist). */
|
|
79
|
+
listAllowedUids(): string[] {
|
|
80
|
+
const cts = strapi.contentTypes as unknown as Record<string, { kind?: string }>;
|
|
81
|
+
return Object.keys(cts).filter(
|
|
82
|
+
(uid) =>
|
|
83
|
+
!INTERNAL_UID.test(uid) &&
|
|
84
|
+
(cts[uid].kind === 'collectionType' || cts[uid].kind === 'singleType')
|
|
85
|
+
);
|
|
86
|
+
},
|
|
87
|
+
});
|