Cisco Duo is an excellent MFA provider. We are rolling out Duo at work primarily due to Microsoft requiring a smartphone during enrollment (though temporary access passwords during passwordless setup may provide a workaround). Understandably, some people don’t have, or would rather not use, a personal smartphone for work. Duo allows end users to enroll using just a FIDO2 token. They do support other options like SMS, phone, TOTP, and of course their smartphone app, Duo Mobile. Interestingly, they do not provide an end user enrollment interface for TOTP.

We have been using Microsoft’s Azure AD MFA for a few years. Almost all the enrollment and MFA management tasks are delegated to the end user using a self-service web app I created. I plan on using the same web app with Duo.

Last week I took a deep dive into Duo’s Admin API. Unfortunately, Cisco does not provide examples for Nodejs. So here is what I did.

The first thing to do when interfacing with an API is figure out how to authenticate. Sometimes authentication is a simple as adding a static API key to your request header. Duo takes a unique (and sometimes strange) approach.

Duo requires you to generate an HMAC signature of the request using a shared secret. The date, method, host, path, and parameters (even if you don’t have any) are concatenated with a newline, \n. The resultant string is the basic authentication password for the request. The integration key is the username.

const { createHmac } = require('crypto')

const duo_integration_key = process.env.DUO_INTEGRATION_KEY
const duo_hostname = process.env.DUO_HOSTNAME
const duo_secret_key = process.env.DUO_SECRET_KEY

const get_request_password = request => {
  let params = ''
  if (request.params)
    params = new URLSearchParams(request.params).toString()

  return createHmac('sha1', duo_secret_key)
    .update([
      request.date,
      request.method,
      request.host,
      request.path,
      params,
    ].join('\n'))
    .digest('hex')
}

// example request
const password = get_request_password({
  date: new Date().toUTCString(),
  method: 'GET',
  host: duo_hostname,
  path: '/admin/v1/users'
})

console.log(password) // 1d2770f20881531dcd28fbffe54ce56713f18a35

This approach has the benefit of mitigating any damage an attacker could cause if they got a copy of the request. The “password” is only good for that request at that time. Replay attacks are nearly impossible.

Now that we can generate passwords to authenticate requests, we can make a function to send any requests to Duo.

const duo_api = request => {
  return new Promise((resolve, reject) => {
    request.date = new Date().toUTCString()
    request.host = duo_hostname

    const password = get_request_password(request)

    const options = {
      method: request.method,
      hostname: duo_hostname,
      path: request.path,
      headers: {
        Authorization: 'Basic ' + Buffer.from(duo_integration_key + ':' + password).toString('base64'),
        Date: request.date,
      }
    }

    // POST request params are sent in the body
    let body = ''
    if (request.params && request.method == 'POST') {
      body = new URLSearchParams(request.params).toString()
      options.headers['Content-Type'] = 'application/x-www-form-urlencoded'
      options.headers['Content-Length'] = Buffer.byteLength(body, 'utf8')
    }

    // all other methods use query string
    if (request.params && request.method != 'POST')
      options.path += '?' + new URLSearchParams(request.params).toString()

    const req = https.request(options, res => {
      let data = ''
      res.on('data', chunk => {
        data += chunk
      })

      res.on('end', () => {
        if (!data) return resolve({
          headers: res.headers,
          status_code: res.statusCode,
        })

        try {
          data = JSON.parse(data)
        } catch (err) { console.error(err) }

        resolve({
          headers: res.headers,
          status_code: res.statusCode,
          data: data
        })
      })
    })

    req.on('error', err => {
      reject(err)
    })

    // send the POST data
    if (body)
      req.write(body)

    req.end()
  })
}

Here is an example GET request.

duo_api({
  path: '/admin/v1/users',
  method: 'GET',
  params: {
    username: 'bob@example.com'
  }
})
  .then(result => {
    console.log(result)
  })
  .catch(err => {
    console.error(err)
  })

And the resultant request header.

GET /admin/v1/users?username=bob%40example.com HTTP/1.1
Authorization: Basic RFVPX0lOVEVHUkFUSU9OX0tFWTo3YWNjMzVmODljNTYxZTdhMmQ0YWM2YWM1MWE4ZmUxZGQ2M2Y4MTkw
Date: Sun, 03 Jul 2022 17:25:28 GMT
Host: duo.example.com
Connection: close

An example POST request.

duo_api({
  path: '/admin/v1/users/USERID12345/bypass_codes',
  method: 'POST',
  params: {
    count: 1,
    reuse_count: 4,
    valid_secs: 60 * 60 * 8,
  }
})
  .then(result => {
    console.log(result)
  })
  .catch(err => {
    console.error(err)
  })

And the resultant request header.

POST /admin/v1/users/USERID12345/bypass_codes HTTP/1.1
Authorization: Basic RFVPX0lOVEVHUkFUSU9OX0tFWTo5NjEwOTgzMTJkZDkxMGYwOGJlMzE1MGQzOTM4ZDUyOGU3Y2IzNGY3
Date: Sun, 03 Jul 2022 17:25:28 GMT
Content-Type: application/x-www-form-urlencoded
Content-Length: 38
Host: duo.example.com
Connection: close

count=1&reuse_count=4&valid_secs=28800

Notice the parameters in the POST request are sent in the body of the message rather than in the URL. Also both these requests have different basic authentication headers because the password for each request is different.

Here is the complete code, feel free to use however you like.

const { createHmac } = require('crypto')
const https = require('https')

const duo_integration_key = process.env.DUO_INTEGRATION_KEY
const duo_hostname = process.env.DUO_HOSTNAME
const duo_secret_key = process.env.DUO_SECRET_KEY

const get_request_password = request => {
  let params = ''
  if (request.params)
    params = new URLSearchParams(request.params).toString()

  return createHmac('sha1', duo_secret_key)
    .update([
      request.date,
      request.method,
      request.host,
      request.path,
      params,
    ].join('\n'))
    .digest('hex')
}

const duo_api = request => {
  return new Promise((resolve, reject) => {
    request.date = new Date().toUTCString()
    request.host = duo_hostname

    const password = get_request_password(request)

    const options = {
      method: request.method,
      hostname: duo_hostname,
      path: request.path,
      headers: {
        Authorization: 'Basic ' + Buffer.from(duo_integration_key + ':' + password).toString('base64'),
        Date: request.date,
      }
    }

    // POST request params are sent in the body
    let body = ''
    if (request.params && request.method == 'POST') {
      body = new URLSearchParams(request.params).toString()
      options.headers['Content-Type'] = 'application/x-www-form-urlencoded'
      options.headers['Content-Length'] = Buffer.byteLength(body, 'utf8')
    }

    // all other methods use query string
    if (request.params && request.method != 'POST')
      options.path += '?' + new URLSearchParams(request.params).toString()

    const req = https.request(options, res => {
      let data = ''
      res.on('data', chunk => {
        data += chunk
      })

      res.on('end', () => {
        if (!data) return resolve({
          headers: res.headers,
          status_code: res.statusCode,
        })

        try {
          data = JSON.parse(data)
        } catch (err) { console.error(err) }

        resolve({
          headers: res.headers,
          status_code: res.statusCode,
          data: data
        })
      })
    })

    req.on('error', err => {
      reject(err)
    })

    // send the POST data
    if (body)
      req.write(body)

    req.end()
  })
}

module.exports = duo_api

Have fun!