how-to-implement-safe-signin-via-oauth

How to Implement Safe Sign-In via OAuth

This tutorial is a step-by-step guide on how to set up Sign-In via OAuth without exposing credentials to the client. After following this tutorial, you will be able to use Social Identities like Facebook, Google+, LinkedIn, and Twitter as described in the user guide.

This tutorial is broken down in the following sections:

  1. What is OAuth?
  2. OAuth and Javascript
  3. Set-Up
  4. Create an Application
  5. Add Business Logic
  6. Client-side implementation
  7. Troubleshooting

What is OAuth?

OAuth is an open protocol to allow secure authorization in a simple and standard method from web, mobile, and desktop applications. As an application developer, services that provide HTTP APIs supporting OAuth, let you access parts of their service on behalf of your users. For example, when accessing a social network site, if your user gives you permission to access his account, you might be able to import pictures, friends lists, or contact information to your application.

This tutorial focuses on using OAuth to implement Sign-In through the following providers: Facebook, Google+, LinkedIn, and Twitter. These providers use either OAuth1.0a (LinkedIn, Twitter) or OAuth2.0 (Facebook, Google+).

To use OAuth, you first have to register an application with that provider. The application will be assigned credentials, needed when performing the OAuth flow.

OAuth1.0a

OAuth1.0a provides a method to access a protected resource at the provider, on behalf of the resource owner (the user). This process consists of the following steps:

  1. The client obtains an unauthorized request token.
  2. The client redirects the user to a login dialog at the provider.
  3. The user authorizes the request token, associating it with their account.
  4. The provider redirects the user back to the client.
  5. The client exchanges the request token for an access token.
  6. The access token allows the client to access a protected resource at the provider, on behalf of the user.

OAuth1.0a protocol

OAuth2.0

OAuth2.0 is similar to the OAuth1.0a protocol explained above, but the steps to be followed are different:

  1. The client redirects the user to a login dialog at the provider.
  2. The user authorizes the client.
  3. The provider redirects the user back to the client, additionally returning an access_token.
  4. The client validates the access token.
  5. The access token allows the client to access a protected resource at the provider.

OAuth2.0 protocol

Both OAuth1.0a and OAuth2.0 use (part of) the application credentials to perform the flow described above.

OAuth and JavaScript

Implementing OAuth in client-side JavaScript is challenging, mainly because:

  • The nature of OAuth forces the client to expose application credentials. This is a serious security vulnerability.
  • The provider may not support CORS. This makes it impossible for the client to communicate with the provider.

Furthermore, implementing multiple OAuth protocols for the different providers is a lot of work. Instead, a simpler and safer solution is needed. One that:

  • Never reveals application credentials to the client.
  • Functions even if the provider does not support CORS.
  • Requires little effort to implement and is easy to use.

The remainder of this tutorial will adhere to these three requirements. By using Kinvey’s Business Logic feature, in combination with the library, you can provide your users with a safe Sign-In via OAuth through Facebook, Google+, LinkedIn, and Twitter.

Set-Up

All the OAuth application credentials will be stored on Kinvey. To do so, execute the following steps:

  1. Browse to the Data Store, and create a new collection. Name it oauth.

    Create the oauth collection

  2. Click Settings, and set the Permissions level to Private. This ensures application credentials are never revealed to clients. However, if you wish, you can use Entity Permissions to allow certain (admin) users or groups to read or write certain application credentials.

    Set Permissions to Private

Create an Application

Next, you have to create an application for each provider you want to support. This is required to allow users to login through that provider.

Facebook

  1. Create a new Facebook app.
  2. The app will be assigned an App ID and App Secret. In the Settings panel, make sure to set the App Domains to the domain your Kinvey app is running on.

    Make sure you have added the Website platform and set the site url to your apps url.

    Create a Facebook app

  3. Use the App ID to load the Facebook SDK in your application. Please see Using Facebook Social Identity for more information.

Google

  1. Create a new Google platform.
  2. Select your newly created Google platform and select APIs & auth → Credentials
  3. Under OAuth click Create new Client ID and select Web Application. Make sure to set the Authorized Redirect URIs and Authorized JavaScript Origins to the domain for your Kinvey app. Click Create Client ID when you are finished.
  4. The app will be assigned a Client ID and Client secret.

    Create a Google app

  5. Return to the data browser. Manually add the Google provider and your Google application credentials:

     {
         "provider"        : "google",
         "consumer_key"    : "949921928169-gp8qc0cqv87ce6jfhoom1q8jk6609658.apps.googleusercontent.com",
         "consumer_secret" : "pKT1_ONG55uHAxE2zDDABQT1"
     }
  6. The Data Browser should now contain the Google provider similar to the image below.

    Add the Google provider

LinkedIn

  1. Create a new LinkedIn app.

    Make sure you have added an authorized redirect url and it matches your apps domain. The path for the callback url does not need to be implmented. For example, you can use /callback as the path for your callback url and it is not required that the path /callback is implemented or maps to a real path in your application.

    Create a LinkedIn app

  2. Return to the data browser. Manually add the LinkedIn provider and your LinkedIn application credentials:

     {
         "provider"        : "linkedIn",
         "consumer_key"    : "77g8m3nmtgwlou",
         "consumer_secret" : "yfglHJU8bbp7fbe4"
     }

    Make sure you have capitalized the second i in linkedIn.

  3. The Data Browser should now contain the LinkedIn provider similar to the image below.

    Add the LinkedIn provider

Twitter

  1. Create a new Twitter app.
  2. The app will be assigned a Consumer key and Consumer secret. Click on manage keys and access tokens to view your Consumer secret.

    Make sure you have set the callback url and it matches your apps domain. The path for the callback url does not need to be implmented. For example, you can use /callback as the path for your callback url and it is not required that the path /callback is implemented or maps to a real path in your application.

    localhost can not be used as a domain for your callback url.

    Create a Twitter app

  3. Return to the data browser. Manually add the Twitter provider and your Twitter application credentials:

     {
         "provider"        : "twitter",
         "consumer_key"    : "wKIvhmLRhJ5Qe6bIgSmdwQ5wV",
         "consumer_secret" : "NIw5iYpO17y4R0v9bSg5MjJ6hDPrqfgMlO6EOnJLAJNYEcIOd2"
     }
  4. The Data Browser should now contain the Twitter provider similar to the image below.

    Add the Twitter provider

Add Business Logic

At this point, you should have created the applications for the providers you want to support. The oauth collection in the Data Browser should look similar to:

OAuth collection

The next step is to add server-side code which performs the OAuth flow. This is done through Business Logic (BL). The BL script implements the OAuth flow through Facebook, Google+, LinkedIn, and Twitter. This means that:

  • Most of the OAuth flow will be performed server-side.
  • Application credentials will never be revealed to the client.
  • No CORS issues can arise.

To add the BL script to your app, execute the following steps:

  1. Go to Business Logic. Click on the + next to user in the left menu, and add an onPreSave hook:

    Business Logic Pre Save tab

  2. Copy and paste the code below. Then, click Save.

    Do not paste this code inside the onPreSave function itself, but replace the entire hook with the code below.

// OAuth providers.
var urlConfig = {
  // OAuth1.0a.
  requestToken: {
    linkedIn : 'https://api.linkedin.com/uas/oauth/requestToken',
    twitter  : 'https://api.twitter.com/oauth/request_token'
  },
  accessToken: {
    linkedIn : 'https://api.linkedin.com/uas/oauth/accessToken',
    twitter  : 'https://api.twitter.com/oauth/access_token'
  },

   // OAuth1.0a and 2.0.
  authenticate: {
    google   : 'https://accounts.google.com/o/oauth2/auth?scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile',
    linkedIn : 'https://api.linkedin.com/uas/oauth/authenticate',
    twitter  : 'https://api.twitter.com/oauth/authenticate'
  }
};

/**
 * Tokenizes string.
 *
 * @param {string} string Token string.
 * @example foo=bar&baz=qux => { foo: 'bar', baz: 'qux' }
 */
var tokenize = function(string) {
  var tokens = {};
  string.split('&').forEach(function(pair) {
    var segments = pair.split('=', 2).map(decodeURIComponent);
    if(segments[0]) {// Key must be non-empty.
      tokens[segments[0]] = segments[1];
    }
  });
  return tokens;
};

/**
 * Logs in or creates user with provided OAuth1.0a credentials.
 *
 * @param {Object} doc OAuth provider document.
 * @param {Object} request Kinvey request object.
 * @param {Object} response Kinvey response object.
 * @param {Object} modules Available JavaScript libraries.
 */
var login = function(doc, request, response, modules) {
  // Validate request body.
  var provider = doc.provider;
  if(!(request.body._socialIdentity && request.body._socialIdentity[provider] && request.body._socialIdentity[provider].access_token && request.body._socialIdentity[provider].access_token_secret)) {
    response.body = {
      error       : 'IncompleteRequestBody',
      description : 'The request body is either missing or incomplete.',
      debug       : 'Missing required attributes: _socialIdentity.<provider>.[access_token, access_token_secret]'
    };
    return response.complete(400);
  }

  // Add consumer key and secret to request body.
  request.body._socialIdentity[provider].consumer_key    = doc.consumer_key;
  request.body._socialIdentity[provider].consumer_secret = doc.consumer_secret;

  // Forward request to the login endpoint.
  modules.request.post({
    uri: 'https://' + encodeURIComponent(request.headers.host) + '/user/' + encodeURIComponent(request.username) + '/login',
    headers: {
      Authorization          : request.headers.authorization,// Application credentials.
      'Content-Type'         : 'application/json',
      'X-Kinvey-API-Version' : request.headers['x-kinvey-api-version']
    },
    json: request.body
  }, function(err, res) {
    if(err) {// Request failed.
      modules.logger.error(err);
      response.body = {
        error       : 'BLInternalError',
        description : 'The Business Logic script did not complete. See debug message for details.',
        debug       : err.code
      };
      response.complete(550);
    }
    else {// Forward response.
      response.body = res.body;
      response.complete(res.status);
    }
  });
};

/**
 * Obtains an OAuth1.0a request token.
 *
 * @param {Object} doc OAuth provider document.
 * @param {Object} request Kinvey request object.
 * @param {Object} response Kinvey response object.
 * @param {Object} modules Available JavaScript libraries.
 */
var requestOAuth1Token = function(doc, request, response, modules) {
  // Validate request body.
  var provider = doc.provider;
  if(!request.body.redirect) {
    response.body = {
      error       : 'IncompleteRequestBody',
      description : 'The request body is either missing or incomplete.',
      debug       : 'Missing required attributes: redirect'
    };
    return response.complete(400);
  }

  // Fire request.
  modules.request.post({
    uri: urlConfig.requestToken[provider],
    oauth: {
      callback: request.body.redirect,
      consumer_key: doc.consumer_key,
      consumer_secret: doc.consumer_secret
    }
  }, function(err, res) {
    if(err) {// Request failed.
      modules.logger.error(err);
      response.body = {
        error       : 'BLInternalError',
        description : 'The Business Logic script did not complete. See debug message for details.',
        debug       : err.code
      };
      response.complete(550);
    }
    else if(200 !== res.status) {// Tokens are invalid.
      response.body = {
        error       : 'InvalidCredentials',
        description : 'Invalid credentials. Please retry your request with correct credentials.',
        debug       : res.body
      };
      response.complete(401);
    }
    else {// Tokens are valid.
      var tokens = tokenize(res.body);
      response.body = {
        url: urlConfig.authenticate[provider] + '?oauth_token=' + encodeURIComponent(tokens.oauth_token),
        oauth_token: tokens.oauth_token,
        oauth_token_secret: tokens.oauth_token_secret
      };
      response.complete(200);
    }
  });
};

/**
 * Obtains an OAuth2 access token.
 *
 * @param {Object} doc OAuth provider document.
 * @param {Object} request Kinvey request object.
 * @param {Object} response Kinvey response object.
 * @param {Object} modules Available JavaScript libraries.
 */
var requestOAuth2Token = function(doc, request, response, modules) {
  // Validate request body.
  var provider = doc.provider;
  if(!request.body.redirect) {
    response.body = {
      error       : 'IncompleteRequestBody',
      description : 'The request body is either missing or incomplete.',
      debug       : 'Missing required attributes: redirect'
    };
    return response.complete(400);
  }

  // Build URL.
  var url = urlConfig.authenticate[provider] +
    '&client_id=' + encodeURIComponent(doc.consumer_key) +
    '&response_type=token' +
    '&redirect_uri=' + encodeURIComponent(request.body.redirect);
  if(request.body.state) {// Append state if specified.
    url += '&state=' + encodeURIComponent(request.body.state);
  }

  // No network request needed, return instantly.
  response.body = { url: url };
  response.complete(200);
};

/**
 * Verifies the OAuth1.0a request token.
 *
 * @param {Object} doc OAuth provider document.
 * @param {Object} request Kinvey request object.
 * @param {Object} response Kinvey response object.
 * @param {Object} modules Available JavaScript libraries.
 */
var verifyToken = function(doc, request, response, modules) {
  // Validate request body.
  var provider = doc.provider;
  if(!(request.body.oauth_token && request.body.oauth_token_secret && request.body.oauth_verifier)) {
    response.body = {
      error       : 'IncompleteRequestBody',
      description : 'The request body is either missing or incomplete.',
      debug       : 'Missing required attributes: oauth_token, oauth_token_secret, and/or oauth_verifier'
    };
    return response.complete(400);
  }

  // Verify request.
  modules.request.post({
    uri: urlConfig.accessToken[provider],
    oauth: {
      consumer_key: doc.consumer_key,
      consumer_secret: doc.consumer_secret,
      token: request.body.oauth_token,
      token_secret: request.body.oauth_token_secret,
      verifier: request.body.oauth_verifier
    }
  }, function(err, res) {
    if(err) {// Request failed.
      modules.logger.error(err);
      response.body = {
        error       : 'BLInternalError',
        description : 'The Business Logic script did not complete. See debug message for details.',
        debug       : err.code
      };
      response.complete(550);
    }
    else if(200 !== res.status) {// Tokens are invalid.
      response.body = {
        error       : 'InvalidCredentials',
        description : 'Invalid credentials. Please retry your request with correct credentials.',
        debug       : res.body
      };
      response.complete(401);
    }
    else {// Tokens are valid.
      var tokens = tokenize(res.body);
      response.body = {
        access_token        : tokens.oauth_token,
        access_token_secret : tokens.oauth_token_secret
      };
      response.complete(200);
    }
  });
};

/**
 * onPreSave hook. Routes OAuth related requests.
 *
 * @param {Object} request Kinvey request object.
 * @param {Object} response Kinvey response object.
 * @param {Object} modules Available JavaScript libraries.
 */
var onPreSave = function(request, response, modules) {
  var provider = request.params.provider;
  if(null != provider) {
    modules.collectionAccess.collection('oauth').findOne({ provider: provider }, function(err, doc) {
      if(err) {// Request failed.
        modules.logger.error(err);
        response.body = {
          error       : 'BLInternalError',
          description : 'The Business Logic script did not complete. See debug message for details.',
          debug       : err.code
        };
        response.complete(550);
      }
      else if(null == doc) {// Provider not supported.
        response.body = {
          error       : 'FeatureUnavailable',
          description : 'This OAuth provider is not supported by this application.',
          debug       : ''
        };
        response.complete(400);
      }
      else {// Provider found.
        // Route step.
        var step = request.params.step || 'login';
        var oauth1 = -1 !== ['linkedIn', 'twitter'].indexOf(provider);
        var oauth2 = -1 !== ['google'].indexOf(provider);

        // OAuth1.0a providers support login through this proxy.
        if(oauth1 && -1 !== ['login'].indexOf(step)) {
          login(doc, request, response, modules);
        }

        // Request a token.
        else if(oauth1 && 'requestToken' === step) {
          requestOAuth1Token(doc, request, response, modules);
        }
        else if(oauth2 && 'requestToken' === step) {// OAuth2.
          requestOAuth2Token(doc, request, response, modules);
        }

        // OAuth1.0a providers require the request token to be verified.
        else if(oauth1 && 'verifyToken' === step) {
          verifyToken(doc, request, response, modules);
        }

        // Provider/step combination not supported.
        else {
          response.body = {
            error       : 'BadRequest',
            description : 'Unable to understand request.',
            debug       : 'The provided step is not valid in combination with this provider.'
          };
          response.complete(400);
        }
      }
    });
  }
  else {
    // Patch regular save requests by embedding the `consumer_key` and
    // `consumer_secret` for OAuth1.0a providers.
    if(request.body && request.body._socialIdentity) {
      var oAuth1Provider = null;
      var socialIdentity = request.body._socialIdentity;
      if(socialIdentity.twitter && socialIdentity.twitter.access_token) {
        oAuth1Provider = 'twitter';
      }
      else if(socialIdentity.linkedIn && socialIdentity.linkedIn.access_token) {
        oAuth1Provider = 'linkedIn';
      }

      // Patch.
      if(null !== oAuth1Provider) {
        return modules.collectionAccess.collection('oauth').findOne({ provider: oAuth1Provider }, function(err, doc) {
          if(err) {// Request failed.
            modules.logger.error(err);
            response.body = {
              error       : 'BLInternalError',
              description : 'The Business Logic script did not complete. See debug message for details.',
              debug       : err.code
            };
            response.complete(550);
          }
          else if(null == doc) {// Provider not supported.
            response.body = {
              error       : 'FeatureUnavailable',
              description : 'This OAuth provider is not supported by this app.',
              debug       : ''
            };
            response.complete(400);
          }
          else {// Provider found.
            request.body._socialIdentity[oAuth1Provider].consumer_key    = doc.consumer_key;
            request.body._socialIdentity[oAuth1Provider].consumer_secret = doc.consumer_secret;
            response['continue']();
          }
        });
      }
    }

    // Regular request, continue.
    response['continue']();
  }
};

Client-side implementation

At this point, the server-side implementation is complete. The library exposes a connect and disconnect method to use Social Identities in your app. Both methods are described in detail in the User guide. In short; connect is used to link a social identity to a user, and disconnect unlinks one.

Troubleshooting

After following this tutorial, your users should be able to login via OAuth. However, if you’re having trouble implementing, this section will provide information. If you cannot find an answer to your question, please contact us at support@kinvey.com.

Error Reporting

The Business Logic script can return any of the following errors upon failure:

ErrorStatusCodeDescription
BadRequest400Unable to understand request.
FeatureUnavailable400This OAuth provider is not supported by this app.
IncompleteRequestBody400The request body is either missing or incomplete.
InvalidCredentials401Invalid credentials. Please retry your request with correct credentials.
BLInternalError550The Business Logic script did not complete. See debug message for details.

In addition, the library will emit a Kinvey.Error.SOCIAL_ERROR if an error occurs. Make sure to inspect the debug property, as it contains useful information to help you debug the error.

Common Issues

Make sure the following is true for your app:

  • The provider field in the oauth collection contains the name of the OAuth provider (google, linkedIn, or twitter).
  • The _acl.creator field yields your application key (kidXXXX).
  • The permissions of the oauth collection is set to Private.
  • The Business Logic script is placed under the user collection.
  • The Business Logic Logs panel does not contain any errors.

Limitations

The implementation presented in this tutorial allows one provider per application. For example, if your Kinvey application has two Twitter apps associated with it, you can only allow your users to Sign-In through one of the two.

Got a question?