Automate cluster pause/resume | Event Bridge | Lambda | Javascript | Axios

I attempted to automate my cluster using MongoDB Atlas Administration API 2.0. When using Postman, everything worked fine with the Digest Auth mechanism. The route I used is:

https://cloud.mongodb.com/api/atlas/v2/groups/{groupId}/clusters/{clusterName}

However, when trying the same in my backend service, I initially received a 401 Unauthorized error. Subsequently, I extracted the www-authenticate from error.response.headers. Utilizing this information, I generated the Authorization header. Despite these efforts, I ultimately encountered the following error:

Request failed with status code 500

Here’s my JavaScript code:

import axios from 'axios';
import crypto from 'crypto';

let nonce;

const projectID = "my_project_ID";
const clusterName = "my_cluster";
const username = "API_public_key";
const password = "API_private_key";
const realm = "MMS Public API";
const uri = `/api/atlas/v1.0/groups/${projectID}/clusters/${clusterName}`;
const algorithm = "MD5";

const hash = () => {
  const ha1 = crypto.createHash('md5').update(`${username}:${realm}:${password}`).digest('hex');
  const ha2 = crypto.createHash('md5').update(`GET:${uri}`).digest('hex');
  const response = crypto.createHash('md5').update(`${ha1}:${nonce}:${ha2}`).digest('hex');

  return `Digest username="${username}", realm="${realm}", nonce="${nonce}", uri="${uri}", algorithm="${algorithm}", response="${response}"`;
}


const url = `https://cloud.mongodb.com/api/atlas/v1.0/groups/${projectID}/clusters/${clusterName}`;

const config = {
  method: 'PATCH',
  url,
  headers: {
    'Content-Type': 'application/json',
    'Accept-Encoding': 'bzip, deflate',
    'Accept' : 'application/vnd.atlas.2023-02-01+json'
  },
};

const trigger = async () => {
  try {
    const authorizationHeader = await hash()
    config.headers.Authorization = authorizationHeader
    const response = await axios(config);
    console.log(response.data);
  } catch (error) {
    if (error.response.status === 401) {
      nonce = error.response.headers['www-authenticate'].split(',')[2].split('=')[1].split('"')[1]
      const authorizationHeader = await hash()
      config.headers.Authorization = authorizationHeader
      await trigger()
    } else {
      console.log(error.message);
    }
  }
}

// Call the function
trigger();

I plan to encapsulate this as a Lambda function. Subsequently, I will set up an AWS EventBridge and designate this Lambda function as a target.

I resolved this issue with the below-mentioned code:

import axios from 'axios';
import crypto from 'crypto';

// Define your Digest Authentication credentials
const authOptions = {
    username: process.env.ATLAS_PUBLIC_API_KEY,
    password: process.env.ATLAS_PRIVATE_API_KEY,
    groupId: process.env.ATLAS_CLUSTER_GROUP_ID,
    clusterName: process.env.ATLAS_CLUSTER_NAME,
    mongoBaseUrl: process.env.MONGO_BASE_URL
};

// Define the path for the MongoDB Atlas API endpoint
let path = `/api/atlas/v1.0/groups/${authOptions.groupId}/clusters/${authOptions.clusterName}`

// Configuration for the PATCH request to MongoDB Atlas API
let config = {
    method: 'PATCH',
    maxBodyLength: Infinity,
    url: `${authOptions.mongoBaseUrl}${path}`,
    headers: {
        'Content-Type': 'application/json',
        'Accept-Encoding': 'bzip, deflate'
    },
};

/**
 * Function to calculate the digest response for HTTP Digest Authentication.
 * @param {string} method - The HTTP method of the request.
 * @param {string} uri - The requested URI (Uniform Resource Identifier).
 * @param {string} realm - The authentication realm, typically provided by the server.
 * @param {string} nonce - A unique value generated by the server to prevent replay attacks.
 * @param {string} nc - The nonce count, a client-specified value that increments with each request.
 * @param {string} cnonce - A client-generated random value for additional security.
 * @param {string} qop - The quality of protection, indicating the algorithm used for the digest.
 * @returns {string} - The calculated Digest Authentication response string.
 */
function calculateDigestResponse({ method, uri, realm, nonce, nc, cnonce, qop }) {
    const ha1 = crypto.createHash('md5').update(`${authOptions.username}:${realm}:${authOptions.password}`).digest('hex');
    const ha2 = crypto.createHash('md5').update(`${method}:${uri}`).digest('hex');
    const response = crypto.createHash('md5').update(`${ha1}:${nonce}:${nc}:${cnonce}:${qop}:${ha2}`).digest('hex');

    return `Digest username="${authOptions.username}", realm="${realm}", nonce="${nonce}", nc="${nc}", cnonce="${cnonce}", uri="${uri}", response="${response}", qop=${qop}`;
}

/**
 * Function to parse the components of a Digest Authentication value received from a server.
 * @param {string} challenge - The Digest Authentication string from the server.
 * @returns {Object} - An object containing parsed key-value pairs extracted from the string.
 */
function parseDigestValue(challenge) {
    // Use a regular expression to extract key-value pairs from the challenge string
    const matches = challenge.match(/(\w+)="([^"]+)"/g);

    // Initialize an empty object to store parsed values
    const result = {};
    // Iterate through the matches and populate the result object
    matches.forEach((match) => {
        const [key, value] = match.split('=');
        result[key.replace(/"/g, '')] = value.replace(/"/g, '');
    });

    // Return the parsed key-value pairs as an object
    return result;
}

/**
 * Asynchronous function to retrieve the status of a MongoDB Atlas cluster using the Digest Authentication mechanism.
 * @param {object} data - The data object containing information to be sent in the PATCH request.
 * @returns {string} - The status of the MongoDB Atlas cluster.
*/
async function updateCluster(data) {
    try {
        config.data = data;
        // Attempt the initial GET request
        await axios(config.url);
    } catch (error) {
        if (error.response.status === 401) {
            // Capture the Digest Authentication challenge in case of an error
            let digestValue = error.response.headers['www-authenticate'];
            try {
                // Parse the Digest challenge to extract realm, nonce, and qop
                const { realm, nonce, qop } = parseDigestValue(digestValue);

                // Add Authorization header with the calculated digest response to the request configuration
                config.headers.Authorization = calculateDigestResponse({
                    method: config.method,
                    uri: path,
                    realm,
                    nonce,
                    nc: `00000001`,
                    cnonce: crypto.randomBytes(8).toString('hex'),
                    qop
                });

                // Make the actual request with the updated config, retrieve the cluster status
                const finalResponse = await axios.request(config);

                let result = finalResponse.data.paused

                return result;
            }
            catch (error) {
                error = error?.response?.data?.detail ? error?.response?.data?.detail : error.message
                throw new Error(error);
            }
        } else {
            throw new Error(error);
        }
    }
}

/**
 * Asynchronous function to pause or resume a MongoDB Atlas cluster based on its current status.
 * @throws Will throw an error if there's an issue with retrieving the cluster status or making the request.
 */
export async function pauseOrResumeCluster() {
    let isPaused
    let data = JSON.stringify({});
    try {
        // Retrieve the current status of the MongoDB Atlas cluster
        config.method = 'GET'
        isPaused = await updateCluster(data);
        console.log(`[Cluster current status] [isPaused : ${isPaused}]`);
        config.method = 'PATCH'

    } catch (error) {
        console.log("Error in pauseOrResumeCluster : ", error);
        console.log('Error in pauseOrResumeCluster stringified : ', JSON.stringify(error));
    }
    try {
        // Check the current status and take appropriate action
        if (isPaused === true) {
            // If the cluster is paused, send a request to resume it
            data = JSON.stringify({
                "paused": false
            })
            let response = await updateCluster(data);
            console.log('[Cluster is resumed succesfully!]',`[isPaused : ${response}]`);

        } else if (isPaused === false) {
            // If the cluster is running, send a request to pause it
            data = JSON.stringify({
                "paused": true
            })
            let response = await updateCluster(data);
            console.log('[Cluster is paused succesfully!]',`[isPaused : ${response}]`);
        }
    } catch (error) {
        console.log("Error in pauseOrResumeCluster : ", error);
        console.error('Error in pauseOrResumeCluster stringified : ', JSON.stringify(error));
    }
}