Skip to main content
Glama
dual-auth.ts28.2 kB
import axios, { AxiosInstance } from 'axios'; import { AuthCredentials, AuthToken, AuthCredentialsSchema } from './types.js'; export interface UserManagementInfo { isKeycloak: boolean; realm?: string; authMethod: 'keycloak' | 'cognito'; } export class UmbrellaDualAuth { private baseURL: string; private axiosInstance: AxiosInstance; private token: string | null = null; private tokenExpiry: number = 0; private userManagementInfo: UserManagementInfo | null = null; private userKey: string | null = null; private username: string | null = null; private availableAccounts: any[] = []; private plainSubUsersCache: Map<string, { data: any; timestamp: number }> = new Map(); private CACHE_TTL_MS = 10 * 60 * 1000; // Cache for 10 minutes constructor(baseURL: string) { this.baseURL = baseURL; this.axiosInstance = axios.create({ baseURL: this.baseURL, timeout: 30000, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' } }); } /** * Set pre-authenticated tokens (for HTTPS OAuth flow) */ setPreAuthenticatedTokens(tokens: { Authorization: string }): void { this.token = tokens.Authorization; // Set token expiry to 24 hours from now (or match config) this.tokenExpiry = Date.now() + (24 * 60 * 60 * 1000); // Extract userKey from JWT token (Cognito token format) try { // The Authorization should be the raw JWT, not with Bearer prefix let jwtToken = tokens.Authorization; if (jwtToken.startsWith('Bearer ')) { jwtToken = jwtToken.substring(7); // Remove 'Bearer ' prefix if present } // Decode the JWT payload to extract the user ID const tokenPayload = JSON.parse(Buffer.from(jwtToken.split('.')[1], 'base64').toString()); // Extract userKey from token dynamically based on what's in the token this.userKey = tokenPayload.sub || // Cognito uses 'sub' for user ID tokenPayload['cognito:username'] || // Alternative Cognito field tokenPayload['custom:apikey']?.split(':')[0] || tokenPayload['custom:user_key'] || tokenPayload.username || tokenPayload['custom:username']; if (this.userKey) { console.error(`[DUAL-AUTH] Extracted userKey from JWT: ${this.userKey}`); } else { console.error('[DUAL-AUTH] Warning: Could not extract userKey from JWT token'); } // Extract username/email for UM detection this.username = tokenPayload.email || tokenPayload.preferred_username || tokenPayload.username; if (this.username) { console.error(`[DUAL-AUTH] Extracted username from JWT: ${this.username}`); // Detect user management system (Keycloak vs Cognito) asynchronously // This is a non-blocking call that sets userManagementInfo for later use this.detectUserManagementSystem(this.username).catch(err => { console.error('[DUAL-AUTH] Failed to detect UM system:', err.message); }); } } catch (error) { console.error('[DUAL-AUTH] Failed to extract userKey from token:', error); } // Don't fetch accounts here - it causes timeouts in Claude Desktop // Accounts will be fetched lazily when buildCustomerApiKey is called } /** * Fetch accounts for the authenticated user (OAuth flow) * This also detects and sets the user management system info */ private async fetchAccounts(): Promise<void> { if (!this.token || !this.userKey) { console.error('[DUAL-AUTH] Cannot fetch accounts: missing token or userKey'); return; } try { // For OAuth users, we need to detect the auth method first // Since we don't have username, we'll default to checking both endpoints // Try Keycloak UM 2.0 endpoint first with correct API key format: {userKey}:-1:-1 console.error('[DUAL-AUTH] 🔍 OAuth: Trying Keycloak UM 2.0 endpoint...'); try { const keycloakApiKey = `${this.userKey}:-1:-1`; const accountsResponse = await this.axiosInstance.get('/user-management/accounts', { headers: { 'Authorization': this.token, 'apikey': keycloakApiKey, 'Content-Type': 'application/json', 'Accept': 'application/json' } }); if (accountsResponse.status === 200 && accountsResponse.data) { this.availableAccounts = accountsResponse.data; this.userManagementInfo = { isKeycloak: true, authMethod: 'keycloak' }; console.error(`[DUAL-AUTH] ✅ Keycloak UM 2.0 detected - Fetched ${this.availableAccounts.length} accounts`); console.error(`[DUAL-AUTH] ✅ userManagementInfo set:`, JSON.stringify(this.userManagementInfo)); return; } } catch (keycloakError: any) { console.error('[DUAL-AUTH] ⚠️ Keycloak UM 2.0 endpoint failed, trying Cognito...'); } // Fallback to Cognito endpoint with Old UM API key format: {userKey}:-1 console.error('[DUAL-AUTH] 🔍 OAuth: Trying Cognito Old UM endpoint...'); const cognitoApiKey = `${this.userKey}:-1`; const cognitoResponse = await this.axiosInstance.get('/users', { headers: { 'Authorization': this.token, 'apikey': cognitoApiKey, 'Content-Type': 'application/json', 'Accept': 'application/json' } }); if (cognitoResponse.status === 200 && cognitoResponse.data) { this.availableAccounts = cognitoResponse.data.accounts || []; this.userManagementInfo = { isKeycloak: false, authMethod: 'cognito' }; console.error(`[DUAL-AUTH] ✅ Cognito Old UM detected - Fetched ${this.availableAccounts.length} accounts`); console.error(`[DUAL-AUTH] ✅ userManagementInfo set:`, JSON.stringify(this.userManagementInfo)); } // Debug: List all available accounts if (this.availableAccounts.length > 0) { console.error(`[DUAL-AUTH] 🔍 Available accounts:`); this.availableAccounts.forEach((acc: any, index: number) => { const accountKey = acc.accountKey || acc.account_key || acc.accountId || acc.account_id || 'N/A'; const accountName = acc.accountName || acc.account_name || acc.name || 'N/A'; console.error(`[DUAL-AUTH] ${index + 1}. Key: ${accountKey}, Name: ${accountName}, Type: ${acc.cloudType || 'N/A'}`); }); } } catch (error: any) { console.error(`[DUAL-AUTH] Failed to fetch accounts: ${error.message}`); this.availableAccounts = []; } } /** * Detect user management system (Keycloak UM 2.0 vs Cognito Old UM) */ async detectUserManagementSystem(username: string): Promise<UserManagementInfo> { try { console.error(`[DUAL-AUTH] Detecting user management system for: ${username}`); // Call the user-realm endpoint to check if user is on Keycloak const realmResponse = await this.axiosInstance.get(`/v1/user-management/users/user-realm`, { params: { username: username.toLowerCase() } }); if (realmResponse.status === 200 && realmResponse.data?.realm) { console.error(`[DUAL-AUTH] User is on Keycloak UM 2.0, realm: ${realmResponse.data.realm}`); return { isKeycloak: true, realm: realmResponse.data.realm, authMethod: 'keycloak' }; } else { console.error(`[DUAL-AUTH] User is on Cognito Old UM (no realm found)`); return { isKeycloak: false, authMethod: 'cognito' }; } } catch (error: any) { console.error(`[DUAL-AUTH] Realm detection failed, defaulting to Cognito: ${error.message}`); // Default to old user management if detection fails return { isKeycloak: false, authMethod: 'cognito' }; } } async authenticate(credentials: AuthCredentials): Promise<AuthToken> { // Validate credentials const validatedCredentials = AuthCredentialsSchema.parse(credentials); // First, detect which user management system this user is on this.userManagementInfo = await this.detectUserManagementSystem(validatedCredentials.username); try { let authResponse; if (this.userManagementInfo.isKeycloak) { // Use new authentication endpoint for Keycloak users (UM 2.0) console.error(`[DUAL-AUTH] Authenticating Keycloak user with /authentication/token/generate...`); try { authResponse = await this.authenticateKeycloak(validatedCredentials); } catch (keycloakError: any) { // Fallback: If Keycloak fails with 500, try Cognito (some migrated accounts may still use Cognito) if (keycloakError.response?.status === 500) { console.error(`[DUAL-AUTH] Keycloak failed with 500, trying Cognito fallback...`); // Update the user management info to reflect we're actually using Cognito this.userManagementInfo = { isKeycloak: false, authMethod: 'cognito' }; authResponse = await this.authenticateCognito(validatedCredentials); } else { throw keycloakError; } } } else { // Use old signin endpoint for Cognito users (Old UM) console.error(`[DUAL-AUTH] Authenticating Cognito user with /users/signin...`); authResponse = await this.authenticateCognito(validatedCredentials); } return authResponse; } catch (error: any) { console.error(`[DUAL-AUTH] Authentication failed for ${this.userManagementInfo.authMethod}: ${error.message}`); if (error.response) { console.error(`[DUAL-AUTH] HTTP Status: ${error.response.status}`); console.error(`[DUAL-AUTH] Response Data:`, JSON.stringify(error.response.data, null, 2)); } throw error; } } /** * Authenticate using Keycloak (User Management 2.0) * Uses the new /authentication/token/generate endpoint with Basic Auth */ private async authenticateKeycloak(credentials: AuthCredentials): Promise<AuthToken> { // Create Basic Auth header - this is what the authentication endpoint expects! const basicAuth = Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64'); const response = await this.axiosInstance.post('/v1/authentication/token/generate', { username: credentials.username, password: credentials.password }, { headers: { 'Authorization': `Basic ${basicAuth}`, 'Content-Type': 'application/json' } }); if (response.status === 200 && response.data.Authorization && response.data.apikey) { this.token = response.data.Authorization; // Extract and store userKey for Keycloak users const tempApiKey = response.data.apikey; this.userKey = tempApiKey.split(':')[0]; this.tokenExpiry = Date.now() + (60 * 60 * 1000); console.error(`[DUAL-AUTH] Keycloak authentication successful, userKey: ${this.userKey}`); return { Authorization: response.data.Authorization, userManagementInfo: this.userManagementInfo || undefined }; } else { throw new Error('Invalid response from Keycloak authentication endpoint'); } } /** * Authenticate using Cognito (Old User Management) * Uses the old /users/signin endpoint */ private async authenticateCognito(credentials: AuthCredentials): Promise<AuthToken> { const response = await this.axiosInstance.post('/v1/users/signin', { username: credentials.username, password: credentials.password }); if (response.status === 200 && response.data.jwtToken) { this.token = response.data.jwtToken; // For Cognito, the response format is different // Extract userKey from JWT token payload const tokenPayload = JSON.parse(Buffer.from(response.data.jwtToken.split('.')[1], 'base64').toString()); this.userKey = tokenPayload.username || tokenPayload['custom:username'] || tokenPayload.sub; this.tokenExpiry = Date.now() + (60 * 60 * 1000); console.error(`[DUAL-AUTH] Cognito authentication successful, userKey: ${this.userKey}`); return { Authorization: response.data.jwtToken, userManagementInfo: this.userManagementInfo || undefined }; } else { throw new Error('Invalid response from Cognito authentication endpoint'); } } /** * Get current authentication headers for API requests */ getAuthHeaders(): AuthToken { if (!this.token) { throw new Error('Not authenticated. Call authenticate() first.'); } if (Date.now() > this.tokenExpiry) { throw new Error('Token expired. Please re-authenticate.'); } return { Authorization: this.token }; } /** * Get user management system info */ getUserManagementInfo(): UserManagementInfo | null { return this.userManagementInfo; } /** * Get user key for building API keys */ getUserKey(): string | null { return this.userKey; } /** * Get default API key - builds a simple API key with userKey and default account */ getDefaultApiKey(): string | null { if (!this.userKey) { console.error('[DUAL-AUTH] Cannot build default API key: missing userKey'); return null; } // Use the first available account or a default format if (this.availableAccounts.length > 0) { const firstAccount = this.availableAccounts[0]; const accountKey = firstAccount.account_key || firstAccount.accountKey; // For Keycloak UM 2.0: use empty divisionId with trailing colon // For Cognito: use divisionId from account or 0 if (this.userManagementInfo?.isKeycloak) { return `${this.userKey}:${accountKey}:`; } else { const divisionId = firstAccount.division_id || 0; return `${this.userKey}:${accountKey}:${divisionId}`; } } // Fallback to user key with placeholder values // For Keycloak: -1:-1, For Cognito: -1:0 if (this.userManagementInfo?.isKeycloak) { return `${this.userKey}:-1:-1`; } else { return `${this.userKey}:-1:0`; } } /** * Check if the current token is still valid */ isAuthenticated(): boolean { return !!(this.token && Date.now() < this.tokenExpiry); } /** * Build API key for specific cloud context */ buildCloudContextApiKey(cloudContext: string): string | null { if (!this.userKey || this.availableAccounts.length === 0) { console.error('[DUAL-AUTH] Cannot build cloud context API key: missing userKey or accounts'); return null; } const normalizedContext = cloudContext.toLowerCase(); let targetAccount = null; console.error(`[DUAL-AUTH] 🔍 Building API key for cloud context: ${normalizedContext}`); // Find the appropriate account based on cloud context if (normalizedContext === 'aws') { // AWS: Look for account 9350 targetAccount = this.availableAccounts.find((acc: any) => { const accountKey = acc.accountKey || acc.account_key || acc.accountId || acc.account_id; const keyString = String(accountKey); return keyString === '9350' || accountKey === 9350; }); } else if (normalizedContext === 'gcp') { // GCP: Look for MasterBilling account (contains "Master", "21112", or "59f88c") targetAccount = this.availableAccounts.find((acc: any) => { const accountKey = acc.accountKey || acc.account_key || acc.accountId || acc.account_id; const accountName = acc.accountName || acc.account_name || acc.name || ''; const keyString = String(accountKey); return keyString === '21112' || accountKey === 21112 || accountName.toLowerCase().includes('master') || keyString.includes('59f88c'); }); } else if (normalizedContext === 'azure') { // Azure: Prioritize AzureAmortized (23105) which has more recent data, then fallback to others targetAccount = this.availableAccounts.find((acc: any) => { const accountKey = acc.accountKey || acc.account_key || acc.accountId || acc.account_id; const keyString = String(accountKey); // First priority: AzureAmortized (has 2025 data) return keyString === '23105' || accountKey === 23105; }); // If AzureAmortized not found, look for other Azure accounts if (!targetAccount) { targetAccount = this.availableAccounts.find((acc: any) => { const accountKey = acc.accountKey || acc.account_key || acc.accountId || acc.account_id; const accountName = acc.accountName || acc.account_name || acc.name || ''; const keyString = String(accountKey); // Check for other Azure accounts or "Azure" in name return keyString === '9347' || accountKey === 9347 || // Azure-Pileus accountName.toLowerCase().includes('azure'); }); } } if (!targetAccount) { console.error(`[DUAL-AUTH] ⚠️ No ${normalizedContext.toUpperCase()} account found, using default AWS account`); // Fallback to AWS account 9350 targetAccount = this.availableAccounts.find((acc: any) => { const accountKey = acc.accountKey || acc.account_key || acc.accountId || acc.account_id; const keyString = String(accountKey); return keyString === '9350' || accountKey === 9350; }); } if (!targetAccount) { console.error(`[DUAL-AUTH] ⚠️ No suitable account found, using first available account`); targetAccount = this.availableAccounts[0]; } const accountKey = targetAccount.accountKey || targetAccount.account_key || targetAccount.accountId || targetAccount.account_id; // For Keycloak UM 2.0 Direct customers: use empty divisionId with trailing colon // For Cognito (Old UM): use divisionId 0 let divisionId: string | number = 0; if (this.userManagementInfo?.isKeycloak) { divisionId = ''; // Empty string for UM 2.0 console.error(`[DUAL-AUTH] Keycloak UM 2.0 detected - using empty divisionId`); } const cloudApiKey = `${this.userKey}:${accountKey}:${divisionId}`; console.error(`[DUAL-AUTH] ✅ Built ${normalizedContext.toUpperCase()} API key: ${cloudApiKey}`); return cloudApiKey; } async buildCustomerApiKey(customerAccountKey: string, apiPath?: string, customerDivisionId?: string): Promise<string | null> { console.error(`[DUAL-AUTH] 🔧 buildCustomerApiKey CALLED with:`); console.error(`[DUAL-AUTH] customerAccountKey: "${customerAccountKey}"`); console.error(`[DUAL-AUTH] customerDivisionId: "${customerDivisionId}" (type: ${typeof customerDivisionId})`); console.error(`[DUAL-AUTH] apiPath: "${apiPath}"`); console.error(`[DUAL-AUTH] this.token exists: ${!!this.token}`); console.error(`[DUAL-AUTH] this.userKey: "${this.userKey}"`); console.error(`[DUAL-AUTH] this.userManagementInfo?.isKeycloak: ${this.userManagementInfo?.isKeycloak}`); if (!this.userKey) { console.error('[DUAL-AUTH] Cannot build customer API key: missing userKey'); return null; } // For OAuth users: userManagementInfo should already be set from the stored token // DO NOT re-detect - use the auth method that actually succeeded during login if (this.token && !this.userManagementInfo) { console.error(`[DUAL-AUTH] ⚠️ OAuth user but userManagementInfo not set!`); console.error(`[DUAL-AUTH] This should not happen - userManagementInfo should be in the token.`); console.error(`[DUAL-AUTH] Defaulting to Cognito to be safe (most common legacy auth method).`); // DO NOT call detectUserManagementSystem() - it's unreliable for users in migration state // The token should already contain the correct auth method from login this.userManagementInfo = { isKeycloak: false, authMethod: 'cognito' }; } // For OAuth users with pre-authenticated tokens, if we have customerAccountKey and customerDivisionId, // just build the API key directly without needing account data const quickPathCondition = this.token && customerAccountKey && customerDivisionId !== undefined && customerDivisionId !== null; console.error(`[DUAL-AUTH] 🔍 Quick path condition evaluation:`); console.error(`[DUAL-AUTH] this.token: ${!!this.token}`); console.error(`[DUAL-AUTH] customerAccountKey: "${customerAccountKey}" -> ${!!customerAccountKey}`); console.error(`[DUAL-AUTH] customerDivisionId !== undefined: ${customerDivisionId !== undefined}`); console.error(`[DUAL-AUTH] customerDivisionId !== null: ${customerDivisionId !== null}`); console.error(`[DUAL-AUTH] ⚡ QUICK PATH: ${quickPathCondition ? 'YES - Taking quick path' : 'NO - Taking full lookup path'}`); if (quickPathCondition) { // For Keycloak UM 2.0: use empty divisionId (Direct customers don't use divisions) // For Cognito: use numeric divisionId let apiKey: string; console.error(`[DUAL-AUTH] 🔍 Checking user management system: isKeycloak=${this.userManagementInfo?.isKeycloak}`); if (this.userManagementInfo?.isKeycloak) { apiKey = `${this.userKey}:${customerAccountKey}:`; // Empty divisionId with trailing colon console.error(`[DUAL-AUTH] ✅ OAuth Keycloak UM 2.0 - Built API key with empty divisionId: ${apiKey}`); console.error(`[DUAL-AUTH] IGNORING customerDivisionId parameter "${customerDivisionId}" for Keycloak user`); } else { const divisionId = parseInt(String(customerDivisionId), 10); apiKey = `${this.userKey}:${customerAccountKey}:${divisionId}`; console.error(`[DUAL-AUTH] ✅ OAuth Cognito - Built API key with divisionId: ${apiKey}`); } return apiKey; } // Fetch accounts lazily if not already fetched (OAuth flow) if (this.token && this.availableAccounts.length === 0) { console.error('[DUAL-AUTH] Lazy-fetching accounts for OAuth user...'); await this.fetchAccounts(); console.error(`[DUAL-AUTH] Fetched ${this.availableAccounts.length} accounts`); } if (this.availableAccounts.length === 0) { console.error('[DUAL-AUTH] Cannot build customer API key: no accounts available'); return null; } console.error(`[DUAL-AUTH] 🔍 Building API key for customer account: ${customerAccountKey}`); console.error(`[DUAL-AUTH] 📝 Received customerDivisionId parameter: ${customerDivisionId} (type: ${typeof customerDivisionId})`); // Find the specific customer account const targetAccount = this.availableAccounts.find((acc: any) => { const accountKey = acc.accountKey || acc.account_key || acc.accountId || acc.account_id; const keyString = String(accountKey); return keyString === customerAccountKey || accountKey === customerAccountKey; }); if (!targetAccount) { console.error(`[DUAL-AUTH] ⚠️ Customer account ${customerAccountKey} not found in available accounts`); return null; } const accountKey = targetAccount.accountKey || targetAccount.account_key || targetAccount.accountId || targetAccount.account_id; const accountName = targetAccount.accountName || targetAccount.account_name || targetAccount.name || ''; // Different division lookup logic based on user management system // For Keycloak UM 2.0 Direct customers: divisionId is empty string (with trailing colon) // For Cognito (Old UM): divisionId is a number let divisionId: string | number = 1; // Default fallback // PRIORITY 1: Use provided customerDivisionId if available if (customerDivisionId !== undefined && customerDivisionId !== null && customerDivisionId !== '') { divisionId = parseInt(String(customerDivisionId), 10); console.error(`[DUAL-AUTH] ✅ Using provided division ${divisionId} from parameter (overrides system-specific lookup)`); } else if (this.userManagementInfo?.isKeycloak) { // Keycloak UM 2.0: Use /user-management/accounts API for Direct customers (no divisions) // For UM 2.0 Direct customers, we use empty divisionId with trailing colon try { // Use the special API key format for fetching accounts: {userKey}:-1:-1 const tempApiKey = `${this.userKey}:-1:-1`; const cacheKey = `um2-accounts:${this.userKey}`; // Check cache first let accountsData = null; const cached = this.plainSubUsersCache.get(cacheKey); if (cached && (Date.now() - cached.timestamp) < this.CACHE_TTL_MS) { console.error(`[DUAL-AUTH] 📦 Using cached user-management/accounts data (cache age: ${Math.round((Date.now() - cached.timestamp) / 1000)}s)`); accountsData = cached.data; } else { console.error(`[DUAL-AUTH] 🔍 Keycloak UM 2.0 - Fetching accounts via /user-management/accounts API...`); const accountsResponse = await this.axiosInstance.get('/user-management/accounts', { headers: { 'Authorization': this.token, 'apikey': tempApiKey, 'Content-Type': 'application/json', 'Accept': 'application/json' } }); if (accountsResponse.status === 200 && accountsResponse.data) { accountsData = accountsResponse.data; // Cache the response this.plainSubUsersCache.set(cacheKey, { data: accountsData, timestamp: Date.now() }); console.error(`[DUAL-AUTH] 📦 Cached user-management/accounts response`); } } if (accountsData) { // UM 2.0 Direct customers: No divisions, so divisionId is empty string // The accounts response contains account list without division structure console.error(`[DUAL-AUTH] 📋 Keycloak UM 2.0 Direct customer - No divisions (using empty divisionId)`); divisionId = ''; // Empty string for UM 2.0 Direct customers console.error(`[DUAL-AUTH] ✅ Keycloak UM 2.0: Using empty divisionId for Direct customer account ${customerAccountKey}`); } } catch (error: any) { console.error(`[DUAL-AUTH] ⚠️ Keycloak UM 2.0 accounts lookup failed: ${error.message}`); // For UM 2.0, default to empty divisionId on error divisionId = ''; console.error(`[DUAL-AUTH] Using empty divisionId for Keycloak UM 2.0 user (Direct customer)`); } } else { // Cognito (Old UM): Use customer-specific division mapping console.error(`[DUAL-AUTH] 🔍 Cognito user - Using customer-specific division logic for MSP customer ${customerAccountKey}`); // Use provided customer division ID if available (from customer detection) if (customerDivisionId !== undefined && customerDivisionId !== null) { divisionId = parseInt(customerDivisionId, 10); console.error(`[DUAL-AUTH] ✅ Cognito: Using division ${divisionId} from customer detection system`); } else { // Fallback: For other customers, use division 0 (master division) // This should only happen when no customer is detected (generic queries) divisionId = 0; console.error(`[DUAL-AUTH] ⚠️ Cognito: No division ID provided, using default division 0`); } } const customerApiKey = `${this.userKey}:${accountKey}:${divisionId}`; console.error(`[DUAL-AUTH] ✅ Built customer API key for "${accountName}" with division ${divisionId}: ${customerApiKey}`); return customerApiKey; } /** * Get available accounts (for debugging) */ getAvailableAccounts(): any[] { return this.availableAccounts; } /** * Clear the stored authentication data */ clearAuth(): void { this.token = null; this.tokenExpiry = 0; this.userManagementInfo = null; this.userKey = null; this.availableAccounts = []; } }

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/daviddraiumbrella/invoice-monitoring'

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