1 Feb 2021

More about Refresh Token

We already blogged About Refresh Token. We even showed how you could handle them in Landing your Forge OAuth authentication workflow.

There is only a tiny issue with the code in the second article. If two functions are trying to get an access token with it at the same time (within less than a second - not sure how likely that is, but still), then both code paths will end up sending a request to the refresh token endpoint. If the difference in time is less than ~50ms then they might both succeed, but around 100ms (in my tests) the second call will fail.

So there might be an error and also it's just a waste to send two requests to the refresh token endpoint so close in time when an access token will usually last about an hour.   

Here is a simplified version of the code to show what's going on:

let clientId = "<your app's client id>"
let clientSecret = "<your app's client secret>"

var accessTokens = {
  "1234": {
    accessToken: "1212313",
    refreshToken: "xIG76ZBMrIuyzWVNwD2nTxO9hkc1kMLAr748iYIRL6"
  }
}

function isCloseToExpiry (token) {
  // let's just fake it for the moment
  return true
}

function getAccessToken(userId, funcId) {
  return new Promise(async(resolve, reject) => {
    console.log(`Created Promise for ${funcId}`)
    if (accessTokens[userId]) {
      if (isCloseToExpiry(accessTokens[userId])) {
        let body = `client_id=${clientId}&` +
        `client_secret=${clientSecret}&` +
        `grant_type=refresh_token&` +
        `refresh_token=${accessTokens[userId].refreshToken}`
        console.log(`Calling fetch() for ${funcId} with body = ${body}`)
        fetch("	https://developer.api.autodesk.com/authentication/v1/refreshtoken", {
          method: "post",
          headers: {
            'Content-Type': 'application/x-www-form-urlencoded'
          },
          body: body
        })
        .then(res => res.json())
        .then(json => {
          console.log(json)
          accessTokens[userId].accessToken = json.access_token
          accessTokens[userId].refreshToken = json.refresh_token
          resolve(accessTokens[userId].accessToken)
        })
      }
    } else {
      reject("User needs to log in")
    }
  })
}

async function func1() {
  console.log(`func1 calls for token`)

  let accessToken = await getAccessToken("1234", "func1")
  
  console.log(`func1 got token`)
}

async function func2() {
  console.log(`func2 calls for token`)

  let accessToken = await getAccessToken("1234", "func2")

  console.log(`func2 got token`)
}

async function main() {
  console.log("Before calling functions")
  func1()
  // you can play with how long we wait between the two functions 
  await new Promise((resolve) => setTimeout(resolve, 100));
  func2()
  console.log("After calling functions")
}

main()

This is what happens depending on how quickly the two functions call getAccessToken() one after the other.

5 millisecond delay between the two functions

If both functions call getAccessToken() almost at the same time then they will ask for a new access token/refresh token using the same refresh token (in red) and both calls succeed. But the refresh token (in green) that the first function got back gets immediately invalidated by the refresh token (in blue) the second function received.

100 millisecond delay between the two functions

In this case as well both function paths will end up calling the refresh token endpoint with the same refresh token (in red), but by the time the second request reaches our servers that refresh token will be invalid (see error in blue), and only the refresh token the first function received is valid (in green)  

Error message (HTTP 400):

{ 
  "developerMessage":"The authorization code/refresh token is expired or invalid/redirect_uri must have the same value as in the authorization request.",
  "errorCode":"AUTH-004",
  "more info":"https://forge.autodesk.com/en/docs/oauth/v2/developers_guide/error_handling/"
}

500 millisecond delay between the two functions

If there is more delay between the two functions then based on the expiry time the second function would not even call the refresh token endpoint, but would simply use the latest access token. In our test code, we are not checking the expiration time, so that we can focus on testing the effects of sending multiple requests to the refresh token endpoint with different delays. In our case, by the time the second function is calling that endpoint the latest refresh token (in green) is already available, so we just end up getting a newer refresh token (in blue).

The simplest solution to all the above is not to create a new Promise if one is already available and is working on getting a new access token/refresh token. In that case, just pass that back to the caller - see the usage of accessTokenPromises

let clientId = "<your app's client id>"
let clientSecret = "<your app's client secret>"

var accessTokens = {
  "1234": {
    expiresAt: 1234567,
    accessToken: "1212313",
    refreshToken: "ehFoA6aZhwy6gfpsgV3yHm4bhscdaLON2zPovGRVdc"
  }
}

var accessTokenPromises = {}

function isExpired (token) {
  return Date.now() > token.expiresAt
}

// funcId is used for debugging purposes
function getAccessToken(userId, funcId) {
  if (accessTokenPromises[userId]) {
    console.log(`Return existing Promise for ${funcId}`)

    return accessTokenPromises[userId]
  }

  accessTokenPromises[userId] = new Promise(async(resolve, reject) => {
    console.log(`Created Promise for ${funcId}`)
    if (accessTokens[userId]) {
      if (isExpired(accessTokens[userId])) {
        console.log(`Access token expired / ${funcId}`)
        let body = `client_id=${clientId}&` +
        `client_secret=${clientSecret}&` +
        `grant_type=refresh_token&` +
        `refresh_token=${accessTokens[userId].refreshToken}`
        console.log(`Calling fetch() for ${funcId} with body = ${body}`)
        fetch("	https://developer.api.autodesk.com/authentication/v1/refreshtoken", {
          method: "post",
          headers: {
            'Content-Type': 'application/x-www-form-urlencoded'
          },
          body: body
        })
        .then(res => res.json())
        .then(json => {
          console.log(json)
          accessTokens[userId].accessToken = json.access_token
          accessTokens[userId].refreshToken = json.refresh_token
          // consider it expired a bit earlier (e.g. 60 seconds = 60,000 milliseconds)
          accessTokens[userId].expiresAt = Date.now() + (json.expires_in * 1000) - 60000
          delete accessTokenPromises[userId]
          resolve(accessTokens[userId].accessToken)
        })
      } else {
        console.log(`Use existing access token / ${funcId}`)
        resolve(accessTokens[userId].accessToken)
      }
    } else {
      reject("User needs to log in")
    }
  })

  return accessTokenPromises[userId]
}

async function func1() {
  console.log(`func1 calls for token`)

  let accessToken = await getAccessToken("1234", "func1")
  
  console.log(`func1 got token = ${accessToken}`)
}

async function func2() {
  console.log(`func2 calls for token`)

  let accessToken = await getAccessToken("1234", "func2")

  console.log(`func2 got token = ${accessToken}`)
}

async function main() {
  console.log("Before calling functions")
  func1()
  await new Promise((resolve) => setTimeout(resolve, 100));
  func2()
  await new Promise((resolve) => setTimeout(resolve, 1000));
  func1()
  console.log("After calling functions")
}

main()

Now it does not matter if both functions end up calling getAccessToken() at the same time or 100ms500ms, etc apart.

In order to test the code (in Node.js or in the browser), you just need a valid refresh token, generated in another app, in Postman or somewhere else.

Related Article