0
votes

With the google oauth2 library, I can successfully authenticate a user on their first pass through, get their refresh token and first access token. Until the token expires, everything works as expected.

However, when the access token expires, I need to get a new access token and store these tokens in my data store using the existing refresh token. I am aware the documentation states tokens should re-fetch themselves when they expire, but as I am creating a new client for each call (to ensure tokens are not re-used between users), I think the client gets torn down before a token gets chance to refresh itself.

Inspecting what the library does calling the actual google api, I should be able to get new access tokens by calling the client.refreshAccessToken() method, the response from this call gives me the invalid_grant Bad Request error. I have compared the actual api request this method makes to the one on google oauth2 playground and the two calls are identical - although their call for refreshing their token works and mine does not.

Attached is my code as it now currently stands Please send help - I don't have any hair left to pull out!

const { google } = require('googleapis')
const scopes = [
  'https://www.googleapis.com/auth/spreadsheets.readonly',
  'https://www.googleapis.com/auth/userinfo.email',
  'https://www.googleapis.com/auth/drive.readonly'
]

module.exports = (env, mongo) => {
  const getBaseClient = () => {
    const { OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_CALLBACK_URL } = env.credentials
    return new google.auth.OAuth2(
      OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_CALLBACK_URL
    )
  }

  const getNewAccessTokens = async (authId, refreshToken) => {
    const { tokens } = await getBaseClient().getToken(refreshToken)
    await mongo.setAccessTokensForAuthUser(authId, { ...tokens, refresh_token: refreshToken })
    return tokens
  }

  const getAuthedClient = async (authId) => {
    let tokens = await mongo.getAccessTokensForAuthUser(authId)

    if (!tokens.access_token) {
      tokens = await getNewAccessTokens(authId, tokens.refresh_token)
    }

    const client = getBaseClient()
    client.setCredentials(tokens)

    if (client.isTokenExpiring()) {
      const { credentials } = await client.refreshAccessToken()
      tokens = { ...credentials, refresh_token: tokens.refreshToken }
      await mongo.setAccessTokensForAuthUser(authId, tokens)
      client.setCredentials(tokens)
    }

    return client
  }

  const generateAuthUrl = (userId) => {
    return getBaseClient().generateAuthUrl({
      access_type: 'offline',
      scope: scopes,
      state: `userId=${userId}`
    })
  }

  const getUserInfo = async (authId) => {
    const auth = await getAuthedClient(authId)
    return google.oauth2({ version: 'v2', auth }).userinfo.get({})
  }

  const listSheets = async (authId) => {
    const auth = await getAuthedClient(authId)
    let nextPageToken = null
    let results = []
    do {
      const { data } = await google
        .drive({ version: 'v3', auth })
        .files.list({
          q: 'mimeType = \'application/vnd.google-apps.spreadsheet\'',
          includeItemsFromAllDrives: true,
          supportsAllDrives: true,
          corpora: 'user',
          orderBy: 'name',
          pageToken: nextPageToken
        })
      nextPageToken = data.nextPageToken
      results = results.concat(data.files)
    } while (nextPageToken)
    return results
  }

  return {
    generateAuthUrl,
    getUserInfo,
    listSheets
  }
}

1

1 Answers

0
votes

I solved my own problem.

I was conflating access_codes with refresh_tokens, and believed the code you receive from the auth url was the refresh_token, storing it, and attempting to reuse it to get more access_tokens. This is wrong. Don't do this.

You get the access_code from the authentication url, and the first time you use that with the client.getToken(code) method, you receive the refresh_token and access_token.

I've attached updated and working code should anyone wish to use it.

I should also mention that I added prompt: 'consent' to the auth url so that you always receive an access_code you can use to get a refresh_token when someone re-authenticates (as if you don't, then a call to client.getToken() does not return a refresh_token (part of what was confusing me in the first place).

const { google } = require('googleapis')
const scopes = [
  'https://www.googleapis.com/auth/spreadsheets.readonly',
  'https://www.googleapis.com/auth/userinfo.email',
  'https://www.googleapis.com/auth/drive.readonly'
]

module.exports = (env, mongo) => {
  const getBaseClient = () => {
    const { OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_CALLBACK_URL } = env.credentials
    return new google.auth.OAuth2(
      OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_CALLBACK_URL
    )
  }

  const getAuthedClient = async (authId) => {
    let tokens = await mongo.getAccessTokensForAuthUser(authId)

    const client = getBaseClient()
    client.setCredentials(tokens)

    if (client.isTokenExpiring()) {
      const { credentials } = await client.refreshAccessToken()
      tokens = { ...credentials, refresh_token: tokens.refresh_token }
      await mongo.setAccessTokensForAuthUser(authId, tokens)
      client.setCredentials(tokens)
    }

    return client
  }

  const generateAuthUrl = (userId) => {
    return getBaseClient().generateAuthUrl({
      access_type: 'offline',
      prompt: 'consent',
      scope: scopes,
      state: `userId=${userId}`
    })
  }

  const getUserInfo = async (accessCode) => {
    const auth = getBaseClient()
    const { tokens } = await auth.getToken(accessCode)
    auth.setCredentials(tokens)
    const { data } = await google.oauth2({ version: 'v2', auth }).userinfo.get({})
    return { ...data, tokens }
  }

  const listSheets = async (authId) => {
    const auth = await getAuthedClient(authId)
    let nextPageToken = null
    let results = []
    do {
      const { data } = await google
        .drive({ version: 'v3', auth })
        .files.list({
          q: 'mimeType = \'application/vnd.google-apps.spreadsheet\'',
          includeItemsFromAllDrives: true,
          supportsAllDrives: true,
          corpora: 'user',
          orderBy: 'name',
          pageToken: nextPageToken
        })
      nextPageToken = data.nextPageToken
      results = results.concat(data.files)
    } while (nextPageToken)
    return results
  }

  return {
    generateAuthUrl,
    getUserInfo,
    listSheets
  }
}