@gavdi/cap-mcp 0.10.1 → 1.0.0
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/README.md +4 -12
- package/lib/auth/{handler.js → factory.js} +22 -1
- package/lib/auth/handlers.js +53 -0
- package/lib/auth/utils.js +211 -33
- package/lib/auth/xsuaa-service.js +190 -0
- package/lib/mcp.js +12 -0
- package/package.json +6 -4
package/README.md
CHANGED
|
@@ -3,8 +3,6 @@
|
|
|
3
3
|
> This implementation is based on the Model Context Protocol (MCP) put forward by Anthropic.
|
|
4
4
|
> For more information on MCP, please have a look at their [official documentation.](https://modelcontextprotocol.io/introduction)
|
|
5
5
|
|
|
6
|
-
> 🔧 **In active development - 1.0 release scheduled for September 2025**
|
|
7
|
-
|
|
8
6
|
# CAP-MCP Plugin
|
|
9
7
|
|
|
10
8
|
A CAP (Cloud Application Programming) plugin that automatically generates Model Context Protocol (MCP) servers from your CAP services using simple annotations.
|
|
@@ -21,13 +19,6 @@ By integrating MCP with your CAP applications, you unlock:
|
|
|
21
19
|
- **Developer Productivity**: Allow AI assistants to help developers understand, query, and work with your CAP data models
|
|
22
20
|
- **Business Intelligence**: Transform your structured business data into AI-queryable resources for insights and analysis
|
|
23
21
|
|
|
24
|
-
## ⚠️ Development Status
|
|
25
|
-
|
|
26
|
-
**This plugin is currently in active development and approaching production readiness.**
|
|
27
|
-
APIs and annotations may change in future releases. Authentication and security features are implemented and tested.
|
|
28
|
-
|
|
29
|
-
Version 1.0 of the plugin is scheduled for release in Summer 2025 after final stability testing and documentation completion.
|
|
30
|
-
|
|
31
22
|
## 🚀 Quick Setup
|
|
32
23
|
|
|
33
24
|
### Prerequisites
|
|
@@ -379,9 +370,10 @@ Disables authentication completely:
|
|
|
379
370
|
|
|
380
371
|
#### Authentication Flow
|
|
381
372
|
1. MCP client connects to `/mcp` endpoint
|
|
382
|
-
2.
|
|
383
|
-
3.
|
|
384
|
-
4.
|
|
373
|
+
2. If the authentication style used is OAuth, the OAuth flow will be executed
|
|
374
|
+
3. CAP authentication middleware validates credentials (if `auth: "inherit"`)
|
|
375
|
+
4. MCP session established with authenticated user context
|
|
376
|
+
5. All MCP operations (resources, tools, prompts) inherit the authenticated user's permissions
|
|
385
377
|
|
|
386
378
|
### Automatic Features
|
|
387
379
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.authHandlerFactory = authHandlerFactory;
|
|
4
4
|
exports.errorHandlerFactory = errorHandlerFactory;
|
|
5
|
+
const xsuaa_service_1 = require("./xsuaa-service");
|
|
5
6
|
/** JSON-RPC 2.0 error code for unauthorized requests */
|
|
6
7
|
const RPC_UNAUTHORIZED = 10;
|
|
7
8
|
/* @ts-ignore */
|
|
@@ -32,7 +33,8 @@ const cds = global.cds || require("@sap/cds"); // This is a work around for miss
|
|
|
32
33
|
*/
|
|
33
34
|
function authHandlerFactory() {
|
|
34
35
|
const authKind = cds.env.requires.auth.kind;
|
|
35
|
-
|
|
36
|
+
const xsuaaService = new xsuaa_service_1.XSUAAService();
|
|
37
|
+
return async (req, res, next) => {
|
|
36
38
|
if (!req.headers.authorization && authKind !== "dummy") {
|
|
37
39
|
res.status(401).json({
|
|
38
40
|
jsonrpc: "2.0",
|
|
@@ -44,6 +46,25 @@ function authHandlerFactory() {
|
|
|
44
46
|
});
|
|
45
47
|
return;
|
|
46
48
|
}
|
|
49
|
+
// For XSUAA/JWT auth types, use @sap/xssec for validation
|
|
50
|
+
if ((authKind === "jwt" || authKind === "xsuaa") &&
|
|
51
|
+
xsuaaService.isConfigured()) {
|
|
52
|
+
const securityContext = await xsuaaService.createSecurityContext(req);
|
|
53
|
+
if (!securityContext) {
|
|
54
|
+
res.status(401).json({
|
|
55
|
+
jsonrpc: "2.0",
|
|
56
|
+
error: {
|
|
57
|
+
code: RPC_UNAUTHORIZED,
|
|
58
|
+
message: "Invalid or expired token",
|
|
59
|
+
id: null,
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
// Add security context to request for later use
|
|
65
|
+
req.securityContext = securityContext;
|
|
66
|
+
}
|
|
67
|
+
// Continue with existing CAP context validation
|
|
47
68
|
const ctx = cds.context;
|
|
48
69
|
if (!ctx) {
|
|
49
70
|
res.status(500).json({
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.handleTokenRequest = handleTokenRequest;
|
|
4
|
+
const logger_1 = require("../logger");
|
|
5
|
+
/**
|
|
6
|
+
* Reusable OAuth token handler function
|
|
7
|
+
* Handles both GET and POST requests by extracting parameters from both query and body
|
|
8
|
+
* This unified approach follows lemaiwo's successful pattern and works around MCP SDK inconsistencies
|
|
9
|
+
*/
|
|
10
|
+
async function handleTokenRequest(req, res, xsuaaService) {
|
|
11
|
+
try {
|
|
12
|
+
const params = { ...req.query, ...req.body };
|
|
13
|
+
const { grant_type, code, redirect_uri, refresh_token } = params;
|
|
14
|
+
if (grant_type === "authorization_code") {
|
|
15
|
+
if (!code || !redirect_uri) {
|
|
16
|
+
res.status(400).json({
|
|
17
|
+
error: "invalid_request",
|
|
18
|
+
error_description: "Missing code or redirect_uri",
|
|
19
|
+
});
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const tokenData = await xsuaaService.exchangeCodeForToken(code, redirect_uri);
|
|
23
|
+
logger_1.LOGGER.debug("[AUTH] Token exchange successful");
|
|
24
|
+
res.json(tokenData);
|
|
25
|
+
}
|
|
26
|
+
else if (grant_type === "refresh_token") {
|
|
27
|
+
if (!refresh_token) {
|
|
28
|
+
res.status(400).json({
|
|
29
|
+
error: "invalid_request",
|
|
30
|
+
error_description: "Missing refresh_token",
|
|
31
|
+
});
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const tokenData = await xsuaaService.refreshAccessToken(refresh_token);
|
|
35
|
+
logger_1.LOGGER.debug("[AUTH] Token refresh successful");
|
|
36
|
+
res.json(tokenData);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
res.status(400).json({
|
|
40
|
+
error: "unsupported_grant_type",
|
|
41
|
+
error_description: "Only authorization_code and refresh_token are supported",
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
logger_1.LOGGER.error("OAuth token error:", error);
|
|
47
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
48
|
+
res.status(400).json({
|
|
49
|
+
error: "invalid_grant",
|
|
50
|
+
error_description: errorMessage,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
package/lib/auth/utils.js
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
2
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
6
|
exports.isAuthEnabled = isAuthEnabled;
|
|
4
7
|
exports.getAccessRights = getAccessRights;
|
|
5
8
|
exports.registerAuthMiddleware = registerAuthMiddleware;
|
|
6
9
|
exports.hasToolOperationAccess = hasToolOperationAccess;
|
|
7
10
|
exports.getWrapAccesses = getWrapAccesses;
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
+
const express_1 = __importDefault(require("express"));
|
|
12
|
+
const helmet_1 = __importDefault(require("helmet"));
|
|
13
|
+
const factory_1 = require("./factory");
|
|
14
|
+
const xsuaa_service_1 = require("./xsuaa-service");
|
|
15
|
+
const handlers_1 = require("./handlers");
|
|
16
|
+
const logger_1 = require("../logger");
|
|
11
17
|
/**
|
|
12
18
|
* @fileoverview Authentication utilities for MCP-CAP integration.
|
|
13
19
|
*
|
|
@@ -119,7 +125,7 @@ function getAccessRights(authEnabled) {
|
|
|
119
125
|
function registerAuthMiddleware(expressApp) {
|
|
120
126
|
const middlewares = cds.middlewares.before; // No types exists for this part of the CDS library
|
|
121
127
|
// Build array of auth middleware to apply
|
|
122
|
-
const authMiddleware = [];
|
|
128
|
+
const authMiddleware = []; // Required any as a workaround for untyped cds middleware
|
|
123
129
|
// Add CAP middleware
|
|
124
130
|
middlewares.forEach((mw) => {
|
|
125
131
|
const process = mw.factory();
|
|
@@ -128,12 +134,12 @@ function registerAuthMiddleware(expressApp) {
|
|
|
128
134
|
}
|
|
129
135
|
});
|
|
130
136
|
// Add MCP auth middleware
|
|
131
|
-
authMiddleware.push((0,
|
|
132
|
-
authMiddleware.push((0,
|
|
137
|
+
authMiddleware.push((0, factory_1.errorHandlerFactory)());
|
|
138
|
+
authMiddleware.push((0, factory_1.authHandlerFactory)());
|
|
139
|
+
// If we require OAuth then we should also apply for that
|
|
140
|
+
configureOAuthProxy(expressApp);
|
|
133
141
|
// Apply auth middleware to all /mcp routes EXCEPT health
|
|
134
142
|
expressApp?.use(/^\/mcp(?!\/health).*/, ...authMiddleware);
|
|
135
|
-
// Then finally we add the oauth proxy to the xsuaa instance
|
|
136
|
-
configureOAuthProxy(expressApp);
|
|
137
143
|
}
|
|
138
144
|
/**
|
|
139
145
|
* Configures OAuth proxy middleware for enterprise authentication scenarios.
|
|
@@ -175,42 +181,214 @@ function configureOAuthProxy(expressApp) {
|
|
|
175
181
|
const config = cds.env.requires.auth;
|
|
176
182
|
const kind = config.kind;
|
|
177
183
|
const credentials = config.credentials;
|
|
178
|
-
//
|
|
184
|
+
// PRESERVE existing logic - skip OAuth proxy for basic auth types
|
|
179
185
|
if (kind === "dummy" || kind === "mocked" || kind === "basic")
|
|
180
186
|
return;
|
|
181
|
-
|
|
187
|
+
// PRESERVE existing validation
|
|
188
|
+
if (!credentials ||
|
|
182
189
|
!credentials.clientid ||
|
|
183
190
|
!credentials.clientsecret ||
|
|
184
191
|
!credentials.url) {
|
|
185
192
|
throw new Error("Invalid security credentials");
|
|
186
193
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
194
|
+
registerOAuthEndpoints(expressApp, credentials);
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Determines the correct protocol (HTTP/HTTPS) for URL construction.
|
|
198
|
+
* Accounts for reverse proxy headers and production environment defaults.
|
|
199
|
+
*
|
|
200
|
+
* @param req - Express request object
|
|
201
|
+
* @returns Protocol string ('http' or 'https')
|
|
202
|
+
*/
|
|
203
|
+
function getProtocol(req) {
|
|
204
|
+
// Check for reverse proxy header first (most reliable)
|
|
205
|
+
if (req.headers["x-forwarded-proto"]) {
|
|
206
|
+
return req.headers["x-forwarded-proto"];
|
|
207
|
+
}
|
|
208
|
+
// Default to HTTPS in production environments
|
|
209
|
+
const isProduction = process.env.NODE_ENV === "production" || process.env.VCAP_APPLICATION;
|
|
210
|
+
return isProduction ? "https" : req.protocol;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Registers OAuth endpoints for XSUAA integration
|
|
214
|
+
* Only called for jwt/xsuaa/ias auth types with valid credentials
|
|
215
|
+
*/
|
|
216
|
+
function registerOAuthEndpoints(expressApp, credentials) {
|
|
217
|
+
const xsuaaService = new xsuaa_service_1.XSUAAService();
|
|
218
|
+
// Add JSON and URL-encoded body parsing for OAuth endpoints
|
|
219
|
+
expressApp.use("/oauth", express_1.default.json());
|
|
220
|
+
expressApp.use("/oauth", express_1.default.urlencoded({ extended: true }));
|
|
221
|
+
// Apply helmet security middleware only to OAuth routes
|
|
222
|
+
expressApp.use("/oauth", (0, helmet_1.default)({
|
|
223
|
+
contentSecurityPolicy: {
|
|
224
|
+
directives: {
|
|
225
|
+
defaultSrc: ["'self'"],
|
|
226
|
+
styleSrc: ["'self'", "'unsafe-inline'"],
|
|
227
|
+
scriptSrc: ["'self'"],
|
|
228
|
+
imgSrc: ["'self'", "data:", "https:"],
|
|
229
|
+
},
|
|
192
230
|
},
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
231
|
+
}));
|
|
232
|
+
// OAuth Authorization endpoint - stateless redirect to XSUAA
|
|
233
|
+
expressApp.get("/oauth/authorize", (req, res) => {
|
|
234
|
+
const { state, redirect_uri } = req.query;
|
|
235
|
+
// Client validation and redirect URI validation is handled by XSUAA
|
|
236
|
+
// We delegate all client management to XSUAA's built-in OAuth server
|
|
237
|
+
const protocol = getProtocol(req);
|
|
238
|
+
const redirectUri = redirect_uri || `${protocol}://${req.get("host")}/oauth/callback`;
|
|
239
|
+
const authUrl = xsuaaService.getAuthorizationUrl(redirectUri, state);
|
|
240
|
+
res.redirect(authUrl);
|
|
241
|
+
});
|
|
242
|
+
// OAuth Callback endpoint - stateless token exchange
|
|
243
|
+
expressApp.get("/oauth/callback", async (req, res) => {
|
|
244
|
+
const { code, state, error, error_description, redirect_uri } = req.query;
|
|
245
|
+
logger_1.LOGGER.debug("[AUTH] Callback received", code, state);
|
|
246
|
+
if (error) {
|
|
247
|
+
res.status(400).json({
|
|
248
|
+
error: "authorization_failed",
|
|
249
|
+
error_description: error_description || error,
|
|
250
|
+
});
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (!code) {
|
|
254
|
+
res.status(400).json({
|
|
255
|
+
error: "invalid_request",
|
|
256
|
+
error_description: "Missing authorization code",
|
|
257
|
+
});
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
const protocol = getProtocol(req);
|
|
262
|
+
const url = redirect_uri || `${protocol}://${req.get("host")}/oauth/callback`;
|
|
263
|
+
const tokenData = await xsuaaService.exchangeCodeForToken(code, url);
|
|
264
|
+
res.json(tokenData);
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
logger_1.LOGGER.error("OAuth callback error:", error);
|
|
268
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
269
|
+
res.status(400).json({
|
|
270
|
+
error: "token_exchange_failed",
|
|
271
|
+
error_description: errorMessage,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
// OAuth Token endpoint - POST (standard OAuth 2.0)
|
|
276
|
+
expressApp.post("/oauth/token", async (req, res) => {
|
|
277
|
+
await (0, handlers_1.handleTokenRequest)(req, res, xsuaaService);
|
|
278
|
+
});
|
|
279
|
+
// OAuth Discovery endpoint
|
|
280
|
+
expressApp.get("/.well-known/oauth-authorization-server", (req, res) => {
|
|
281
|
+
const protocol = getProtocol(req);
|
|
282
|
+
const baseUrl = `${protocol}://${req.get("host")}`;
|
|
283
|
+
res.json({
|
|
284
|
+
issuer: credentials.url,
|
|
285
|
+
authorization_endpoint: `${baseUrl}/oauth/authorize`,
|
|
286
|
+
token_endpoint: `${baseUrl}/oauth/token`,
|
|
287
|
+
registration_endpoint: `${baseUrl}/oauth/register`,
|
|
288
|
+
response_types_supported: ["code"],
|
|
289
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
290
|
+
code_challenge_methods_supported: ["S256"],
|
|
291
|
+
// scopes_supported: ["uaa.resource"],
|
|
292
|
+
token_endpoint_auth_methods_supported: ["client_secret_post"],
|
|
293
|
+
registration_endpoint_auth_methods_supported: ["client_secret_basic"],
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
// OAuth Dynamic Client Registration discovery endpoint (GET)
|
|
297
|
+
expressApp.get("/oauth/register", async (req, res) => {
|
|
298
|
+
try {
|
|
299
|
+
// Simple proxy for discovery - no CSRF needed
|
|
300
|
+
const response = await fetch(`${credentials.url}/oauth/register`, {
|
|
301
|
+
method: "GET",
|
|
302
|
+
headers: {
|
|
303
|
+
Authorization: `Basic ${Buffer.from(`${credentials.clientid}:${credentials.clientsecret}`).toString("base64")}`,
|
|
304
|
+
Accept: "application/json",
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
const xsuaaData = await response.json();
|
|
308
|
+
// Add missing required fields that MCP client expects
|
|
309
|
+
const protocol = getProtocol(req);
|
|
310
|
+
const enhancedResponse = {
|
|
311
|
+
...xsuaaData, // Keep all XSUAA fields
|
|
312
|
+
client_id: credentials.clientid, // Add our CAP app's client ID
|
|
313
|
+
redirect_uris: [`${protocol}://${req.get("host")}/oauth/callback`], // Add our callback URL for discovery
|
|
198
314
|
};
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
315
|
+
res.status(response.status).json(enhancedResponse);
|
|
316
|
+
}
|
|
317
|
+
catch (error) {
|
|
318
|
+
logger_1.LOGGER.error("OAuth registration discovery error:", error);
|
|
319
|
+
res.status(500).json({
|
|
320
|
+
error: "server_error",
|
|
321
|
+
error_description: error instanceof Error ? error.message : "Unknown error",
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
// OAuth Dynamic Client Registration endpoint (POST) with CSRF handling
|
|
326
|
+
expressApp.post("/oauth/register", async (req, res) => {
|
|
327
|
+
try {
|
|
328
|
+
// Step 1: Fetch CSRF token from XSUAA
|
|
329
|
+
const csrfResponse = await fetch(`${credentials.url}/oauth/register`, {
|
|
330
|
+
method: "GET",
|
|
331
|
+
headers: {
|
|
332
|
+
"X-CSRF-Token": "Fetch",
|
|
333
|
+
Authorization: `Basic ${Buffer.from(`${credentials.clientid}:${credentials.clientsecret}`).toString("base64")}`,
|
|
334
|
+
Accept: "application/json",
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
if (!csrfResponse.ok) {
|
|
338
|
+
throw new Error(`CSRF fetch failed: ${csrfResponse.status}`);
|
|
339
|
+
}
|
|
340
|
+
// Step 2: Extract CSRF token and session cookie
|
|
341
|
+
const setCookieHeader = csrfResponse.headers.get("set-cookie") || "";
|
|
342
|
+
const csrfToken = extractCsrfFromCookie(setCookieHeader);
|
|
343
|
+
if (!csrfToken) {
|
|
344
|
+
throw new Error("Could not extract CSRF token from XSUAA response");
|
|
345
|
+
}
|
|
346
|
+
// Step 3: Make actual registration POST with CSRF token
|
|
347
|
+
const registrationResponse = await fetch(`${credentials.url}/oauth/register`, {
|
|
348
|
+
method: "POST",
|
|
349
|
+
headers: {
|
|
350
|
+
"Content-Type": "application/json",
|
|
351
|
+
"X-CSRF-Token": csrfToken,
|
|
352
|
+
Cookie: setCookieHeader,
|
|
353
|
+
Authorization: `Basic ${Buffer.from(`${credentials.clientid}:${credentials.clientsecret}`).toString("base64")}`,
|
|
354
|
+
Accept: "application/json",
|
|
355
|
+
},
|
|
356
|
+
body: JSON.stringify(req.body),
|
|
357
|
+
});
|
|
358
|
+
const xsuaaData = await registrationResponse.json();
|
|
359
|
+
// Add missing required fields that MCP client expects
|
|
360
|
+
const protocol = getProtocol(req);
|
|
361
|
+
const enhancedResponse = {
|
|
362
|
+
...xsuaaData, // Keep all XSUAA fields
|
|
363
|
+
client_id: credentials.clientid, // Add our CAP app's client ID
|
|
364
|
+
client_secret: credentials.clientsecret, // CAP app's client secret
|
|
365
|
+
redirect_uris: req.body.redirect_uris || [
|
|
366
|
+
`${protocol}://${req.get("host")}/oauth/callback`,
|
|
367
|
+
], // Use client's redirect URIs
|
|
205
368
|
};
|
|
206
|
-
|
|
369
|
+
logger_1.LOGGER.debug("[AUTH] Register POST response", enhancedResponse);
|
|
370
|
+
res.status(registrationResponse.status).json(enhancedResponse);
|
|
371
|
+
}
|
|
372
|
+
catch (error) {
|
|
373
|
+
logger_1.LOGGER.error("OAuth registration error:", error);
|
|
374
|
+
res.status(500).json({
|
|
375
|
+
error: "server_error",
|
|
376
|
+
error_description: error instanceof Error ? error.message : "Unknown error",
|
|
377
|
+
});
|
|
378
|
+
}
|
|
207
379
|
});
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
380
|
+
logger_1.LOGGER.debug("OAuth endpoints registered for XSUAA integration");
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Extracts CSRF token from XSUAA Set-Cookie header
|
|
384
|
+
* Looks for "X-Uaa-Csrf=<token>" pattern in the cookie string
|
|
385
|
+
*/
|
|
386
|
+
function extractCsrfFromCookie(setCookieHeader) {
|
|
387
|
+
if (!setCookieHeader)
|
|
388
|
+
return null;
|
|
389
|
+
// Match X-Uaa-Csrf=<token> pattern
|
|
390
|
+
const csrfMatch = setCookieHeader.match(/X-Uaa-Csrf=([^;,]+)/i);
|
|
391
|
+
return csrfMatch ? csrfMatch[1] : null;
|
|
214
392
|
}
|
|
215
393
|
/**
|
|
216
394
|
* Checks whether the requesting user's access matches that of the roles required
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.XSUAAService = void 0;
|
|
37
|
+
const xssec = __importStar(require("@sap/xssec"));
|
|
38
|
+
const logger_1 = require("../logger");
|
|
39
|
+
/* @ts-ignore */
|
|
40
|
+
const cds = global.cds || require("@sap/cds");
|
|
41
|
+
/**
|
|
42
|
+
* XSUAA service using official @sap/xssec library
|
|
43
|
+
* Leverages SAP's official authentication and validation mechanisms
|
|
44
|
+
*/
|
|
45
|
+
class XSUAAService {
|
|
46
|
+
credentials;
|
|
47
|
+
xsuaaService;
|
|
48
|
+
constructor() {
|
|
49
|
+
this.credentials = cds.env.requires.auth.credentials;
|
|
50
|
+
// Initialize XSUAA service from @sap/xssec
|
|
51
|
+
this.xsuaaService = new xssec.XsuaaService(this.credentials);
|
|
52
|
+
}
|
|
53
|
+
isConfigured() {
|
|
54
|
+
return !!(this.credentials?.clientid &&
|
|
55
|
+
this.credentials?.clientsecret &&
|
|
56
|
+
this.credentials?.url);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Generates authorization URL using @sap/xssec
|
|
60
|
+
*/
|
|
61
|
+
getAuthorizationUrl(redirectUri, state) {
|
|
62
|
+
const params = new URLSearchParams({
|
|
63
|
+
response_type: "code",
|
|
64
|
+
client_id: this.credentials.clientid,
|
|
65
|
+
redirect_uri: redirectUri,
|
|
66
|
+
// scope: "uaa.resource",
|
|
67
|
+
});
|
|
68
|
+
if (state) {
|
|
69
|
+
params.append("state", state);
|
|
70
|
+
}
|
|
71
|
+
return `${this.credentials.url}/oauth/authorize?${params.toString()}`;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Exchange authorization code for token using @sap/xssec
|
|
75
|
+
*/
|
|
76
|
+
async exchangeCodeForToken(code, redirectUri) {
|
|
77
|
+
try {
|
|
78
|
+
const tokenOptions = {
|
|
79
|
+
grant_type: "authorization_code",
|
|
80
|
+
code,
|
|
81
|
+
redirect_uri: redirectUri,
|
|
82
|
+
};
|
|
83
|
+
// Use direct XSUAA token endpoint for authorization code exchange
|
|
84
|
+
const response = await fetch(`${this.credentials.url}/oauth/token`, {
|
|
85
|
+
method: "POST",
|
|
86
|
+
headers: {
|
|
87
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
88
|
+
Authorization: `Basic ${Buffer.from(`${this.credentials.clientid}:${this.credentials.clientsecret}`).toString("base64")}`,
|
|
89
|
+
},
|
|
90
|
+
body: new URLSearchParams(tokenOptions),
|
|
91
|
+
});
|
|
92
|
+
if (!response.ok) {
|
|
93
|
+
const errorData = (await response.json());
|
|
94
|
+
throw new Error(`Token exchange failed: ${response.status} ${errorData.error_description || errorData.error}`);
|
|
95
|
+
}
|
|
96
|
+
return response.json();
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
if (error instanceof Error) {
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
throw new Error(`Token exchange failed: ${String(error)}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Refresh access token using @sap/xssec
|
|
107
|
+
*/
|
|
108
|
+
async refreshAccessToken(refreshToken) {
|
|
109
|
+
try {
|
|
110
|
+
const response = await fetch(`${this.credentials.url}/oauth/token`, {
|
|
111
|
+
method: "POST",
|
|
112
|
+
headers: {
|
|
113
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
114
|
+
Authorization: `Basic ${Buffer.from(`${this.credentials.clientid}:${this.credentials.clientsecret}`).toString("base64")}`,
|
|
115
|
+
},
|
|
116
|
+
body: new URLSearchParams({
|
|
117
|
+
grant_type: "refresh_token",
|
|
118
|
+
refresh_token: refreshToken,
|
|
119
|
+
}),
|
|
120
|
+
});
|
|
121
|
+
if (!response.ok) {
|
|
122
|
+
const errorData = (await response.json());
|
|
123
|
+
throw new Error(`Token refresh failed: ${response.status} ${errorData.error_description || errorData.error}`);
|
|
124
|
+
}
|
|
125
|
+
return response.json();
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
if (error instanceof Error) {
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
throw new Error(`Token refresh failed: ${String(error)}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Validate JWT token using @sap/xssec SecurityContext
|
|
136
|
+
* This is the proper way to validate tokens with XSUAA
|
|
137
|
+
*/
|
|
138
|
+
async validateToken(accessToken, req) {
|
|
139
|
+
try {
|
|
140
|
+
// Create security context using @sap/xssec
|
|
141
|
+
const securityContext = await xssec.createSecurityContext(this.xsuaaService, {
|
|
142
|
+
req: req || { headers: { authorization: `Bearer ${accessToken}` } },
|
|
143
|
+
token: accessToken,
|
|
144
|
+
});
|
|
145
|
+
// If security context is created successfully, token is valid
|
|
146
|
+
return !!securityContext;
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
// Log validation errors for debugging
|
|
150
|
+
if (error instanceof xssec.errors.TokenValidationError) {
|
|
151
|
+
logger_1.LOGGER.warn("Token validation failed:", error.message);
|
|
152
|
+
}
|
|
153
|
+
else if (error instanceof Error) {
|
|
154
|
+
logger_1.LOGGER.warn("Token validation failed:", error.message);
|
|
155
|
+
}
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Create security context for authenticated requests
|
|
161
|
+
* Returns null if token is invalid
|
|
162
|
+
*/
|
|
163
|
+
async createSecurityContext(req) {
|
|
164
|
+
try {
|
|
165
|
+
const authHeader = req.headers.authorization;
|
|
166
|
+
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
const token = authHeader.substring(7);
|
|
170
|
+
const securityContext = await xssec.createSecurityContext(this.xsuaaService, { req, token: token });
|
|
171
|
+
return securityContext;
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
if (error instanceof xssec.errors.TokenValidationError) {
|
|
175
|
+
logger_1.LOGGER.warn("Security context creation failed:", error.message);
|
|
176
|
+
}
|
|
177
|
+
else if (error instanceof Error) {
|
|
178
|
+
logger_1.LOGGER.warn("Security context creation failed:", error.message);
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Get XSUAA service instance for advanced operations
|
|
185
|
+
*/
|
|
186
|
+
getXsuaaService() {
|
|
187
|
+
return this.xsuaaService;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
exports.XSUAAService = XSUAAService;
|
package/lib/mcp.js
CHANGED
|
@@ -12,6 +12,7 @@ const constants_1 = require("./mcp/constants");
|
|
|
12
12
|
const loader_1 = require("./config/loader");
|
|
13
13
|
const session_manager_1 = require("./mcp/session-manager");
|
|
14
14
|
const utils_2 = require("./auth/utils");
|
|
15
|
+
const helmet_1 = __importDefault(require("helmet"));
|
|
15
16
|
/* @ts-ignore */
|
|
16
17
|
const cds = global.cds; // Use hosting app's CDS instance exclusively
|
|
17
18
|
/**
|
|
@@ -40,6 +41,17 @@ class McpPlugin {
|
|
|
40
41
|
logger_1.LOGGER.debug("Event received for 'bootstrap'");
|
|
41
42
|
this.expressApp = app;
|
|
42
43
|
this.expressApp.use("/mcp", express_1.default.json());
|
|
44
|
+
// Apply helmet security middleware only to MCP routes
|
|
45
|
+
this.expressApp.use("/mcp", (0, helmet_1.default)({
|
|
46
|
+
contentSecurityPolicy: {
|
|
47
|
+
directives: {
|
|
48
|
+
defaultSrc: ["'self'"],
|
|
49
|
+
styleSrc: ["'self'", "'unsafe-inline'"],
|
|
50
|
+
scriptSrc: ["'self'"],
|
|
51
|
+
imgSrc: ["'self'", "data:", "https:"],
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
}));
|
|
43
55
|
if (this.config.auth === "inherit") {
|
|
44
56
|
(0, utils_2.registerAuthMiddleware)(this.expressApp);
|
|
45
57
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gavdi/cap-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "MCP Pluging for CAP",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"MCP",
|
|
@@ -23,9 +23,9 @@
|
|
|
23
23
|
"scripts": {
|
|
24
24
|
"mock": "npm run start --workspace=test/demo",
|
|
25
25
|
"mock:watch": "npm run build && npm run watch --workspace=test/demo",
|
|
26
|
-
"test": "NODE_ENV=test jest --silent",
|
|
27
|
-
"test:unit": "NODE_ENV=test jest --silent test/unit",
|
|
28
|
-
"test:integration": "NODE_ENV=test jest --silent test/integration",
|
|
26
|
+
"test": "NODE_ENV=test jest --silent --testTimeout=30000",
|
|
27
|
+
"test:unit": "NODE_ENV=test jest --silent test/unit --testTimeout=30000",
|
|
28
|
+
"test:integration": "NODE_ENV=test jest --silent test/integration --testTimeout=30000",
|
|
29
29
|
"build": "tsc",
|
|
30
30
|
"inspect": "npx @modelcontextprotocol/inspector",
|
|
31
31
|
"prepare": "husky",
|
|
@@ -42,6 +42,8 @@
|
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"@modelcontextprotocol/sdk": "^1.17.3",
|
|
45
|
+
"@sap/xssec": "^4.9.1",
|
|
46
|
+
"helmet": "^8.1.0",
|
|
45
47
|
"zod": "^3.25.67",
|
|
46
48
|
"zod-to-json-schema": "^3.24.5"
|
|
47
49
|
},
|