Skip to main content
Glama

SAP OData to MCP Server

by Raistlin82
ias-auth-service.ts23.6 kB
import { Logger } from '../utils/logger.js'; import { Config } from '../utils/config.js'; import xsenv from '@sap/xsenv'; import xssec from '@sap/xssec'; export interface IASTokenResponse { access_token: string; token_type: string; expires_in: number; scope?: string; jti?: string; refresh_token?: string; id_token?: string; } export interface IASUserInfo { sub: string; email?: string; preferred_username?: string; given_name?: string; family_name?: string; name?: string; groups?: string[]; scope?: string[]; } export interface TokenData { token: string; user: string; scopes: string[]; expiresAt: number; refreshToken?: string; } export class IASAuthService { private logger: Logger; private config: Config; private iasUrl: string; private clientId: string; private clientSecret: string; private isConfigured: boolean = true; constructor(logger?: Logger, config?: Config) { this.logger = logger || new Logger('IASAuthService'); this.config = config || new Config(); const originalIasUrl = this.config.get('ias.url', process.env.SAP_IAS_URL || ''); const originalClientId = this.config.get('ias.clientId', process.env.SAP_IAS_CLIENT_ID || ''); const originalClientSecret = this.config.get( 'ias.clientSecret', process.env.SAP_IAS_CLIENT_SECRET || '' ); if (!originalIasUrl || !originalClientId || !originalClientSecret) { this.logger.warn( 'IAS configuration missing. Authentication will be disabled. Please set SAP_IAS_URL, SAP_IAS_CLIENT_ID, and SAP_IAS_CLIENT_SECRET environment variables to enable authentication.' ); // Set dummy values to prevent crashes - authentication will be disabled this.iasUrl = 'https://dummy.accounts.ondemand.com'; this.clientId = 'dummy-client-id'; this.clientSecret = 'dummy-client-secret'; this.isConfigured = false; } else { this.iasUrl = originalIasUrl; this.clientId = originalClientId; this.clientSecret = originalClientSecret; this.isConfigured = true; } } /** * Check if IAS is properly configured */ isProperlyConfigured(): boolean { return this.isConfigured; } /** * Generate authorization URL for OAuth 2.0 Authorization Code flow */ generateAuthorizationUrl(redirectUri: string, state?: string): string { if (!this.isConfigured) { throw new Error( 'IAS authentication not configured. Please configure IAS environment variables.' ); } const authUrl = `${this.iasUrl}/oauth2/authorize`; const params = new URLSearchParams({ client_id: this.clientId, response_type: 'code', scope: 'openid profile email groups', redirect_uri: redirectUri, ...(state && { state }), }); return `${authUrl}?${params.toString()}`; } /** * Exchange authorization code for tokens (Authorization Code flow) */ async exchangeCodeForTokens(code: string, redirectUri: string): Promise<TokenData> { if (!this.isConfigured) { throw new Error( 'IAS authentication not configured. Please configure IAS environment variables.' ); } try { this.logger.debug('Exchanging authorization code for tokens'); this.logger.debug(`IAS URL: ${this.iasUrl}`); this.logger.debug(`Client ID: ${this.clientId}`); this.logger.debug(`Client Secret: ${this.clientSecret ? '***PROVIDED***' : 'MISSING'}`); this.logger.debug(`Redirect URI: ${redirectUri}`); this.logger.debug(`Authorization Code: ${code ? 'PROVIDED' : 'MISSING'}`); const tokenUrl = `${this.iasUrl}/oauth2/token`; const params = new URLSearchParams({ grant_type: 'authorization_code', code: code, redirect_uri: redirectUri, client_id: this.clientId, client_secret: this.clientSecret, }); this.logger.debug(`Token URL: ${tokenUrl}`); this.logger.debug(`Request body: ${params.toString()}`); 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 exchange failed: ${response.status} - ${errorText}`); this.logger.error( `Response headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}` ); throw new Error(`Token exchange failed: ${response.status} ${response.statusText}`); } const tokenResponse: IASTokenResponse = (await response.json()) as any; // Get user info from IAS const userInfo = await this.getUserInfo(tokenResponse.access_token); // Try to exchange IAS token for XSUAA token to get application scopes const finalToken = `Bearer ${tokenResponse.access_token}`; let finalScopes = tokenResponse.scope?.split(' ') || userInfo.scope || []; this.logger.info(`Initial IAS scopes: ${finalScopes.join(', ')}`); try { this.logger.info('Using enhanced JWT validator for IAS token validation...'); // Import and use the enhanced JWT validator const { JWTValidator } = await import('../utils/jwt-validator.js'); const jwtValidator = new JWTValidator(this.logger); const validationResult = await jwtValidator.validateJWT(tokenResponse.access_token); if (validationResult.valid && validationResult.userInfo?.scopes) { finalScopes = validationResult.userInfo.scopes; this.logger.info( `✅ IAS token validated with enhanced validator, mapped scopes: ${finalScopes.join(', ')}` ); } else { this.logger.warn('❌ Enhanced JWT validator failed - using basic IAS scopes'); this.logger.warn(`Validation error: ${validationResult.error}`); } } catch (error) { this.logger.error('❌ Enhanced JWT validator failed:', error); // Continue with IAS token - fallback to basic scopes } // Get user name for logging and token creation const userName = userInfo.preferred_username || userInfo.email || userInfo.sub; // Admin scopes should be configured through IAS role collections or XSUAA this.logger.debug(`User scopes from IAS/XSUAA: ${finalScopes.join(', ')}`); const tokenData: TokenData = { token: finalToken, user: userName, scopes: finalScopes, expiresAt: Date.now() + tokenResponse.expires_in * 1000, refreshToken: tokenResponse.refresh_token, }; this.logger.info( `User authenticated successfully: ${tokenData.user}, scopes: ${finalScopes.join(', ')}` ); return tokenData; } catch (error) { this.logger.error('Failed to exchange code for tokens:', error); throw new Error( `Token exchange failed: ${error instanceof Error ? error.message : 'Unknown error'}` ); } } /** * Authenticate user with IAS using username/password (fallback for development only) * @deprecated Use Authorization Code flow instead */ async authenticateUser(username: string, password: string): Promise<TokenData> { if (!this.isConfigured) { throw new Error( 'IAS authentication not configured. Please configure IAS environment variables.' ); } this.logger.warn( 'Using deprecated password flow. Consider using Authorization Code flow instead.' ); try { this.logger.debug(`Authenticating user with IAS: ${username}`); const tokenUrl = `${this.iasUrl}/oauth2/token`; const params = new URLSearchParams({ grant_type: 'password', username: username, password: password, scope: 'openid profile email groups', }); const response = await fetch(tokenUrl, { method: 'POST', headers: { Authorization: `Basic ${Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64')}`, 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', }, body: params.toString(), }); if (!response.ok) { const errorText = await response.text(); this.logger.error(`IAS authentication failed: ${response.status} - ${errorText}`); throw new Error(`Authentication failed: ${response.status} ${response.statusText}`); } const tokenResponse: IASTokenResponse = (await response.json()) as any; // Get user info from IAS const userInfo = await this.getUserInfo(tokenResponse.access_token); const finalScopes = tokenResponse.scope?.split(' ') || userInfo.scope || []; const userName = userInfo.preferred_username || userInfo.email || username; // Admin scopes should be configured through IAS role collections or XSUAA this.logger.debug(`User scopes from IAS: ${finalScopes.join(', ')}`); const tokenData: TokenData = { token: `Bearer ${tokenResponse.access_token}`, user: userName, scopes: finalScopes, expiresAt: Date.now() + tokenResponse.expires_in * 1000, refreshToken: tokenResponse.refresh_token, }; this.logger.info( `User authenticated successfully: ${tokenData.user}, scopes: ${finalScopes.join(', ')}` ); return tokenData; } catch (error) { this.logger.error('Failed to authenticate user with IAS:', error); throw new Error( `Authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}` ); } } /** * Get client credentials token (for app-to-app authentication) */ async getClientCredentialsToken(): Promise<TokenData> { if (!this.isConfigured) { throw new Error( 'IAS authentication not configured. Please configure IAS environment variables.' ); } try { this.logger.debug('Getting client credentials token from IAS'); const tokenUrl = `${this.iasUrl}/oauth2/token`; const params = new URLSearchParams({ grant_type: 'client_credentials', scope: 'read write delete admin discover', }); const response = await fetch(tokenUrl, { method: 'POST', headers: { Authorization: `Basic ${Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64')}`, 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', }, body: params.toString(), }); if (!response.ok) { const errorText = await response.text(); this.logger.error(`IAS client credentials failed: ${response.status} - ${errorText}`); throw new Error( `Client credentials authentication failed: ${response.status} ${response.statusText}` ); } const tokenResponse: IASTokenResponse = (await response.json()) as any; const tokenData: TokenData = { token: `Bearer ${tokenResponse.access_token}`, user: 'system', scopes: tokenResponse.scope?.split(' ') || ['read', 'write', 'delete', 'admin', 'discover'], expiresAt: Date.now() + tokenResponse.expires_in * 1000, }; this.logger.info('Client credentials token obtained successfully'); return tokenData; } catch (error) { this.logger.error('Failed to get client credentials token:', error); throw new Error( `Client credentials authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}` ); } } /** * Refresh an expired token using refresh token */ async refreshToken(refreshToken: string): Promise<TokenData> { if (!this.isConfigured) { throw new Error( 'IAS authentication not configured. Please configure IAS environment variables.' ); } try { this.logger.debug('Refreshing token with IAS'); const tokenUrl = `${this.iasUrl}/oauth2/token`; const params = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, }); const response = await fetch(tokenUrl, { method: 'POST', headers: { Authorization: `Basic ${Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64')}`, '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 failed: ${response.status} - ${errorText}`); throw new Error(`Token refresh failed: ${response.status} ${response.statusText}`); } const tokenResponse: IASTokenResponse = (await response.json()) as any; // Get user info from IAS const userInfo = await this.getUserInfo(tokenResponse.access_token); const tokenData: TokenData = { token: `Bearer ${tokenResponse.access_token}`, user: userInfo.preferred_username || userInfo.email || userInfo.sub, scopes: tokenResponse.scope?.split(' ') || userInfo.scope || [], expiresAt: Date.now() + tokenResponse.expires_in * 1000, refreshToken: tokenResponse.refresh_token || refreshToken, }; this.logger.info(`Token refreshed successfully for user: ${tokenData.user}`); return tokenData; } catch (error) { this.logger.error('Failed to refresh token:', error); throw new Error( `Token refresh failed: ${error instanceof Error ? error.message : 'Unknown error'}` ); } } /** * Get user information from IAS using access token */ async getUserInfo(accessToken: string): Promise<IASUserInfo> { if (!this.isConfigured) { throw new Error( 'IAS authentication not configured. Please configure IAS environment variables.' ); } try { const userInfoUrl = `${this.iasUrl}/oauth2/userinfo`; const token = accessToken.startsWith('Bearer ') ? accessToken : `Bearer ${accessToken}`; const response = await fetch(userInfoUrl, { method: 'GET', headers: { Authorization: token, Accept: 'application/json', }, }); if (!response.ok) { const errorText = await response.text(); this.logger.error(`User info request failed: ${response.status} - ${errorText}`); throw new Error(`User info request failed: ${response.status} ${response.statusText}`); } const userInfo: IASUserInfo = (await response.json()) as any; this.logger.debug( `Retrieved user info for: ${userInfo.preferred_username || userInfo.email || userInfo.sub}` ); // === ENHANCED DEBUG LOG: DECODED JWT CLAIMS FOR SCOPE ANALYSIS === try { // Decode the JWT token to see all claims including scopes const tokenPart = accessToken.replace('Bearer ', '').split('.')[1]; const decodedPayload = JSON.parse(Buffer.from(tokenPart, 'base64').toString()); // JWT payload analysis - only in development mode if (process.env.NODE_ENV === 'development') { this.logger.debug('JWT payload decoded for scope analysis'); // Check for scopes in different claim locations const scopeClaims = { scope: decodedPayload.scope, scopes: decodedPayload.scopes, scp: decodedPayload.scp, authorities: decodedPayload.authorities, 'xs.user.attributes': decodedPayload['xs.user.attributes'], role_collections: decodedPayload.role_collections, groups: decodedPayload.groups, }; // Log only relevant scope information in development Object.entries(scopeClaims).forEach(([key, value]) => { if (value !== undefined) { this.logger.debug(`Scope claim ${key} found`); } }); } // If JWT contains application scopes directly, extract them const directScopes = decodedPayload.scope?.split?.(' ') || decodedPayload.scopes || []; if ( directScopes.some( (scope: string) => scope.includes('admin') || scope.includes('read') || scope.includes('write') ) ) { this.logger.info('✅ Found application scopes directly in JWT token'); userInfo.scope = directScopes; } } catch (debugError) { this.logger.warn('Failed to decode JWT for debugging:', debugError); } // === END ENHANCED DEBUG LOG === return userInfo; } catch (error) { this.logger.error('Failed to get user info:', error); throw new Error( `User info request failed: ${error instanceof Error ? error.message : 'Unknown error'}` ); } } /** * Validate a JWT token with IAS using introspection endpoint (secure validation) */ async validateToken(token: string): Promise<{ valid: boolean; payload?: any; error?: string }> { if (!this.isConfigured) { return { valid: false, error: 'IAS not configured' }; } try { // Remove 'Bearer ' prefix if present const jwtToken = token.startsWith('Bearer ') ? token.substring(7) : token; // Security: Use introspection endpoint instead of local JWT decoding // This ensures the token is validated by the issuer const introspectUrl = `${this.iasUrl}/oauth2/introspect`; const params = new URLSearchParams({ token: jwtToken, token_type_hint: 'access_token', }); const response = await fetch(introspectUrl, { method: 'POST', headers: { Authorization: `Basic ${Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64')}`, 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', }, body: params.toString(), }); if (!response.ok) { this.logger.warn(`Token introspection failed: ${response.status}`); return { valid: false, error: `Introspection failed: ${response.status}` }; } const introspectResult = (await response.json()) as any; if (introspectResult.active === true) { // Token is valid, return payload from introspection return { valid: true, payload: { sub: introspectResult.sub, exp: introspectResult.exp, iss: introspectResult.iss, aud: introspectResult.aud, scope: introspectResult.scope, username: introspectResult.username, }, }; } else { return { valid: false, error: 'Token is not active' }; } } catch (error) { this.logger.error('Token validation failed:', error); return { valid: false, error: error instanceof Error ? error.message : 'Unknown error' }; } } /** * @deprecated This method is insecure and should not be used. * Use validateToken() method instead which uses proper introspection. */ private decodeJWT(_token: string): never { this.logger.error( 'SECURITY WARNING: decodeJWT() called - this method is deprecated and insecure' ); throw new Error( 'decodeJWT() method is deprecated for security reasons. Use validateToken() instead.' ); } /** * Exchange IAS token for XSUAA token to get application scopes */ private async exchangeIASTokenForXSUAA( iasToken: string ): Promise<{ token: string; scopes: string[] } | null> { try { // Get XSUAA service credentials this.logger.info('🔍 Looking for XSUAA service binding...'); const services = xsenv.getServices({ xsuaa: { label: 'xsuaa' } }); const xsuaaCredentials = services.xsuaa; if (!xsuaaCredentials) { this.logger.warn('❌ No XSUAA service binding found, skipping token exchange'); this.logger.info( '💡 To get application scopes, ensure XSUAA service is bound to your application' ); return null; } this.logger.info(`✅ Found XSUAA service binding: ${(xsuaaCredentials as any).xsappname}`); this.logger.debug('XSUAA credentials:', { url: (xsuaaCredentials as any).url, clientid: (xsuaaCredentials as any).clientid, xsappname: (xsuaaCredentials as any).xsappname, }); // Use the IAS token to create a security context with XSUAA // This will validate the token and potentially map to application scopes this.logger.info('🔄 Creating XSUAA security context...'); const securityContext = await new Promise<any>((resolve, reject) => { xssec.createSecurityContext( `Bearer ${iasToken}`, xsuaaCredentials, (err: any, ctx: any) => { if (err) { this.logger.error('❌ XSUAA security context creation failed:', err); reject(err); } else { this.logger.info('✅ XSUAA security context created successfully'); resolve(ctx); } } ); }); if (securityContext) { const userInfo = securityContext.getTokenInfo(); const grantedScopes = securityContext.getGrantedScopes(); this.logger.info(`👤 XSUAA user: ${userInfo.getLogonName()}`); this.logger.info( `🎫 XSUAA granted scopes (${grantedScopes.length}): ${grantedScopes.join(', ')}` ); // Check for application scopes const hasAppScopes = grantedScopes.some( (scope: string) => scope.includes('.admin') || scope.includes('.read') || scope.includes('.write') || scope.includes('.delete') || scope.includes('.discover') ); this.logger.info(`🔐 Has application scopes: ${hasAppScopes}`); if (hasAppScopes) { this.logger.info('✅ Returning XSUAA token with application scopes'); return { token: `Bearer ${iasToken}`, // Keep the same token but with validated scopes scopes: grantedScopes, }; } else { this.logger.warn('❌ XSUAA context created but no application scopes found'); this.logger.info( '💡 Check role collection assignment and role template mapping in BTP Cockpit' ); return null; } } this.logger.warn('❌ XSUAA security context is null'); return null; } catch (error) { this.logger.error('❌ IAS to XSUAA token exchange failed:', error); if (error instanceof Error) { this.logger.error('Error details:', error.message); this.logger.error('Error stack:', error.stack); } return null; } } /** * Get IAS configuration for client setup */ getConfiguration() { return { iasUrl: this.iasUrl, clientId: this.clientId, authorizationEndpoint: `${this.iasUrl}/oauth2/authorize`, tokenEndpoint: `${this.iasUrl}/oauth2/token`, userInfoEndpoint: `${this.iasUrl}/oauth2/userinfo`, introspectionEndpoint: `${this.iasUrl}/oauth2/introspect`, supportedGrantTypes: [ 'authorization_code', 'client_credentials', 'refresh_token', 'password', ], supportedScopes: [ 'openid', 'profile', 'email', 'groups', 'read', 'write', 'delete', 'admin', 'discover', ], }; } }

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/Raistlin82/btp-sap-odata-to-mcp-server-optimized'

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