@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
JavaScript
"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;
}