#!/usr/bin/env node
/**
* Test: JWT Auth Flow (External IdP Simulation)
*
* Uses a local JWT provider (RS256 key pair + JWKS server) to test
* the full MCP JWT authentication path end-to-end:
*
* MCP Client → Bearer JWT (RS256, signed by local provider)
* → MCP Auth Middleware (auth: 'jwt')
* → auth.service.verifyJWT() → jose.jwtVerify()
* → JWKS fetch from local provider
* → email claim → Strapi admin user lookup
* → _mcpClaims { role, tenant, permissions }
* → authorization.service.authorize()
*
* Tests:
* 1. Valid JWT with Admin role → full access
* 2. Valid JWT with Author role → restricted access + ownership
* 3. Valid JWT with Reader role → read-only
* 4. JWT role claim overrides Strapi admin role derivation
* 5. Tenant claim injection for multi-tenant scoping
* 6. Expired JWT → rejected
* 7. Wrong issuer → rejected
* 8. Wrong audience → rejected
* 9. Missing email claim → rejected
* 10. JWT for non-existent Strapi user → 403
* 11. MCP_SECRET_KEY header validation
*
* ⚠️ IMPORTANT: This test requires Strapi to be configured with auth: 'jwt'
* and the JWT_* environment variables pointing to our local provider.
* The test will output instructions if the plugin is not in JWT mode.
*
* Prerequisites:
* - Running Strapi 5.x with MCP plugin in JWT auth mode
* - STRAPI_ADMIN_EMAIL / STRAPI_ADMIN_PASSWORD env vars (for user setup)
* - Plugin config: auth: 'jwt'
* - Environment:
* JWT_JWKS_URI=http://localhost:9876/.well-known/jwks.json
* JWT_ISSUER=http://localhost:9876
* JWT_AUDIENCE=mcp-test
*/
import {
requireStrapi,
section,
pass,
fail,
skip,
assert,
summary,
getSuperAdminToken,
createAdminUser,
deleteAdminUser,
McpTestClient,
MCP_ENDPOINT,
STRAPI_URL,
fetchJSON,
} from './helpers.js';
import { JwtProvider } from './jwt-provider.js';
async function main() {
console.log('\x1b[1m=== Test: JWT Auth Flow (External IdP Simulation) ===\x1b[0m');
await requireStrapi();
// ─── Start local JWT provider ──────────────────────────────────────
section('JWT Provider Setup');
const provider = new JwtProvider({ port: 9876 });
await provider.start();
pass(`JWT provider started on port ${provider.port}`);
console.log(` JWKS URI: ${provider.jwksUri}`);
console.log(` Issuer: ${provider.issuer}`);
console.log(` Audience: ${provider.audience}`);
const env = provider.getEnvVars();
console.log('\n Required Strapi env vars for JWT mode:');
for (const [k, v] of Object.entries(env)) {
console.log(` ${k}=${v}`);
}
// ─── Verify JWKS endpoint is accessible ────────────────────────────
section('JWKS Endpoint Verification');
try {
const jwksResp = await fetch(provider.jwksUri);
const jwks = await jwksResp.json();
assert(jwksResp.status === 200, 'JWKS endpoint returns 200', `HTTP ${jwksResp.status}`);
assert(jwks.keys?.length === 1, 'JWKS has one key', `${jwks.keys?.length} keys`);
assert(jwks.keys[0].kty === 'RSA', 'Key type is RSA', `kty: ${jwks.keys[0].kty}`);
assert(jwks.keys[0].alg === 'RS256', 'Algorithm is RS256', `alg: ${jwks.keys[0].alg}`);
assert(jwks.keys[0].kid === 'test-key-1', 'Key ID matches', `kid: ${jwks.keys[0].kid}`);
} catch (err) {
fail('JWKS endpoint', err.message);
}
// ─── Setup: admin token for user management ────────────────────────
section('Admin Setup (for user management)');
const adminToken = await getSuperAdminToken();
if (!adminToken) {
fail('Super admin login', 'Cannot login — need admin to create test users');
await provider.stop();
summary();
process.exit(1);
}
pass('Super admin login for user management');
// ─── Check if MCP plugin is in JWT mode ────────────────────────────
section('MCP Plugin Auth Mode Detection');
// Try a JWT auth request to see if the plugin accepts it
const testJwt = await provider.sign({ email: 'admin@example.com', role: 'Admin' });
const probeResp = await fetch(MCP_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${testJwt}`,
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2025-03-26',
capabilities: {},
clientInfo: { name: 'jwt-test', version: '1.0.0' },
},
}),
});
const probeStatus = probeResp.status;
const probeSessionId = probeResp.headers.get('mcp-session-id');
// Clean up probe session if it succeeded
if (probeSessionId) {
await fetch(MCP_ENDPOINT, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'mcp-session-id': probeSessionId,
'Authorization': `Bearer ${testJwt}`,
},
});
}
// Determine if plugin is in JWT mode
const isJwtMode = probeStatus === 200 && probeSessionId;
const isAdminMode = probeStatus === 401; // JWT rejected = likely admin mode
if (isJwtMode) {
pass('MCP plugin is in JWT auth mode');
} else if (isAdminMode) {
console.log(`\n \x1b[33m⚠ MCP plugin appears to be in 'admin' mode (HTTP ${probeStatus})\x1b[0m`);
console.log(' To run JWT auth tests, configure Strapi MCP plugin:');
console.log('');
console.log(' 1. Set plugin config to auth: "jwt" in your Strapi config');
console.log(' 2. Set environment variables:');
for (const [k, v] of Object.entries(env)) {
console.log(` export ${k}=${v}`);
}
console.log(' 3. Restart Strapi');
console.log(' 4. Re-run this test');
console.log('');
// Still run what we can — token generation and JWKS work
skip('JWT auth tests', 'Plugin not in JWT mode — running token generation tests only');
} else {
console.log(` \x1b[33mUnexpected probe status: HTTP ${probeStatus}\x1b[0m`);
}
// ─── Token Generation Tests (always run) ───────────────────────────
section('Token Generation & Structure');
try {
// Admin token
const adminJwt = await provider.sign({ email: 'admin@example.com', role: 'Admin' });
assert(adminJwt.split('.').length === 3, 'Admin JWT has 3 parts (header.payload.signature)', 'Invalid JWT structure');
// Decode payload (no verification, just inspect)
const payload = JSON.parse(Buffer.from(adminJwt.split('.')[1], 'base64url').toString());
assert(payload.email === 'admin@example.com', 'JWT email claim correct', `email: ${payload.email}`);
assert(payload.role === 'Admin', 'JWT role claim correct', `role: ${payload.role}`);
assert(payload.iss === provider.issuer, 'JWT issuer correct', `iss: ${payload.iss}`);
assert(payload.aud === provider.audience, 'JWT audience correct', `aud: ${payload.aud}`);
assert(payload.exp > Date.now() / 1000, 'JWT not expired', `exp: ${payload.exp}`);
assert(payload.sub === 'admin@example.com', 'JWT subject set to email', `sub: ${payload.sub}`);
// Author token with tenant
const authorJwt = await provider.sign({
email: 'author@test.local',
role: 'Author',
tenant: 'acme-corp',
});
const authorPayload = JSON.parse(Buffer.from(authorJwt.split('.')[1], 'base64url').toString());
assert(authorPayload.role === 'Author', 'Author JWT role claim', `role: ${authorPayload.role}`);
assert(authorPayload.tenant === 'acme-corp', 'Author JWT tenant claim', `tenant: ${authorPayload.tenant}`);
// Reader token with permissions
const readerJwt = await provider.sign({
email: 'reader@test.local',
role: 'Reader',
permissions: ['content.read', 'schema.read'],
});
const readerPayload = JSON.parse(Buffer.from(readerJwt.split('.')[1], 'base64url').toString());
assert(readerPayload.role === 'Reader', 'Reader JWT role claim', `role: ${readerPayload.role}`);
assert(
Array.isArray(readerPayload.permissions) && readerPayload.permissions.includes('content.read'),
'Reader JWT permissions claim',
`permissions: ${JSON.stringify(readerPayload.permissions)}`
);
} catch (err) {
fail('Token generation', err.message);
}
// ─── Expired Token Generation ──────────────────────────────────────
section('Expired & Invalid Token Generation');
try {
const expiredJwt = await provider.signExpired({ email: 'expired@test.local' });
const expPayload = JSON.parse(Buffer.from(expiredJwt.split('.')[1], 'base64url').toString());
assert(expPayload.exp < Date.now() / 1000, 'Expired JWT has past expiration', `exp: ${new Date(expPayload.exp * 1000).toISOString()}`);
const wrongIssJwt = await provider.signWrongIssuer({ email: 'wrong@test.local' });
const wrongIssPayload = JSON.parse(Buffer.from(wrongIssJwt.split('.')[1], 'base64url').toString());
assert(wrongIssPayload.iss === 'https://wrong-issuer.example.com', 'Wrong-issuer JWT has bad issuer', `iss: ${wrongIssPayload.iss}`);
const wrongAudJwt = await provider.signWrongAudience({ email: 'wrong@test.local' });
const wrongAudPayload = JSON.parse(Buffer.from(wrongAudJwt.split('.')[1], 'base64url').toString());
assert(wrongAudPayload.aud === 'wrong-audience', 'Wrong-audience JWT has bad audience', `aud: ${wrongAudPayload.aud}`);
} catch (err) {
fail('Invalid token generation', err.message);
}
// ─── JWT Mode Tests (only if plugin is in JWT mode) ────────────────
if (isJwtMode) {
// Create test users that the JWT email claims will map to
const testUsers = [
{ email: 'jwt-admin@test.local', role: 'Admin', strapiRole: 'Super Admin' },
{ email: 'jwt-editor@test.local', role: 'Publisher', strapiRole: 'Editor' },
{ email: 'jwt-author@test.local', role: 'Author', strapiRole: 'Author' },
];
const createdUsers = [];
section('Create Test Users for JWT Mapping');
for (const u of testUsers) {
try {
const result = await createAdminUser(adminToken, {
email: u.email,
password: 'JwtTest1234!',
firstname: u.role,
lastname: 'JwtTest',
roleName: u.strapiRole,
});
createdUsers.push(u.email);
pass(`Created ${u.role} user: ${u.email}`);
} catch (err) {
skip(`${u.role} user creation`, err.message);
}
}
// ── Test 1: Valid Admin JWT → full access ──
section('Valid Admin JWT → Full Access');
try {
const jwt = await provider.sign({ email: 'jwt-admin@test.local', role: 'Admin' });
const mcp = new McpTestClient(jwt);
const init = await mcp.initialize();
if (init.sessionId) {
pass('Admin JWT: MCP session created');
const typesRes = await mcp.callTool('list_content_types', {});
assert(!McpTestClient.isError(typesRes), 'Admin JWT: can list content types', 'Tool call failed');
const types = McpTestClient.parseResult(typesRes) || [];
const collections = types.filter((t) => t.kind === 'collectionType');
if (collections.length > 0) {
const ct = collections[0].uid;
// Admin should be able to create
const createRes = await mcp.callTool('create_entry', {
contentType: ct,
data: JSON.stringify({ title: `JWT Admin Test ${Date.now()}` }),
});
if (!McpTestClient.isError(createRes)) {
const entry = McpTestClient.parseResult(createRes);
pass(`Admin JWT: create_entry succeeds (${entry?.documentId})`);
// And delete
if (entry?.documentId) {
const delRes = await mcp.callTool('delete_entry', { contentType: ct, documentId: entry.documentId });
assert(!McpTestClient.isError(delRes), 'Admin JWT: delete_entry succeeds', McpTestClient.parseResult(delRes));
}
} else {
fail('Admin JWT create', McpTestClient.parseResult(createRes));
}
}
await mcp.close();
} else {
fail('Admin JWT session', `HTTP ${init.status}`);
}
} catch (err) {
fail('Admin JWT test', err.message);
}
// ── Test 2: JWT role override ──
section('JWT Role Claim Overrides Strapi Role');
try {
// jwt-editor has Strapi "Editor" role, but JWT says "Reader"
const jwt = await provider.sign({ email: 'jwt-editor@test.local', role: 'Reader' });
const mcp = new McpTestClient(jwt);
const init = await mcp.initialize();
if (init.sessionId) {
pass('JWT with role override: session created');
// The _mcpClaims.role = 'Reader' should override Editor → Publisher
// Reader should not be able to create
const types = McpTestClient.parseResult(await mcp.callTool('list_content_types', {})) || [];
const collections = types.filter((t) => t.kind === 'collectionType');
if (collections.length > 0) {
const ct = collections[0].uid;
const createRes = await mcp.callTool('create_entry', {
contentType: ct,
data: JSON.stringify({ title: `JWT Override Test ${Date.now()}` }),
});
if (McpTestClient.isError(createRes)) {
pass('JWT role=Reader override: create correctly denied');
} else {
// If authorization service isn't checking _mcpClaims yet, note it
const entry = McpTestClient.parseResult(createRes);
fail(
'JWT role override',
`Editor with JWT role=Reader was able to create (authorization may not check _mcpClaims yet). Entry: ${entry?.documentId}`
);
// Cleanup
if (entry?.documentId) {
await mcp.callTool('delete_entry', { contentType: ct, documentId: entry.documentId });
}
}
}
await mcp.close();
}
} catch (err) {
fail('JWT role override', err.message);
}
// ── Test 3: Author JWT → ownership enforcement ──
section('Author JWT → Ownership Enforcement');
try {
const jwt = await provider.sign({ email: 'jwt-author@test.local', role: 'Author' });
const mcp = new McpTestClient(jwt);
const init = await mcp.initialize();
if (init.sessionId) {
pass('Author JWT: session created');
// Author can read
const typesRes = await mcp.callTool('list_content_types', {});
assert(!McpTestClient.isError(typesRes), 'Author JWT: can list content types', 'Read failed');
await mcp.close();
}
} catch (err) {
fail('Author JWT', err.message);
}
// ── Test 4: Tenant claim injection ──
section('Tenant Claim in JWT');
try {
const jwt = await provider.sign({
email: 'jwt-author@test.local',
role: 'Author',
tenant: 'tenant-abc',
});
const mcp = new McpTestClient(jwt);
const init = await mcp.initialize();
if (init.sessionId) {
pass('JWT with tenant claim: session created');
// Tenant filter would be injected by authorization service if content type has tenantId field
// We can't fully verify this without a content type that has tenantId, but we confirm session works
pass('JWT tenant claim accepted (filter injection happens at authorization layer)');
await mcp.close();
}
} catch (err) {
fail('Tenant JWT', err.message);
}
// ── Test 5: Expired JWT → rejected ──
section('Expired JWT → Rejected');
try {
const expiredJwt = await provider.signExpired({ email: 'jwt-admin@test.local' });
const mcp = new McpTestClient(expiredJwt);
const init = await mcp.initialize();
assert(
init.status === 401 || !init.sessionId,
'Expired JWT rejected',
`Expected 401, got HTTP ${init.status} with session ${init.sessionId}`
);
if (init.sessionId) await mcp.close();
} catch (err) {
pass('Expired JWT threw error (rejected)');
}
// ── Test 6: Wrong issuer → rejected ──
section('Wrong Issuer → Rejected');
try {
const badJwt = await provider.signWrongIssuer({ email: 'jwt-admin@test.local' });
const mcp = new McpTestClient(badJwt);
const init = await mcp.initialize();
assert(
init.status === 401 || !init.sessionId,
'Wrong issuer JWT rejected',
`Expected 401, got HTTP ${init.status}`
);
if (init.sessionId) await mcp.close();
} catch (err) {
pass('Wrong issuer JWT threw error');
}
// ── Test 7: Wrong audience → rejected ──
section('Wrong Audience → Rejected');
try {
const badJwt = await provider.signWrongAudience({ email: 'jwt-admin@test.local' });
const mcp = new McpTestClient(badJwt);
const init = await mcp.initialize();
assert(
init.status === 401 || !init.sessionId,
'Wrong audience JWT rejected',
`Expected 401, got HTTP ${init.status}`
);
if (init.sessionId) await mcp.close();
} catch (err) {
pass('Wrong audience JWT threw error');
}
// ── Test 8: Missing email claim → rejected ──
section('Missing Email Claim → Rejected');
try {
const noEmailJwt = await provider.sign({ sub: 'no-email-user', role: 'Admin' });
const mcp = new McpTestClient(noEmailJwt);
const init = await mcp.initialize();
assert(
init.status === 401 || !init.sessionId,
'JWT without email claim rejected',
`Expected 401, got HTTP ${init.status}`
);
if (init.sessionId) await mcp.close();
} catch (err) {
pass('Missing email JWT threw error');
}
// ── Test 9: Email not in Strapi → 403 ──
section('JWT Email Not in Strapi → 403');
try {
const unknownJwt = await provider.sign({ email: 'nobody-exists@nowhere.local', role: 'Admin' });
const mcp = new McpTestClient(unknownJwt);
const init = await mcp.initialize();
assert(
init.status === 403 || init.status === 401 || !init.sessionId,
'JWT for unknown Strapi user rejected (403 or 401)',
`Expected 403/401, got HTTP ${init.status}`
);
if (init.sessionId) await mcp.close();
} catch (err) {
pass('Unknown user JWT threw error');
}
// ── Test 10: MCP_SECRET_KEY validation ──
section('MCP_SECRET_KEY Header (if configured)');
// We can't know if MCP_SECRET_KEY is set on the server,
// but we can test that a request without it still works (if not set)
// or fails (if set). This is informational.
try {
const jwt = await provider.sign({ email: 'jwt-admin@test.local', role: 'Admin' });
// Request with a random secret header
const resp = await fetch(MCP_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${jwt}`,
'x-mcp-internal-secret': 'wrong-secret-value',
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2025-03-26',
capabilities: {},
clientInfo: { name: 'secret-test', version: '1.0.0' },
},
}),
});
if (resp.status === 401) {
pass('MCP_SECRET_KEY is configured and enforced (wrong secret rejected)');
} else if (resp.status === 200) {
pass('MCP_SECRET_KEY not configured (request accepted without valid secret)');
// Clean up session
const sessionId = resp.headers.get('mcp-session-id');
if (sessionId) {
await fetch(MCP_ENDPOINT, {
method: 'DELETE',
headers: { 'mcp-session-id': sessionId, 'Authorization': `Bearer ${jwt}` },
});
}
} else {
fail('MCP_SECRET_KEY check', `Unexpected HTTP ${resp.status}`);
}
} catch (err) {
fail('MCP_SECRET_KEY check', err.message);
}
// ── Cleanup test users ──
section('Cleanup');
for (const email of createdUsers) {
try {
await deleteAdminUser(adminToken, email);
} catch {
// ignore
}
}
pass(`Cleaned up ${createdUsers.length} test users`);
}
// ─── Stop provider & summary ───────────────────────────────────────
await provider.stop();
pass('JWT provider stopped');
const results = summary();
process.exit(results.failed > 0 ? 1 : 0);
}
main().catch((err) => {
console.error('Fatal error:', err);
process.exit(1);
});