Skip to main content
Glama
lemaiwo

SAP OData to MCP Server

by lemaiwo
auth-service.ts15 kB
import xssec from '@sap/xssec'; import xsenv from '@sap/xsenv'; import { Request, Response, NextFunction } from 'express'; import { Logger } from '../utils/logger.js'; import { Config } from '../utils/config.js'; export interface AuthRequest extends Request { authInfo?: xssec.SecurityContext; jwtToken?: string; } export class AuthService { private xsuaaCredentials: Record<string, unknown> | null = null; private logger: Logger; private config: Config; constructor(logger?: Logger, config?: Config) { this.logger = logger || new Logger('AuthService'); this.config = config || new Config(); this.initializeXSUAA(); } private initializeXSUAA(): void { try { xsenv.loadEnv(); const services = xsenv.getServices({ xsuaa: { label: 'xsuaa' } }); this.xsuaaCredentials = services.xsuaa as Record<string, unknown>; this.logger.info('XSUAA service initialized successfully'); } catch { this.logger.warn('XSUAA service not found in VCAP_SERVICES, OAuth will be disabled'); this.xsuaaCredentials = null; } } /** * Generate OAuth authorization URL for user login */ getAuthorizationUrl(state?: string, requestUrl?: string): string { if (!this.xsuaaCredentials) { throw new Error('XSUAA service not configured'); } const creds = this.xsuaaCredentials as Record<string, string>; const params = new URLSearchParams({ response_type: 'code', client_id: creds.clientid, redirect_uri: this.getRedirectUri(requestUrl), ...(state && { state }) }); return `${creds.url}/oauth/authorize?${params.toString()}`; } /** * Exchange authorization code for access token */ async exchangeCodeForToken(code: string, redirectUri?: string): Promise<{ access_token: string; refresh_token?: string; expires_in: number }> { if (!this.xsuaaCredentials) { throw new Error('XSUAA service not configured'); } const creds = this.xsuaaCredentials as Record<string, string>; const tokenUrl = `${creds.url}/oauth/token`; const params = new URLSearchParams({ grant_type: 'authorization_code', code, client_id: creds.clientid, client_secret: creds.clientsecret, redirect_uri: redirectUri || this.getRedirectUri() }); try { const response = await fetch(tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' }, body: params.toString() }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Token exchange failed: ${response.status} - ${errorText}`); } const tokenData = await response.json(); return tokenData; } catch (error) { this.logger.error('Failed to exchange code for token:', error); throw error; } } /** * Refresh an access token using a refresh token */ async refreshAccessToken(refreshToken: string): Promise<{ access_token: string; refresh_token?: string; expires_in: number; token_type?: string }> { if (!this.xsuaaCredentials) { throw new Error('XSUAA service not configured'); } const creds = this.xsuaaCredentials as Record<string, string>; const tokenUrl = `${creds.url}/oauth/token`; const params = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: creds.clientid, client_secret: creds.clientsecret }); try { const response = await fetch(tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' }, body: params.toString() }); if (!response.ok) { const errorText = await response.text(); this.logger.error(`Token refresh HTTP error: ${response.status} - ${errorText}`); throw new Error(`Token refresh failed: ${response.status} - ${errorText}`); } const tokenData = await response.json(); this.logger.info('Token refresh successful'); // Return the token data in the same format as XSUAA (snake_case) return { access_token: tokenData.access_token, refresh_token: tokenData.refresh_token, expires_in: tokenData.expires_in, token_type: tokenData.token_type || 'bearer' }; } catch (error) { this.logger.error('Failed to refresh token:', error); throw error; } } /** * Validate JWT token and extract user information */ async validateToken(token: string): Promise<xssec.SecurityContext> { if (!this.xsuaaCredentials) { throw new Error('XSUAA service not configured'); } return new Promise((resolve, reject) => { xssec.createSecurityContext(token, this.xsuaaCredentials as Record<string, unknown>, (error: Error | null, securityContext?: xssec.SecurityContext) => { if (error) { this.logger.error('Token validation failed:', error); reject(error); } else if (securityContext) { resolve(securityContext); } else { reject(new Error('Security context creation failed')); } }); }); } /** * Express middleware for JWT authentication * Extracts and validates JWT token from Authorization header */ authenticateJWT() { return async (req: AuthRequest, res: Response, next: NextFunction) => { // Skip authentication for health and docs endpoints if (req.path === '/health' || req.path === '/docs' || req.path === '/oauth/authorize' || req.path === '/oauth/callback') { return next(); } // Skip if XSUAA is not configured (development mode) if (!this.xsuaaCredentials) { this.logger.debug('XSUAA not configured, skipping authentication'); return next(); } const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { const baseUrl = `${req.protocol}://${req.get('host')}`; return res.status(401).json({ error: 'Authentication Required', message: 'This SAP OData MCP server requires OAuth 2.0 authentication', authentication: { required: true, type: 'Bearer Token', instructions: { step1: `Navigate to ${baseUrl}/oauth/authorize to start OAuth flow`, step2: 'Login with your SAP BTP credentials', step3: 'Copy the access token from the callback response', step4: 'Include token in Authorization header: Bearer <your-token>' }, endpoints: { authorize: `${baseUrl}/oauth/authorize`, userinfo: `${baseUrl}/oauth/userinfo`, refresh: `${baseUrl}/oauth/refresh`, discovery: `${baseUrl}/.well-known/oauth-authorization-server` } }, mcp_context: { message: 'All MCP operations require authenticated SAP user context', dual_auth_model: { discovery: 'Uses technical user for service discovery', execution: 'Uses your JWT token for data operations' } } }); } const token = authHeader.substring(7); try { const securityContext = await this.validateToken(token); req.authInfo = securityContext; req.jwtToken = token; this.logger.debug(`Request authenticated for user: ${securityContext.getUserName()}`); next(); } catch (error) { this.logger.error('Authentication failed:', error); const baseUrl = `${req.protocol}://${req.get('host')}`; return res.status(401).json({ error: 'Token Validation Failed', message: 'The provided JWT token is invalid or expired', details: error instanceof Error ? error.message : 'Token validation error', next_steps: { if_expired: `Get a fresh token from ${baseUrl}/oauth/authorize`, if_refresh_available: `Try refreshing your token at ${baseUrl}/oauth/refresh`, if_invalid: 'Ensure you are using a valid SAP BTP XSUAA token' }, authentication: { endpoints: { authorize: `${baseUrl}/oauth/authorize`, refresh: `${baseUrl}/oauth/refresh`, userinfo: `${baseUrl}/oauth/userinfo` }, token_info: { typical_lifetime: '1 hour', refresh_lifetime: '24 hours', issuer: (this.xsuaaCredentials as Record<string, string>)?.url || 'SAP XSUAA Service' } } }); } }; } /** * Express middleware for optional JWT authentication * Validates token if present but doesn't require it */ optionalAuthenticateJWT() { return async (req: AuthRequest, res: Response, next: NextFunction) => { const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { return next(); } if (!this.xsuaaCredentials) { return next(); } const token = authHeader.substring(7); try { const securityContext = await this.validateToken(token); req.authInfo = securityContext; req.jwtToken = token; this.logger.debug(`Optional auth: Request authenticated for user: ${securityContext.getUserName()}`); } catch { this.logger.debug('Optional auth: Token validation failed, continuing without auth'); } next(); }; } /** * Check if a user has a specific scope */ hasScope(securityContext: xssec.SecurityContext, scope: string): boolean { return securityContext.checkScope(scope); } /** * Get user information from security context */ getUserInfo(securityContext: xssec.SecurityContext) { return { username: securityContext.getUserName(), email: securityContext.getEmail(), givenName: securityContext.getGivenName(), familyName: securityContext.getFamilyName(), scopes: securityContext.getGrantedScopes() }; } getRedirectUri(requestUrl?: string): string { const port = process.env.PORT || '3000'; const defaultBaseUrl = `http://localhost:${port}`; const baseUrl = this.config.get('oauth.redirectBaseUrl', process.env.OAUTH_REDIRECT_BASE_URL || requestUrl || defaultBaseUrl); return `${baseUrl}/oauth/callback`; } /** * Check if XSUAA is configured */ isConfigured(): boolean { return this.xsuaaCredentials !== null; } /** * Get XSUAA discovery metadata for OAuth endpoints */ getXSUAADiscoveryMetadata() { if (!this.xsuaaCredentials) { return null; } const creds = this.xsuaaCredentials as Record<string, string>; return { issuer: creds.url, clientId: creds.clientid, xsappname: creds.xsappname, identityZone: creds.identityzone, tenantId: creds.tenantid, tenantMode: creds.tenantmode, endpoints: { authorization: `${creds.url}/oauth/authorize`, token: `${creds.url}/oauth/token`, userinfo: `${creds.url}/userinfo`, jwks: `${creds.url}/token_keys`, introspection: `${creds.url}/oauth/introspect`, revocation: `${creds.url}/oauth/revoke` }, verificationKey: creds.verificationkey }; } /** * Get application-specific scopes from xs-security.json configuration */ getApplicationScopes(): string[] { const creds = this.xsuaaCredentials as Record<string, string>; if (!creds?.xsappname) { return []; } const appName = creds.xsappname; return [ `${appName}.read`, `${appName}.write`, `${appName}.admin` ]; } /** * Get XSUAA service information (safe for public exposure) */ getServiceInfo() { if (!this.xsuaaCredentials) { return null; } const creds = this.xsuaaCredentials as Record<string, string>; return { url: creds.url, clientId: creds.clientid, xsappname: creds.xsappname, identityZone: creds.identityzone, tenantId: creds.tenantid, tenantMode: creds.tenantmode, // Don't expose sensitive credentials configured: true }; } /** * Get XSUAA client credentials for OAuth client registration (sensitive) * This method should only be used internally for client registration */ getClientCredentials() { if (!this.xsuaaCredentials) { return null; } const creds = this.xsuaaCredentials as Record<string, string>; return { client_id: creds.clientid, client_secret: creds.clientsecret, url: creds.url, identityZone: creds.identityzone, tenantMode: creds.tenantmode }; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/lemaiwo/btp-sap-odata-to-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server