ChuckJonas / ts-force

A Salesforce REST Client written in Typescript for Typescript
87 stars 20 forks source link

access token expired scenarios using rest client #90

Open avivek opened 4 years ago

avivek commented 4 years ago

Does TS force have the ability to refresh an expired access token by itself. Current behaviour of setting the RestClient at the time of object creation, would create problem when the access token expires during the usage of this object for further operations.

Is there a suggested method of handling this?

ChuckJonas commented 4 years ago

Unfortunately it's not yet been implemented..

My use case has always been on-org (using the session_id). Refresh token functionality would not be too hard to add though.

To make this work, we would need to make the following updates:

We've discussed completely moving away from the ORM style object which would also address this, but have no timeline in which we would get around to that.

avivek commented 4 years ago

Thanks a lot for the update.

ChuckJonas commented 4 years ago

I started working on a solution. It should actually be pretty easy using the axios Interceptors .

It could be hacked together by just checking for a refresh token on the default auth, but a better solution would be to build support for more comprehensive oAuth strategies.

avivek commented 4 years ago

Thats good to know. Would it be possible to not tie the solution to only using refresh tokens. Rather have the ability to also take in a handler function, which could be called if any time the token expires. This could be actually more generic then tying into refresh token based implementation. The Handler function can return a promise, that should resolve to the current config object, or if it fails that might indicate the failure to get token. Per my understanding, when we use the Username, Password flow in salesforce, refresh token is not given.

ChuckJonas commented 4 years ago

that might be a more flexible solution... I was planning on implementing the Username & Password "refresh" and refresh flows as build in "Auth" handlers. It might be easy to provide built in support for the common authentication methods but also allow overridding

wuservices commented 3 years ago

@ChuckJonas I noticed you mentioned:

My use case has always been on-org (using the session_id)

and the docs on auth seem to only reference a username-password flow in the context of OAuth.

Does that mean that right now, ts-force isn't designed to handle getting tokens for a user-initiated OAuth flow, like the browser-based OAuth flow in JSforce?

ChuckJonas commented 3 years ago

@wuservices that's correct. oAuth2 flows are fairly trivial to setup, so it could be added easily... An equivalent example would look like this:

const getOAuthUrl = (scopes: string) => {
  const clientId = process.env.CLIENT_ID;
  const redirectUrl = `${process.env.SITE_URL}/oauth2/token`;
  return `${process.env.INSTANCE_URL}/services/oauth2/authorize?client_id=${encodeURI(clientId)}&redirect_uri=${encodeURI(
    redirectUrl,
  )}&response_type=code&prompt=login&scope=${encodeURI(scopes)}`;
};

// you could just redirect them directly from the browser... Don't really need an endpoint for this.
app.get('/oauth2/auth', function(req, res) {
  res.redirect(getOAuthUrl( 'api id web' ));
});

// typically you would store the auth token in a user-session (jwt)
app.get('/oauth2/token', function(req, res) {
 const params = {
        grant_type: 'authorization_code',
        code: code as string,
        client_id: process.env.CLIENT_ID,
        client_secret: process.env.CLIENT_SECRET,
        redirect_uri: `${process.env.SITE_URL}/api/token`,
      };
 const searchParams = toFormData(params);

 const response = await fetch(`${instanceUrl}/services/oauth2/token`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
        },
        body: searchParams,
  });

 if (response.ok) {
         const respJson = await response.json();
         const { access_token: accessToken, id, instance_url: instanceUrl } = respJson;
         const tsForceClient = new Rest({
             accessToken, instanceUrl
         });
         // do stuff
         const describe = tsForceClient.getDescribe('account');
  }
});

Typically this type of thing would be handled by the authentication middle wear (passport, next-auth,etc) and you'd want to store the token in a session cookie or JWT...

I have been working on getting refresh tokens to automatically regrant auth tokens (it's done, but I need to do more QA)... I guess I could add some small utilities to make it easier to work with oAuth flows (or maybe just a separate library).

ChuckJonas commented 3 years ago

@avivek you may have moved on and don't care anymore, but I wanted to provide an update to this:

Would it be possible to not tie the solution to only using refresh tokens. Rather have the ability to also take in a handler function, which could be called if any time the token expires. This could be actually more generic then tying into refresh token based implementation. The Handler function can return a promise, that should resolve to the current config object, or if it fails that might indicate the failure to get token. Per my understanding, when we use the Username, Password flow in salesforce, refresh token is not given.

You can actually already do this, via the axios instance, which is exposed on any client:

import {Rest} from 'ts-force';
const client = new Rest();

//"request" is an AxiosInstance
rest.request.defaults.adapter = myCustomAxiosAdapter;

Axios already has a ton of powerful stuff around adapters (unfortunately not well documented) and interceptors, but I do want to create an interceptor to automatically generate new auth_tokens on expiration.

Here's an example of how you can add a custom retry hook:


import * as rax from 'retry-axios';

const client = new Rest();

client.request.defaults['raxConfig'] = retryHandler;
client.request.defaults['raxConfig']['instance'] = client.request;
rax.attach(client.request);

const retryHandler = {
  retry: 7,
  retryDelay: 200,
  shouldRetry: (err: AxiosError) => {
    const cfg = rax.getConfig(err) as any;
    if (cfg.currentRetryAttempt < cfg.retry) {
      return !err.response;
    }
    return false;
  },
  onRetryAttempt: (err) => {
    const cfg = rax.getConfig(err) as any;
    console.log(`Retry attempt #${cfg.currentRetryAttempt} : ${new Date().getTime()}`);
  },
};
wuservices commented 3 years ago

@ChuckJonas wow thank you for taking the time to cook up an example. I think that sounds pretty viable. Right now, I'm just trying to get a Zendesk app bootstrapped with Vue and TypeScript, but once I start pulling in data, I can't wait to try this out.

Also, I just happened to see your BASS today and loved it. Sent you a tweet as to not derail this issue too much.

ChuckJonas commented 3 years ago

@wuservices I went ahead and added this code via a couple utility functions. You need to use 3.0.0-rc.22 (I'm about to release, so if you are starting a new project, you want v3 anyways).

See the updated docs.

wuservices commented 3 years ago

@ChuckJonas glad you pointed out v3. I'll take a look at that and the new utilities. I'm trying to build a static browser app without a backend. Would I need the User-Agent flow vs the Web-Server flow?

ChuckJonas commented 3 years ago

@wuservices possibly...

I can add the user-agent flow... It's basically JUST redirecting the user via the authorization and then access token is returned in the redirect url. Your client code can then parse it from the URL.

Is this app only going to be used by a single, specific salesforce organization?

If you're trying to write a generic app that can make requests against any org, CORS will need to be added in each Salesforce ORG for your domain so it can make requests against the rest API.

In that case, you really just want a backend to make the request (you mentioned vue, so maybe checkout nuxtjs)

wuservices commented 3 years ago

@ChuckJonas Yeah the flow looks pretty simple, but it'd definitely be a nice addition to have the library help out. The app will run in an iframe in the Zendesk apps sidebar. Since it's internal only, it's just for a single organization. I think we can get a lot done from just the frontend, so not using a backend at all and using the User-Agent flow should keep things simpler.

Nuxt would definitely be an option if we needed to add a backend later, or we also love Cloudflare Workers.

ChuckJonas commented 3 years ago

👍 sounds like a good use. I will push a version today that should "support" the user-agent flow (it's really just updating a type).

The API for this will likely evolve slightly before v3 is officially released as to make it more streamlined, but not significantly.

ChuckJonas commented 3 years ago

@wuservices types for this should setup in @3.0.0-rc.23... I haven't actually tested, so LMK if it doesn't work.

Docs are here: https://app.gitbook.com/@cjonas/s/ts-force/v/3.0/guides/connecting-with-salesforce/oauth/user-agent-flow

Also added docs for the original issue: https://app.gitbook.com/@cjonas/s/ts-force/v/3.0/guides/connecting-with-salesforce/re-auth-on-token-expiration

Closing this out

wuservices commented 3 years ago

@ChuckJonas thanks for early the Christmas present. 🙇‍♂️

The docs, examples, and utilities for the User-Agent flow made this a breeze to get set up once I set up my Connected App in Salesforce with the right redirect URL, enabled CORS in Salesforce, and made sure to use the My Domain URL for the instance (both for CORS and to allow custom login options configured in my org). Using the refresh token also worked to get a new access token, and I'm looking forward to trying out your axios-auth-refresh example.

wuservices commented 3 years ago

@ChuckJonas I've been testing the axios-auth-refresh example you added and it doesn't work for me from the browser. I found that once my access token expires, Salesforce doesn't return CORS headers on the response, so the browser blocks being able to read the response headers to figure out that it's a 401 Unauthorized response. This feels pretty silly on Salesforce's part.

Sounds like you haven't used the User-Agent flow, so maybe you're not using CORS yourself, but wasn't sure if this was expected from Salesforce, or if you had any other ideas. I opened an issue in axios-auth-refresh to see if it could be modified to intercept any error, but other than that, maybe writing my own interceptor or forking that project would be the best bet.

ChuckJonas commented 3 years ago

Ugh, ya I haven't tried it in the browser, but Salesforce CORS is a mess so that doesn't surprise me.

Even with an interceptor, it's going to be a tricky to know if it was a CORS issue or maybe just an internet connectivity issue.

Unfortunately, it doesn't look like axios-auth-refresh supports a custom shouldReauth "hook" (only which codes to execute on, which doesn't really help here). This would be the ideal approach, as axios-auth-retry handles some of the more complicated edge cases.

You could see if you can get theretry-axios library to work (checkout the example I posted above in this thread). In the onRetryAttempt, I think might be able to make your re-auth attempt and then overwrite the authorization header and also update the ts-force client.

If you figure out a clean way to do so, please provide an update here so I can add some documentation!

wuservices commented 3 years ago

I've been considering a few solutions and ended up just hacking away at axios-auth-refresh. In the interest of getting something working, I just copied it locally and made a couple of changes. I also submitted a PR to axios-auth-refresh, but it may be too niche to get merged.

With my change, you just need to use the interceptNetworkError option:

createAuthRefreshInterceptor(restInstance.request, refreshAuthLogic, {
  interceptNetworkError: true
})

The cleanest general option would be to add a shouldReauth hook or just the ability to pass in your own shouldInterceptError function to override the default. However, a added a quick and easy to implement option interceptNetworkError, to handle intercepting any network error. As you noted, it's tricky to detect if there's a CORS issue, and this SO also concludes that you can't, and it's part of the spec. I'm not sure if my logic works everywhere as I've only tested in Chrome, but I don't see why it wouldn't. Since I'm just building an internal tool, I'm not going to test everywhere, and this is viable for now. Not the simplest for all if it isn't merged though.

I considered trying retry-axios or just implementing an interceptor myself, but to handle some edge cases or to intercept requests that come in while we're refreshing the token, I'd end up reimplementing a bit of axios-auth-refresh, so I didn't want to go that route. Alternatively, it was probably good enough for me to naively refresh on every failed request with a basic interceptor, but I think this patch works the best for the Salesforce browser use case, with the least effort.

The biggest downside is that token refresh attempts will also occur if network connectivity is down, but one could mitigate that with an extra check for connectivity in their refresh method. Otherwise, users could just utilize your example, but deploy a proxy. I was trying to avoid that to optimize for simplicity and latency, but others might prefer the more precise 401 handling.

wuservices commented 3 years ago

My PR (https://github.com/Flyrell/axios-auth-refresh/pull/134) was merged and should land in axios-auth-refresh v3.1.0 (the next release), so I think using it with interceptNetworkError: true is a decent option for those who want to use CORS. It seems to be working smoothly for me.

ChuckJonas commented 3 years ago

nice work!

Maybe you could provide a quick example of how you are using it, so I can update the docs?

wuservices commented 3 years ago

I've adapted some old code I was using into a generic example using localStorage to authenticate in the same window. I've since rewritten this to use a pop-up since my app runs in an iframe and had to use a new window. However, that code got a bit involved, so this example easier to follow.

This code requires the instance URL and client ID to be set, along with CORS for OAuth to be enabled in Salesforce and the app's URL added to the redirect URLs of the connected app. I set up a bunch of utilities, then initialize everything at the bottom, or redirect and authorize.

It also requires axios-auth-refresh v3.1.0+ (not yet released).

import { getAuthorizationUrl, requestAccessToken, Rest, setDefaultConfig } from 'ts-force'
import createAuthRefreshInterceptor from 'axios-auth-refresh'

// Using the "My Domain" instance URL instead of login.salesforce.com is necessary for CORS when
// using a refresh token to get a new access token.
const SALESFORCE_INSTANCE_URL = 'https://<my-domain>.my.salesforce.com'
const SALESFORCE_CLIENT_ID = '<your connected app client ID>'

const SALESFORCE_ACCESS_TOKEN_KEY = 'salesforceAccessToken'
const SALESFORCE_REFRESH_TOKEN_KEY = 'salesforceRefreshToken'

/**
 * Returns URL-encoded params from the hash as an object.
 */
function hashSearchParams() {
  const params: { [key: string]: string } = {}
  // Leading # prevents URLSearchParams from parsing the hash
  for (const [key, value] of new URLSearchParams(
    window.location.hash.substring(1)
  )) {
    params[key] = value
  }
  return params
}

/**
 * Removes the hash from the current URL and replaces the current history entry.
 *
 * This may be useful for tidying up a URL after reading and persisting some data from it
 * such as app settings. If there are OAuth tokens in the hash (e.g. from Salesforce),
 * removing the tokens from the hash as soon as they've been read, helps avoid token leakage
 * to other code (e.g. analytics or logging), which may read the hash.
 */
function stripHash() {
  history.replaceState(
    '',
    document.title,
    window.location.pathname + window.location.search
  )
}

function setTokensFromHash(paramsFromHash: { [key: string]: string }) {
  const accessToken = paramsFromHash.access_token
  if (accessToken) {
    localStorage.setItem(SALESFORCE_ACCESS_TOKEN_KEY, accessToken)
  }
  const refreshToken = paramsFromHash.refresh_token
  if (refreshToken) {
    localStorage.setItem(SALESFORCE_REFRESH_TOKEN_KEY, refreshToken)
  }
}

function authorize() {
  window.location.href = getAuthorizationUrl({
    response_type: 'token',
    instanceUrl: SALESFORCE_INSTANCE_URL,
    client_id: SALESFORCE_CLIENT_ID,
    redirect_uri: location.href
  })
}

function setAccessToken(accessToken: string) {
  localStorage.setItem(SALESFORCE_ACCESS_TOKEN_KEY, accessToken)
  setDefaultConfig({
    accessToken,
    instanceUrl: SALESFORCE_INSTANCE_URL
  })
}

async function getAccessToken() {
  const accessToken = localStorage.getItem(SALESFORCE_ACCESS_TOKEN_KEY)
  if (accessToken) {
    const isInitialLoad = !accessToken

    // Ensure that the default config is initialized before the interceptor is set up,
    // otherwise the interceptor won't work properly.
    setAccessToken(accessToken)

    // Set up access token refresh, but only once
    if (isInitialLoad) {
      // Get default instance to configure it with interceptor
      const restInstance = new Rest()

      const refreshAuthLogic = async (failedRequest: any) => {
        const newToken = await refreshAccessToken()
        const newAuthHeader = `Bearer ${newToken}`
        failedRequest.response.config.headers['Authorization'] = newAuthHeader
        // Also update our instance so any additional requests use the new token
        restInstance.request.defaults.headers['Authorization'] = newAuthHeader
      }

      // Intercept all error responses and silently refresh the access token and retry
      createAuthRefreshInterceptor(restInstance.request, refreshAuthLogic, {
        // CAUTION: This will attempt to refresh the access token on any network failure
        // because Salesforce doesn't return CORS headers on a 401 Unauthorized response,
        // so we can't differentiate between a loss of connectivity to Salesforce and
        // an invalid access token. Despite being coarse, it should work as intended in most
        // scenarios.
        interceptNetworkError: true
      })
    }
  }
  return accessToken
}

function getRefreshToken() {
  return localStorage.getItem(SALESFORCE_REFRESH_TOKEN_KEY)
}

/**
 * Uses the refresh token to get a new access token.
 */
async function refreshAccessToken() {
  const refreshToken = getRefreshToken()
  if (refreshToken) {
    const response = await requestAccessToken({
      grant_type: 'refresh_token',
      instanceUrl: SALESFORCE_INSTANCE_URL,
      client_id: SALESFORCE_CLIENT_ID,
      refresh_token: refreshToken
    })
    setAccessToken(response.access_token)
  } else {
    throw new Error(
      'Cannot refresh access token because no refresh token was found'
    )
  }
}

// Initialize tokens from hash or authorize
const paramsFromHash = hashSearchParams()
if (Object.keys(paramsFromHash).length) {
  // Avoid token leakage
  stripHash()
}

setTokensFromHash(paramsFromHash)
if (!getAccessToken()) {
  authorize()
}