intuit / oauth-jsclient

Intuit's NodeJS OAuth client provides a set of methods to make it easier to work with OAuth2.0 and Open ID
https://developer.intuit.com/
Apache License 2.0
120 stars 154 forks source link

How to use in clustered node server (PM2)? #123

Open ysageev opened 2 years ago

ysageev commented 2 years ago

I'm struggling trying to implement OAuth2 in a pm2 clustered environment. The example provided stores the new OAuthClient in a global variable. This is great if everyone is connecting to that one server instance. In production that is almost never the case.

I am going to assume that if process 1 and process 2 create wholly new OAuthClients, then access tokens returned to 2 will invalidate tokens in 1. Is that correct?

If so, I need a way to reconstitute an OAuthClient from data stored in a database. I imagine the flow will look like this:

  1. On connect request, check global variable is populated.
  2. If not, grab the access and refresh tokens and expiry from the database
  3. Recreate OAuthClient and manually and enter data from (2). HOW?
  4. If access token has expired, call RefreshToken() etc. ...
lsacco-nutreense commented 2 years ago

I have the same issue. Have you figured out a solution?

We follow this process:

  1. Authenticate a QBO user
  2. Set-up a callback to our app to save the resulting access/refresh token
  3. On subsequent calls, check to see if the access token is valid; if not, refresh the token
  4. Save the new access/refresh token in our DB; repeat step 3 as needed.

This works great in a single-node environment, but when this runs in a clustered environment, we see this error:

{"authResponse":{"body":"{\"error\":\"invalid_grant\"}","intuit_tid":"1-623b94f9-1d22e7c022d9091a3c2281c8","json":{"error":"invalid_grant"}

This is the relevant code:

  private async validateToken() {
    // Need to double check if oauthClient is available when in multi-pod HA environment
    if (!this.oauthClient) {
      this.oauthClient = new OAuthClient({
        clientId: environment.INTUIT_CLIENT_ID,
        clientSecret: environment.INTUIT_CLIENT_SECRET,
        environment: environment.INTUIT_ENV,
        redirectUri: environment.INTUIT_REDIRECT_URI,
        logging: true
      });
    }
    const isTokenValid = await this.oauthClient.isAccessTokenValid();
    if (isTokenValid) {
      this.logger.log('Token is valid');
    } else {
      const testToken = await this.oauthClient.token.getToken();
      if (!testToken.refresh_token) {
        const dbdoc = await this.findToken(TOKEN_NAME);
        this.token = dbdoc.token;
        this.oauthClient.setToken(this.token);
        const isValid = await this.oauthClient.isAccessTokenValid();
        if (!isValid) {
          await this.refreshToken();
        }
      } else {
        await this.refreshToken();
      }
    }
  }

  private async refreshToken() {
    await this.oauthClient
      .refresh()
      .then((res) => {
        this.intuitTokenDto.token = res.token;
        this.createOrUpdateToken({ name: TOKEN_NAME }, this.intuitTokenDto);
      })
      .catch((err) => {
        const msg = 'Could not refresh access token. User must reauthenticate to Intuit for new token.';
        err.error = msg;
        throw err;
      });
  }
ysageev commented 2 years ago

Right now I just use a single server instance. :-/

I don't anticipate it being that bad for now because the proportion of users actually hitting QBO is very small -- just those with accounting privileges.

Eventually I will revisit the clustered environment, and imagine that it will work by deeply serializing the oauthClient into a database or local json file, check if it is there, and reconstitute the oauthClient from that data if it is. I don't have the cycles for that at this time.

It would be nice if the API had toJSON() and fromJSON() methods that could attempt to instantiate the client in this way, or something similar. Currently, it really looks like the API is not designed to be used in a clustered environment.

robreiss commented 8 months ago

I treat the refresh_token as a single-use token. When a new access_token is obtained, I store the new access_token and refresh_token in a central database. In a cloud server environment where multiple instances of my program might attempt to access QuickBooks simultaneously, a race condition can occur. To address this, I serialize access to the stored refresh_token using Postgres.

I store the tokens in a table row designated for each company_id and include a column that serves as a lock when refreshing the tokens. This ensures that only one process can refresh the tokens at any given time. While I have not extensively tested this solution in a production setting, it appeared to work effectively during the limited testing conducted before the project shifted away from QuickBooks.

antiquicksort commented 6 months ago

@abisalehalliprasan Do you know a workaround for this?