UNPKG

@aashari/mcp-server-aws-sso

Version:

Node.js/TypeScript MCP server for AWS Single Sign-On (SSO). Enables AI systems (LLMs) with tools to initiate SSO login (device auth flow), list accounts/roles, and securely execute AWS CLI commands using temporary credentials. Streamlines AI interaction w

476 lines (475 loc) 23.2 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.getCachedCredentials = void 0; exports.listSsoAccounts = listSsoAccounts; exports.listAccountRoles = listAccountRoles; exports.getAwsCredentials = getAwsCredentials; exports.getAllAccountsWithRoles = getAllAccountsWithRoles; /** * AWS SSO accounts vendor service */ const logger_util_js_1 = require("../utils/logger.util.js"); const error_util_js_1 = require("../utils/error.util.js"); const aws_sso_cache_util_js_1 = require("../utils/aws.sso.cache.util.js"); Object.defineProperty(exports, "getCachedCredentials", { enumerable: true, get: function () { return aws_sso_cache_util_js_1.getCachedCredentials; } }); const vendor_aws_sso_types_js_1 = require("./vendor.aws.sso.types.js"); const vendor_aws_sso_auth_core_service_js_1 = require("./vendor.aws.sso.auth.core.service.js"); const client_sso_1 = require("@aws-sdk/client-sso"); const retry_util_js_1 = require("../utils/retry.util.js"); const zod_1 = require("zod"); const logger = logger_util_js_1.Logger.forContext('services/vendor.aws.sso.accounts.service.ts'); // Create inline schemas that were previously imported const AccountInfoSchema = zod_1.z.object({ accountId: zod_1.z.string().optional(), accountName: zod_1.z.string().optional(), emailAddress: zod_1.z.string().optional(), }); // Add a local AwsSsoAccountSchema (no export needed) const AwsSsoAccountSchema = zod_1.z.object({ accountId: zod_1.z.string(), accountName: zod_1.z.string(), accountEmail: zod_1.z.string().optional(), }); /** * List AWS SSO accounts for the authenticated user * * Retrieves the list of AWS accounts that the user has access to via SSO. * Requires an active SSO token. * * @param {ListAccountsParams} [params={}] - Optional parameters for customizing the request * @param {number} [params.maxResults] - Maximum number of accounts to return * @param {string} [params.nextToken] - Pagination token for subsequent requests * @returns {Promise<ListAccountsResponse>} List of AWS SSO accounts and pagination token if available * @throws {Error} If SSO token is missing or API request fails */ async function listSsoAccounts(params = {}) { const methodLogger = logger.forMethod('listSsoAccounts'); methodLogger.debug('Listing AWS SSO accounts', params); // Get SSO token const token = await (0, vendor_aws_sso_auth_core_service_js_1.getCachedSsoToken)(); if (!token) { throw (0, error_util_js_1.createAuthMissingError)('No SSO token found. Please login first.'); } try { // Use AWS SDK to list accounts instead of direct API call const region = token.region || 'us-east-1'; // Create SSO client with proper region const ssoClient = new client_sso_1.SSOClient({ region: region, // Disable the built-in retry to use our custom implementation maxAttempts: 1, }); // Configure command with proper parameters const command = new client_sso_1.ListAccountsCommand({ accessToken: token.accessToken, maxResults: params.maxResults, nextToken: params.nextToken, }); // Execute command with retry logic to handle 429 errors methodLogger.debug('Requesting accounts list using AWS SDK with retry mechanism'); const response = await (0, retry_util_js_1.withRetry)(() => ssoClient.send(command), { // Use default retry options, can be adjusted if needed maxRetries: 5, initialDelayMs: 1000, maxDelayMs: 30000, backoffFactor: 2.0, }); // Validate accounts with Zod schema try { // First validate that each account matches the AccountInfo schema if (response.accountList) { for (const account of response.accountList) { AccountInfoSchema.parse(account); } } // Then validate the overall response const result = vendor_aws_sso_types_js_1.ListAccountsResponseSchema.parse({ accountList: response.accountList || [], nextToken: response.nextToken, }); methodLogger.debug(`Retrieved ${result.accountList.length} accounts${result.nextToken ? ' with pagination token' : ''}`); return result; } catch (error) { if (error instanceof zod_1.z.ZodError) { methodLogger.error('Invalid accounts response format', error); const issueSummary = error.issues .map((issue) => { const path = issue.path.length > 0 ? issue.path.join('.') : '(root)'; return `${path}: ${issue.message}`; }) .join(', '); throw (0, error_util_js_1.createApiError)(`Invalid response format from AWS SSO: ${issueSummary}`, undefined, error); } throw error; } } catch (error) { methodLogger.error('Failed to list accounts', error); throw (0, error_util_js_1.createApiError)(`Failed to list AWS accounts: ${error instanceof Error ? error.message : String(error)}`, undefined, error); } } /** * List roles for a specific AWS SSO account * * Retrieves the list of roles that the user can assume in the specified AWS account. * Requires an active SSO token. * * @param {ListAccountRolesParams} params - Parameters for the request * @param {string} params.accountId - AWS account ID to list roles for * @param {number} [params.maxResults] - Maximum number of roles to return * @param {string} [params.nextToken] - Pagination token for subsequent requests * @returns {Promise<ListAccountRolesResponse>} List of AWS SSO roles and pagination token if available * @throws {Error} If SSO token is missing or API request fails */ async function listAccountRoles(params) { const methodLogger = logger.forMethod('listAccountRoles'); methodLogger.debug('Listing AWS SSO account roles', params); // Validate required parameters if (!params.accountId) { throw new Error('Account ID is required'); } // Get SSO token const token = await (0, vendor_aws_sso_auth_core_service_js_1.getCachedSsoToken)(); if (!token) { throw (0, error_util_js_1.createAuthMissingError)('No SSO token found. Please login first.'); } try { // Use AWS SDK to list roles instead of direct API call const region = token.region || 'us-east-1'; // Create SSO client with proper region const ssoClient = new client_sso_1.SSOClient({ region: region, // Disable the built-in retry to use our custom implementation maxAttempts: 1, }); // Configure command with proper parameters const command = new client_sso_1.ListAccountRolesCommand({ accessToken: token.accessToken, accountId: params.accountId, maxResults: params.maxResults, nextToken: params.nextToken, }); // Execute command with retry logic to handle 429 errors methodLogger.debug('Requesting roles list using AWS SDK with retry mechanism'); const response = await (0, retry_util_js_1.withRetry)(() => ssoClient.send(command), { // Use default retry options, can be adjusted if needed maxRetries: 5, initialDelayMs: 1000, maxDelayMs: 30000, backoffFactor: 2.0, }); // Validate roles with Zod schema try { // First validate that each role matches the RoleInfo schema if (response.roleList) { for (const role of response.roleList) { vendor_aws_sso_types_js_1.RoleInfoSchema.parse(role); } } // Then validate the overall response const result = vendor_aws_sso_types_js_1.ListAccountRolesResponseSchema.parse({ roleList: response.roleList || [], nextToken: response.nextToken, }); methodLogger.debug(`Retrieved ${result.roleList.length} roles for account ${params.accountId}${result.nextToken ? ' with pagination token' : ''}`); return result; } catch (error) { if (error instanceof zod_1.z.ZodError) { methodLogger.error('Invalid roles response format', error); const issueSummary = error.issues .map((issue) => { const path = issue.path.length > 0 ? issue.path.join('.') : '(root)'; return `${path}: ${issue.message}`; }) .join(', '); throw (0, error_util_js_1.createApiError)(`Invalid response format from AWS SSO: ${issueSummary}`, undefined, error); } throw error; } } catch (error) { methodLogger.error('Failed to list roles', error); throw (0, error_util_js_1.createApiError)(`Failed to list roles for account ${params.accountId}: ${error instanceof Error ? error.message : String(error)}`, undefined, error); } } /** * Get temporary AWS credentials for a role via SSO * * Retrieves temporary AWS credentials for the specified account and role. * Requires an active SSO token. * * @param {GetCredentialsParams} params - Parameters for the request * @param {string} params.accountId - AWS account ID * @param {string} params.roleName - Role name to assume * @param {string} [params.region] - Optional AWS region override * @param {boolean} [params.forceRefresh] - Force refresh credentials even if cached * @returns {Promise<AwsCredentials>} Temporary AWS credentials * @throws {Error} If SSO token is missing or API request fails */ async function getAwsCredentials(params) { const methodLogger = logger.forMethod('getAwsCredentials'); methodLogger.debug('Getting AWS credentials', { accountId: params.accountId, roleName: params.roleName, forceRefresh: !!params.forceRefresh, }); // Validate required parameters if (!params.accountId || !params.roleName) { throw new Error('Account ID and role name are required'); } // Check if we should force refresh if (!params.forceRefresh) { // First, check if we have cached credentials const cachedCreds = await (0, aws_sso_cache_util_js_1.getCachedCredentials)(params.accountId, params.roleName); if (cachedCreds) { const now = new Date(); // Allow a 5-minute buffer before expiration const expiration = new Date(cachedCreds.expiration); const bufferMs = 5 * 60 * 1000; // 5 minutes in milliseconds if (expiration.getTime() - now.getTime() > bufferMs) { methodLogger.debug('Using cached credentials', { accountId: params.accountId, roleName: params.roleName, expiration: expiration.toISOString(), }); // Ensure we have the right type const credentials = { accessKeyId: cachedCreds.accessKeyId, secretAccessKey: cachedCreds.secretAccessKey, sessionToken: cachedCreds.sessionToken, expiration: new Date(cachedCreds.expiration), }; return credentials; } methodLogger.debug('Cached credentials are expiring soon, refreshing', { expiration: expiration.toISOString(), }); } } else { methodLogger.debug('Force refreshing credentials', { accountId: params.accountId, roleName: params.roleName, }); } // Get SSO token const token = await (0, vendor_aws_sso_auth_core_service_js_1.getCachedSsoToken)(); if (!token) { throw (0, error_util_js_1.createAuthMissingError)('No SSO token found. Please login first.'); } // Log token details for debugging methodLogger.debug('Retrieved SSO token details', { hasAccessToken: !!token.accessToken, accessTokenLength: token.accessToken?.length || 0, accessTokenPrefix: token.accessToken?.substring(0, 10) + '...', expiresAt: token.expiresAt, expiresAtDate: new Date(token.expiresAt * 1000).toISOString(), region: token.region, tokenType: 'unknown', }); try { // Use AWS SDK to get credentials instead of direct API call // IMPORTANT: For SSO API calls, we must use the token's region first // The user-provided region is for the actual AWS CLI command, not the SSO API const ssoRegion = token.region || 'us-east-1'; methodLogger.debug('Setting up AWS SSO client', { ssoRegion: ssoRegion, userRegion: params.region, accountId: params.accountId, roleName: params.roleName, }); // Create SSO client with proper region const ssoClient = new client_sso_1.SSOClient({ region: ssoRegion, // Disable the built-in retry to use our custom implementation maxAttempts: 1, }); // Configure command with proper parameters const command = new client_sso_1.GetRoleCredentialsCommand({ accessToken: token.accessToken, accountId: params.accountId, roleName: params.roleName, }); methodLogger.debug('Configured GetRoleCredentialsCommand', { accountId: params.accountId, roleName: params.roleName, hasAccessToken: !!token.accessToken, }); // Execute command with retry logic to handle 429 errors methodLogger.debug('Requesting temporary credentials using AWS SDK with retry mechanism'); const response = await (0, retry_util_js_1.withRetry)(() => ssoClient.send(command), { // Use default retry options, can be adjusted if needed maxRetries: 5, initialDelayMs: 1000, maxDelayMs: 30000, backoffFactor: 2.0, }); methodLogger.debug('Received response from AWS SSO', { hasRoleCredentials: !!response.roleCredentials, responseKeys: Object.keys(response || {}), }); if (!response.roleCredentials) { methodLogger.error('No role credentials in response', { response: response, }); throw new Error('No credentials returned from AWS SSO'); } methodLogger.debug('Role credentials received', { hasAccessKeyId: !!response.roleCredentials.accessKeyId, hasSecretAccessKey: !!response.roleCredentials.secretAccessKey, hasSessionToken: !!response.roleCredentials.sessionToken, expiration: response.roleCredentials.expiration, }); // Create credentials object from response const credentials = { accessKeyId: response.roleCredentials.accessKeyId, secretAccessKey: response.roleCredentials.secretAccessKey, sessionToken: response.roleCredentials.sessionToken, expiration: new Date(response.roleCredentials.expiration), // Include the user's preferred region, or fall back to SSO region region: params.region || ssoRegion, }; // Cache the credentials await (0, aws_sso_cache_util_js_1.saveCachedCredentials)(params.accountId, params.roleName, credentials); return credentials; } catch (error) { methodLogger.error('Failed to get AWS credentials - detailed error', { errorName: error instanceof Error ? error.name : 'Unknown', errorMessage: error instanceof Error ? error.message : String(error), errorStack: error instanceof Error ? error.stack : undefined, isAwsError: error && typeof error === 'object' && '$metadata' in error, awsErrorCode: error && typeof error === 'object' && '$metadata' in error ? error.__type : undefined, awsHttpStatus: error && typeof error === 'object' && '$metadata' in error ? error.$metadata : undefined, accountId: params.accountId, roleName: params.roleName, }); throw (0, error_util_js_1.createApiError)(`Failed to get AWS credentials: ${error instanceof Error ? error.message : String(error)}`, undefined, error); } } /** * Get ALL AWS accounts with their available roles, handling pagination internally. * * Retrieves a combined view of all accounts and their roles that the user has access to. * This function loops through all pages of accounts and roles, utilizing caching for roles. * * @returns {Promise<AwsSsoAccountWithRoles[]>} Complete list of AWS accounts with their roles * @throws {Error} If SSO token is missing or API request fails */ async function getAllAccountsWithRoles() { const methodLogger = logger.forMethod('getAllAccountsWithRoles'); methodLogger.debug('Getting ALL AWS SSO accounts with roles (using cache)...'); const allAccountsWithRoles = []; let accountsNextToken; do { const accountsResponse = await listSsoAccounts({ nextToken: accountsNextToken, }); const accounts = accountsResponse.accountList; accountsNextToken = accountsResponse.nextToken; methodLogger.debug(`Fetched page of ${accounts.length} accounts. Next token: ${accountsNextToken ? 'Yes' : 'No'}`); for (const account of accounts) { // Ensure the account has required fields const validatedAccount = { accountId: account.accountId || '', accountName: account.accountName || '', // Map emailAddress to accountEmail for consistency accountEmail: account.emailAddress, }; try { // Validate the account structure AwsSsoAccountSchema.parse(validatedAccount); // --- Check Cache First (Uses AwsSsoAccountRole[]) --- const rolesFromCache = await (0, aws_sso_cache_util_js_1.getCachedAccountRoles)(validatedAccount.accountId); if (rolesFromCache && rolesFromCache.length > 0) { methodLogger.debug(`Using cached roles for account ${validatedAccount.accountId}`); // Map cached roles (AwsSsoAccountRole[]) to the expected AwsSsoRole[] const mappedCachedRoles = rolesFromCache.map((role) => ({ accountId: validatedAccount.accountId, roleName: role.roleName, roleArn: role.roleArn || `arn:aws:iam::${validatedAccount.accountId}:role/${role.roleName}`, })); // Create and validate the account with roles const accountWithRoles = { ...validatedAccount, roles: mappedCachedRoles, }; // Validate the structure vendor_aws_sso_types_js_1.AwsSsoAccountWithRolesSchema.parse(accountWithRoles); allAccountsWithRoles.push(accountWithRoles); continue; } else { methodLogger.debug(`No valid cached roles found for account ${validatedAccount.accountId}, fetching...`); } // --- End Cache Check --- let allRolesForAccountApi = []; // SDK RoleInfo type let rolesNextToken; do { const rolesResponse = await listAccountRoles({ accountId: validatedAccount.accountId, nextToken: rolesNextToken, }); allRolesForAccountApi = allRolesForAccountApi.concat(rolesResponse.roleList || []); rolesNextToken = rolesResponse.nextToken; methodLogger.debug(`Fetched page of roles for account ${validatedAccount.accountId}. Next token: ${rolesNextToken ? 'Yes' : 'No'}`); } while (rolesNextToken); // Map fetched roles (RoleInfo[]) to AwsSsoRole[] for the return type const formattedRoles = allRolesForAccountApi.map((role) => ({ accountId: validatedAccount.accountId, roleName: role.roleName || '', roleArn: role.roleArn || `arn:aws:iam::${validatedAccount.accountId}:role/${role.roleName || ''}`, })); // Create and validate the account with roles const accountWithRoles = { ...validatedAccount, roles: formattedRoles, }; // Validate the structure vendor_aws_sso_types_js_1.AwsSsoAccountWithRolesSchema.parse(accountWithRoles); allAccountsWithRoles.push(accountWithRoles); // --- Save Fetched Roles to Cache --- // Map formattedRoles (AwsSsoRole[]) back to AwsSsoAccountRole[] const rolesToCache = formattedRoles.map((role) => ({ accountId: role.accountId, roleName: role.roleName, roleArn: role.roleArn, })); await (0, aws_sso_cache_util_js_1.saveAccountRoles)(validatedAccount, rolesToCache); // --- End Save Cache --- } catch (error) { methodLogger.warn(`Error processing account ${validatedAccount.accountId}: ${error instanceof Error ? error.message : 'Unknown error'}`, error); // Include account even if role fetching fails, but with empty roles try { // Create an account with empty roles const accountWithEmptyRoles = { ...validatedAccount, roles: [], }; // Validate the structure vendor_aws_sso_types_js_1.AwsSsoAccountWithRolesSchema.parse(accountWithEmptyRoles); allAccountsWithRoles.push(accountWithEmptyRoles); } catch (validationError) { methodLogger.error(`Failed to add account with empty roles: ${validationError instanceof Error ? validationError.message : 'Unknown error'}`); // Skip this account if it doesn't even validate with empty roles } } } } while (accountsNextToken); methodLogger.debug(`Retrieved a total of ${allAccountsWithRoles.length} accounts with their roles.`); return allAccountsWithRoles; }