denali-js/denali-auth

View on GitHub
lib/facets/oauthable.ts

Summary

Maintainability
A
2 hrs
Test Coverage
import {
  upperFirst,
  camelCase,
  forEach
} from 'lodash';
import { attr, createMixin, Errors, Response, Model, Action } from 'denali';
import * as createDebug from 'debug';

const debug = createDebug('denali-auth:oauthable');
const createError = Errors;

export interface ProviderConfig {
  type: string;
  Strategy: any;
  strategy: any;
  options?: object;
  attributesFromProfile(results: any): object;
}

export default createMixin((BaseModel: typeof Model, options: { providers?: (string | { type: string })[] } = {}) => {
  if (!options.providers || options.providers.length === 0) {
    throw new Error('You must provide at least one provider to the oauthable({ providers: [] }) mixin');
  }
  // Loop through requested providers, load the passport strategy for each, and
  // load it
  let providers: { [type: string]: ProviderConfig } = {};
  options.providers.forEach((providerConfig) => {
    let config;
    if (typeof providerConfig === 'string') {
      let type = providerConfig;
      config = providers[type] = <ProviderConfig>{ type };
    } else {
      config = providers[providerConfig.type] = <ProviderConfig>providerConfig;
    }
    config.Strategy = require(`passport-${ config.type }`);
  });

  class OAuthableMixin extends BaseModel {

    [attr: string]: any;

    static isOauthable = true;
    static strategyName = 'oauth';

    static authenticateRequest(action: Action, params: any, User: Model) {
      debug(`[${ action.request.id }]: attempting to authenticate`);
      if (!params.provider) {
        throw new Errors.BadRequest(`No OAuth provider was specified, unable to attempt OAuth login`);
      }
      let provider = providers[params.provider];
      if (!provider) {
        throw new Errors.BadRequest(`${ params.provider } OAuth provider is not supported`);
      }
      // If no options were provided inline in the mixin config, then load them
      // from the application config
      if (!provider.options) {
        debug(`no inline provider options supplied, caching ${ provider.type } options from config/environment.js`);
        let authConfig = action.container.config.auth || {};
        let oauthConfig = authConfig.oauthProviders || {};
        provider.options = oauthConfig[provider.type];
      }
      // If this is the first request against this provider, instantiate and
      // cache the strategy for later use
      if (!provider.strategy) {
        debug(`caching ${ provider.type } strategy instance`);
        provider.strategy = new provider.Strategy(provider.options, (accessToken: string, refreshToken: string, profile: any, cb: (err: Error, data: any) => void) => {
          // We hijack this callback - normally, you pass back the user, but we
          // override below since we don't have a reference to the model class up
          // here yet. So we just kick all the data out and handle the rest in our
          // authenticateRequest method below.
          cb(null, { accessToken, refreshToken, profile });
        });
      }
      // Shim around the passport API to take advantage of their strategies
      return new Promise((resolve, reject) => {
        let strategy = Object.create(provider.strategy);
        debug(`[${ action.request.id }]: augmenting strategy instance for this request`);
        strategy.success = async (results: { accessToken: string, refreshToken: string, profile: any }) => {
          debug(`[${ action.request.id }]: success! third party granted access token, trying to lookup matching user by profile id`);
          // This method is invoked by the cb from above. Here, we find the user
          // that matches those credentials now that we have a reference to the
          // User class itself
          try {
            // We associate third-party profiles with our own user accounts via
            // provider id fields, i.e. twitterId, facebookId
            let user = await User.findOne({ [`${ camelCase(provider.type) }Id`]: results.profile.id });
            if (!user) {
              debug(`[${ action.request.id }]: no user was found with that profile id, registering a new user now`);
              // There's no user that exists for that third-party profile, so
              // create one
              user = await this.registerFromProvider(provider.type, results, User);
            }
            resolve(user);
          } catch (error) {
            reject(error);
          }
        };
        strategy.fail = function fail(challenge: any, status = 401) {
          debug(`[${ action.request.id }]: strategy failed`);
          reject(createError(status, challenge));
        };
        strategy.redirect = function redirect(url: string, status = 302) {
          debug(`[${ action.request.id }]: strategy redirected to ${ url }`);
          resolve(new Response(status, { headers: { Location: url } }));
        };
        strategy.error = function error(err: Error) {
          debug(`[${ action.request.id }]: strategy errored`);
          reject(err);
        };

        strategy.authenticate(action.request, params);
      });
    }

    static async registerFromProvider(type: string, results: any, User: Model) {
      let attributes;
      let providerSpecificMapper = `attributesFrom${ upperFirst(type) }Profile`;
      if (User[providerSpecificMapper]) {
        attributes = User[providerSpecificMapper](results);
      } else if (providers[type].attributesFromProfile) {
        attributes = providers[type].attributesFromProfile(results);
      } else {
        attributes = Object.assign({}, results.profile, {
          [`${ type }Id`]: results.profile.id
        });
        delete attributes.id;
      }
      let user = await User.register(attributes);
      return user;
    }

  }

  forEach(providers, (provider) => {
    (<any>OAuthableMixin)[`${ camelCase(provider.type) }Id`] = attr('text');
  });

  return OAuthableMixin;
});