Authenticate a Forest Admin API against an OAuth protected API Backend

In some cases, you may want to authenticate a Forest Admin user against your API backend that is hosted elsewhere and protected via OAuth. Your API backend may be using a hosted authentication provider such as Auth0, Okta, or Ping, or you may have implemented OAuth yourself.

To achieve authentication between the Forest Admin API and your API backend, the client credentials grant (https://tools.ietf.org/html/rfc6749#section-4.4) is appropriate. When implemented, a client credentials grant eliminates the need to transmit a static or pre-shared, per-user, API key between Forest Admin and your API backend.

Requirements

  • An admin backend running on forest-express-sequelize/forest-express-mongoose

  • An API protected via OAuth

Authentication Flow Overview

  1. When you need to call your backend API from a Forest Admin route (via a Smart Action or route override, for example), your Forest Admin API must first call your OAuth token endpoint, including the appropriate Client ID, Client Secret, and Forest Admin username (retrieved via Forest Admin’s request.user.email field). The Client ID and Client Secret should be stored securely and protected against disclosure. The implementation of this step is likely specific to your authentication provider. See Auth0 documentation: https://auth0.com/docs/flows/guides/client-credentials/call-api-client-credentials.

  2. Upon success, your authentication provider returns a signed access token (typically as a JWT) that includes your Forest Admin username as a custom claim. You must configure your authentication provider to include this custom claim in the token response. See Auth0 documentation: https://auth0.com/docs/scopes/current/sample-use-cases#add-custom-claims-to-a-token.

  3. The signed token is received by your Forest Admin API and stored in memory or in a database if the user needs to make additional authenticated calls to your API backend. Future calls using this token should inspect the expiry date of the token to determine if a token refresh is required.

  4. Finally, the Forest Admin API makes a call to your API backend, including the signed token received from the authentication provider in step 2. Your API validates the signed token and inspects the custom claims to retrieve the username of the Forest Admin user. This username should either match or be mapped to an appropriate user entry in your backend so that the correct authorization (via role or access control restrictions) can be applied.

Sample Code

In the following example, we override the CREATE user route to authenticate the Forest Admin user against our own API backend protected via OAuth.

//routes/users.js

const { createUser } = require('../services/your-api')
...

// Create a User
router.post('/user', permissionMiddlewareCreator.create(), (request, response, next) => {
  const recordSerializer = new RecordSerializer(User)
  createUser(request, request.body.data.attributes).then(async (response) => {
    response.send(await recordSerializer.serialize(response.body.data))
  }).catch(error => {
    response.status(400).send({ error: error.message })
  })
})
// service/your-api.js

require('dotenv').config();
const got = require('got');

// userAccessTokens will store any retrieved tokens mapped to Forest Admin usernames
const userAccessTokens = {};
const oAuthClient = got.extend({
  prefixUrl: process.env.YOUR_OAUTH_DOMAIN,
  headers: {
    'user-agent': 'forest-admin',
    'content-type': 'application/json',
  },
  responseType: 'json',
});

const getToken = async (username) => {
  return oAuthClient.post('oauth/token', {
    json: {
      client_id: process.env.YOUR_API_CLIENT_ID,
      client_secret: process.env.YOUR_API_CLIENT_SECRET,
      audience: process.env.YOUR_API_AUDIENCE,
      grant_type: 'client_credentials',
      forest_admin_username: username,
    },
  })
    .then((response) => {
      const now = new Date();
      return {
        accessToken: response.body.access_token,
        expiryDate: now.setSeconds(now.getSeconds() + response.body.expires_in),
      };
    })
    .catch((error) => {
      throw error;
    });
};

const tokenDetailsForUser = async (username) => {
  if (!(username in userAccessTokens)
    || userAccessTokens[username].expiryDate === undefined
    || userAccessTokens[username].expiryDate < new Date()) {
    // if there is no stored token or the expiry date has already passed
    // get a token fro your oAuth provider
    return getToken(username)
      .then((tokenDetails) => {
        userAccessTokens[username] = tokenDetails;
        return tokenDetails;
      })
      .catch((error) => {
        throw error;
      });
  }

  // otherwise, use the stored token
  return userAccessTokens[username];
};

// This is an API Client that will call your backend
// Before each request, it is configured to add the correct
// user access token (a signed JWT) to the header so that
// your backend API can inspect for the username
const YourAPIClient = got.extend({
  prefixUrl: process.env.YOUR_API_BASE_URL,
  headers: {
    'user-agent': 'forest-admin',
    'content-type': 'application/json',
  },
  responseType: 'json',
  hooks: {
    beforeRequest: [
      async (options) => {
        try {
          const userAccessTokenDetails = await tokenDetailsForUser(options.context.user);
          options.headers.authorization = `Bearer ${userAccessTokenDetails.accessToken}`;
        } catch (error) {
          throw new Error('Unable to fetch access token.');
        }
      },
    ],
  },
});

const createUser = async (request, data) => {
  return YourAPIClient.post('users', {
    context: {
      user: request.user.email,
    },
    json: data,
  })
    .then((response) => {
      console.log(response.body);
      return response;
    })
    .catch((error) => {
      console.log('error creating user');
      throw error;
    });
};
module.exports = {
  createUser,
};

Last updated