diff --git a/composer.json b/composer.json
index e28780bcf560570de6a2d34e1ad3f280e06d1b12..b02b218338cbe6c585d21dac1d9eb9c057d19486 100644
--- a/composer.json
+++ b/composer.json
@@ -30,6 +30,7 @@
     "consolidation/output-formatters": "^3.2.0",
     "drupal/dynamic_entity_reference": "^2.0@alpha",
     "drupal/encrypt": "^3.0@rc",
+    "lusitanian/oauth": "^0.8.11",
     "messageagency/force.com-toolkit-for-php": "^1.0.0"
   }
 }
diff --git a/config/install/salesforce.settings.yml b/config/install/salesforce.settings.yml
index 1a2b933d9c12794bd2b4b9937cefe0ad30846958..77ae9eb2d924053605f0b0856bc243356e17a612 100644
--- a/config/install/salesforce.settings.yml
+++ b/config/install/salesforce.settings.yml
@@ -8,3 +8,4 @@ pull_max_queue_size: 100000
 show_all_objects: false
 standalone: false
 limit_mapped_object_revisions: 10
+salesforce_auth_provider: ''
diff --git a/config/schema/salesforce.schema.yml b/config/schema/salesforce.schema.yml
index e00a0262ed0840736f91e28f22b9889fb05c3a7f..ac83bb9277c18346201dfbbaf48330bce4f17415 100644
--- a/config/schema/salesforce.schema.yml
+++ b/config/schema/salesforce.schema.yml
@@ -38,6 +38,10 @@ salesforce.settings:
       type: integer
       label: 'Limit mapped object revisions'
       description: 'Specify a maximum number of revisions to retain for Mapped Object content. Use 0 for no limit.'
+    salesforce_auth_provider:
+      type: string
+      label: 'Default authorization provider id'
+      description: 'A salesforce_auth config entity id which provides API authorization.'
     rest_api_version:
       type: mapping
       label: 'REST API Version'
@@ -52,3 +56,35 @@ salesforce.settings:
         version:
           type: string
           label: 'Version'
+
+salesforce.salesforce_auth.*:
+  type: config_entity
+  label: 'Salesforce Auth Provider'
+  mapping:
+    id:
+      type: string
+      label: 'ID'
+    label:
+      type: label
+      label: 'Label'
+      translatable: true
+    provider:
+      type: string
+      label: 'Provider Plugin'
+    provider_settings:
+      type: salesforce.auth_provider_settings.[%parent.provider]
+      label: 'Provider Plugin Settings'
+
+salesforce.auth_provider_settings.oauth:
+  type: mapping
+  label: 'Salesforce OAuth Provider Settings'
+  mapping:
+    consumer_key:
+      type: string
+      label: 'Consumer Key'
+    consumer_secret:
+      type: string
+      label: 'Consumer Secret'
+    login_url:
+      type: uri
+      label: 'Login URL'
diff --git a/salesforce.info.yml b/salesforce.info.yml
index 0e3ba3afcf79194cf8a9e259b8a05c6809599a7c..c6a3d3f05d2a9799f1d1c2ada1ec48bb183082cc 100644
--- a/salesforce.info.yml
+++ b/salesforce.info.yml
@@ -3,4 +3,4 @@ type: module
 description: Modules to integrate Drupal and Salesforce
 package: Salesforce
 core: 8.x
-configure: salesforce.config_index
+configure: salesforce.admin_config_salesforce
diff --git a/salesforce.install b/salesforce.install
index 306bfa67f472fc731c3a484e55d2bf22990866e1..41b194482b260a0b930d3a31453ba39ad93fe5f4 100644
--- a/salesforce.install
+++ b/salesforce.install
@@ -5,7 +5,11 @@
  * Salesforce install file.
  */
 
+use Drupal\Core\Url;
 use Drupal\Component\Serialization\Json;
+use Drupal\salesforce\Entity\SalesforceAuthConfig;
+use Drupal\Core\Utility\UpdateException;
+use Drupal\salesforce\SalesforceAuthProviderPluginManager;
 
 /**
  * Implements hook_uninstall().
@@ -36,7 +40,12 @@ function salesforce_requirements($phase) {
   // Check requirements once per 24 hours.
   $last = \Drupal::state()->get('salesforce.last_requirements_check', 0);
 
-  $requirements['salesforce_usage'] = salesforce_get_usage_requirements();
+  $requirements['salesforce_auth_provider'] = salesforce_get_auth_provider_requirements();
+
+  // Don't bother checking usage if we aren't connected to Salesforce.
+  if ($requirements['salesforce_auth_provider']['severity'] == REQUIREMENT_OK) {
+    $requirements['salesforce_usage'] = salesforce_get_usage_requirements();
+  }
 
   $requirements['salesforce_tls'] = salesforce_get_tls_requirements();
   if ($last < REQUEST_TIME - (60 * 60 * 24) || empty($requirements['salesforce_tls'])) {
@@ -47,6 +56,53 @@ function salesforce_requirements($phase) {
   return $requirements;
 }
 
+/**
+ * Check to see if an auth provider has been set.
+ */
+function salesforce_get_auth_provider_requirements() {
+  $requirements = [
+    'title' => t('Salesforce Authentication Status'),
+    'value' => t('Provider Status'),
+  ];
+  /** @var \Drupal\salesforce\SalesforceAuthProviderPluginManager $authMan */
+  $authMan = \Drupal::service('plugin.manager.salesforce.auth_providers');
+  if (!$authMan->hasProviders()) {
+    $requirements += [
+      'description' => t('No auth providers have been created. Please <a href="@href">create an auth provider</a> to connect to Salesforce.', ['@href' => Url::fromRoute('entity.salesforce_auth.add_form')]),
+      'severity' => REQUIREMENT_WARNING,
+    ];
+  }
+  elseif (!$authMan->getConfig()) {
+    $requirements += [
+      'description' => t('Default auth provider has not been set. Please <a href="@href">choose an auth provider</a> to connect to Salesforce.', ['@href' => Url::fromRoute('salesforce.auth_config')->toString()]),
+      'severity' => REQUIREMENT_WARNING,
+    ];
+  }
+  else {
+    $failMessage = t('Salesforce authentication failed. Please <a href="@href">check your auth provider settings</a> to connect to Salesforce.', ['@href' => Url::fromRoute('entity.salesforce_auth.edit_form', ['salesforce_auth' => $authMan->getConfig()])]);
+    try {
+      if (!$authMan->getToken()) {
+        $requirements += [
+          'description' => $failMessage,
+          'severity' => REQUIREMENT_WARNING,
+        ];
+      }
+    }
+    catch (Exception $e) {
+      $requirements += [
+        'description' => $failMessage,
+        'severity' => REQUIREMENT_WARNING,
+      ];
+    }
+  }
+  if (empty($requirements['severity'])) {
+    $requirements += [
+      'severity' => REQUIREMENT_OK,
+    ];
+  }
+  return $requirements;
+}
+
 /**
  * Check TLS status.
  */
@@ -132,6 +188,7 @@ function salesforce_get_usage_requirements() {
   }
 
   if (empty($usage)) {
+    // Missing usage information is not necessarily a problem.
     $requirements += [
       'severity' => REQUIREMENT_OK,
       'description' => t('Usage information unavailable'),
@@ -152,7 +209,7 @@ function salesforce_get_usage_requirements() {
     ];
     $requirements += [
       'description' => t('Usage: %usage requests of %limit limit (%pct) in the past 24 hours.', $args),
-      'severity' => $pct >= 100 ? REQUIREMENT_WARNING : REQUIREMENT_OK,
+      'severity' => $pct >= 100 ? REQUIREMENT_ERROR : ($pct >= 80 ? REQUIREMENT_WARNING : REQUIREMENT_OK),
     ];
   }
 
@@ -209,3 +266,40 @@ function salesforce_update_8003() {
 function salesforce_update_8004() {
   \Drupal::cache()->delete('salesforce:objects');
 }
+
+/**
+ * Convert legacy oauth credentials to new auth plugin config.
+ */
+function salesforce_update_8005() {
+  $change_list = \Drupal::entityDefinitionUpdateManager()->getChangeSummary();
+  if (!empty($change_list['salesforce_auth'])) {
+    throw new UpdateException("** PENDING SCHEMA UPDATES ** \n** Please install entity updates (entup) to install Salesforce Auth Config before proceeding with database update.");
+  }
+
+  $message = '';
+  // If auth plugin providers have not been created already, convert existing.
+  if (SalesforceAuthConfig::load('oauth_default')) {
+    // If an auth config with our name already exists, we are done here.
+    $message = 'Existing "oauth_default" provider config detected. Refused to set legacy credentials.';
+  }
+  else {
+    SalesforceAuthProviderPluginManager::updateAuthConfig();
+    $message = 'Default OAuth provider created from legacy credentials.';
+  }
+  return $message;
+}
+
+/**
+ * Convert legacy token to new auth plugin config.
+ */
+function salesforce_update_8006() {
+  $oauth = SalesforceAuthProviderPluginManager::getAuthConfig();
+  if (!$oauth) {
+    return "Auth config missing. Refused to update legacy token.";
+  }
+  if (\Drupal::service('salesforce.auth_token_storage')->retrieveAccessToken($oauth->id())) {
+    return "Token exists. Refused to update.";
+  }
+  \Drupal::service('salesforce.auth_token_storage')->updateToken();
+  return "Updated legacy token to new plugin config.";
+}
diff --git a/salesforce.permissions.yml b/salesforce.permissions.yml
index 3de400a507c041f9ea29db484af4045abb2bea2d..8277c4774a862c31bd0dd2a7178322d4b6e06162 100644
--- a/salesforce.permissions.yml
+++ b/salesforce.permissions.yml
@@ -6,3 +6,4 @@ administer salesforce:
 authorize salesforce:
   title: 'authorize salesforce'
   description: 'Access Salesforce OAuth consumer key, secret, and identify information'
+  restrict access: TRUE
diff --git a/salesforce.services.yml b/salesforce.services.yml
index a91e46c9a040f0719e44810ba1e893ccf22a4ed6..a72e9f999917c4349055b6749e24429d4f28d70d 100644
--- a/salesforce.services.yml
+++ b/salesforce.services.yml
@@ -2,3 +2,15 @@ services:
   salesforce.client:
     class: Drupal\salesforce\Rest\RestClient
     arguments: ['@http_client', '@config.factory', '@state', '@cache.default', '@serialization.json', '@datetime.time']
+
+  plugin.manager.salesforce.auth_providers:
+    class: Drupal\salesforce\SalesforceAuthProviderPluginManager
+    arguments: ['@container.namespaces', '@cache.discovery', '@module_handler', '@entity_type.manager']
+
+  salesforce.http_client_wrapper:
+    class: Drupal\salesforce\Client\HttpClientWrapper
+    arguments: ['@http_client']
+
+  salesforce.auth_token_storage:
+    class: Drupal\salesforce\Storage\SalesforceAuthTokenStorage
+    arguments: ['@state']
diff --git a/src/Client/HttpClientWrapper.php b/src/Client/HttpClientWrapper.php
new file mode 100644
index 0000000000000000000000000000000000000000..53c6c5fb3c83f1be247b0a1e1be81c5c989552c5
--- /dev/null
+++ b/src/Client/HttpClientWrapper.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace Drupal\salesforce\Client;
+
+use GuzzleHttp\ClientInterface as GuzzleClientInterface;
+use OAuth\Common\Http\Client\ClientInterface;
+use OAuth\Common\Http\Uri\UriInterface;
+
+/**
+ * Wraps Guzzle HTTP client for an OAuth ClientInterface.
+ */
+class HttpClientWrapper implements ClientInterface {
+
+  /**
+   * Guzzle HTTP Client service.
+   *
+   * @var \GuzzleHttp\ClientInterface
+   */
+  protected $httpClient;
+
+  /**
+   * HttpClientWrapper constructor.
+   *
+   * @param \GuzzleHttp\ClientInterface $httpClient
+   *   Guzzle HTTP client service, from core http_client.
+   */
+  public function __construct(GuzzleClientInterface $httpClient) {
+    $this->httpClient = $httpClient;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function retrieveResponse(
+    UriInterface $endpoint,
+    $requestBody,
+    array $extraHeaders = [],
+    $method = 'POST'
+  ) {
+    $response = $this->httpClient->request($method, $endpoint->getAbsoluteUri(), ['headers' => $extraHeaders, 'form_params' => $requestBody]);
+    return $response->getBody()->getContents();
+  }
+
+}
diff --git a/src/Consumer/OAuthCredentials.php b/src/Consumer/OAuthCredentials.php
new file mode 100644
index 0000000000000000000000000000000000000000..091672b61318458ee2f54e9294cb6ff22a588af2
--- /dev/null
+++ b/src/Consumer/OAuthCredentials.php
@@ -0,0 +1,18 @@
+<?php
+
+namespace Drupal\salesforce\Consumer;
+
+/**
+ * OAuth user agent credentials.
+ */
+class OAuthCredentials extends SalesforceCredentials {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __construct($consumerKey, $loginUrl, $consumerSecret) {
+    parent::__construct($consumerKey, $loginUrl);
+    $this->consumerSecret = $consumerSecret;
+  }
+
+}
diff --git a/src/Consumer/SalesforceCredentials.php b/src/Consumer/SalesforceCredentials.php
new file mode 100644
index 0000000000000000000000000000000000000000..2ee0818f32e3e8ca03eefe003da0f83bc036e329
--- /dev/null
+++ b/src/Consumer/SalesforceCredentials.php
@@ -0,0 +1,60 @@
+<?php
+
+namespace Drupal\salesforce\Consumer;
+
+use Drupal\Core\Url;
+use OAuth\Common\Consumer\Credentials;
+
+/**
+ * Salesforce credentials extension, for drupalisms.
+ */
+abstract class SalesforceCredentials extends Credentials implements SalesforceCredentialsInterface {
+
+  /**
+   * Login URL e.g. https://test.salesforce.com or https://login.salesforce.com.
+   *
+   * @var string
+   */
+  protected $loginUrl;
+
+  /**
+   * Consumer key for the Salesforce connected OAuth app.
+   *
+   * @var string
+   */
+  protected $consumerKey;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __construct($consumerKey, $loginUrl) {
+    parent::__construct($consumerKey, NULL, NULL);
+    $this->loginUrl = $loginUrl;
+    $this->consumerKey = $consumerKey;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getConsumerKey() {
+    return $this->consumerKey;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getLoginUrl() {
+    return $this->loginUrl;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCallbackUrl() {
+    return Url::fromRoute('salesforce.oauth_callback', [], [
+      'absolute' => TRUE,
+      'https' => TRUE,
+    ])->toString();
+  }
+
+}
diff --git a/src/Consumer/SalesforceCredentialsInterface.php b/src/Consumer/SalesforceCredentialsInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..000e1d1097d1dd2b013d055d37b61f7f8d0eabd1
--- /dev/null
+++ b/src/Consumer/SalesforceCredentialsInterface.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace Drupal\salesforce\Consumer;
+
+/**
+ * Salesforce credentials interface.
+ */
+interface SalesforceCredentialsInterface {
+
+  /**
+   * Get the consumer key for these credentials.
+   *
+   * @return string
+   *   The consumer key.
+   */
+  public function getConsumerKey();
+
+  /**
+   * Get the login URL for these credentials.
+   *
+   * @return string
+   *   The login url, e.g. https://login.salesforce.com.
+   */
+  public function getLoginUrl();
+
+}
diff --git a/src/Entity/SalesforceAuthConfig.php b/src/Entity/SalesforceAuthConfig.php
new file mode 100644
index 0000000000000000000000000000000000000000..b6e2944a57081d7ecacfe583970e3d1df120cbe3
--- /dev/null
+++ b/src/Entity/SalesforceAuthConfig.php
@@ -0,0 +1,143 @@
+<?php
+
+namespace Drupal\salesforce\Entity;
+
+use Drupal\Core\Config\Entity\ConfigEntityBase;
+use Drupal\Core\Entity\EntityInterface;
+
+/**
+ * Defines a Salesforce Auth entity.
+ *
+ * @ConfigEntityType(
+ *   id = "salesforce_auth",
+ *   label = @Translation("Salesforce Auth Config"),
+ *   module = "salesforce_auth",
+ *   entity_keys = {
+ *     "id" = "id",
+ *     "label" = "label"
+ *   },
+ *   handlers = {
+ *     "list_builder" = "Drupal\salesforce\Controller\SalesforceAuthListBuilder",
+ *     "form" = {
+ *       "default" = "Drupal\salesforce\Form\SalesforceAuthForm",
+ *       "delete" = "Drupal\salesforce\Form\SalesforceAuthDeleteForm",
+ *       "revoke" = "Drupal\salesforce\Form\SalesforceAuthRevokeForm"
+ *      }
+ *   },
+ *   links = {
+ *     "collection" = "/admin/config/salesforce/authorize/list",
+ *     "edit-form" = "/admin/config/salesforce/authorize/edit/{salesforce_auth}",
+ *     "delete-form" = "/admin/config/salesforce/authorize/delete/{salesforce_auth}",
+ *     "revoke" = "/admin/config/salesforce/authorize/revoke/{salesforce_auth}"
+ *   },
+ *   admin_permission = "authorize salesforce",
+ * )
+ */
+class SalesforceAuthConfig extends ConfigEntityBase implements EntityInterface {
+
+  /**
+   * Auth id. e.g. "oauth_full_sandbox".
+   *
+   * @var string
+   */
+  protected $id;
+
+  /**
+   * Auth label. e.g. "OAuth Full Sandbox".
+   *
+   * @var string
+   */
+  protected $label;
+
+  /**
+   * The auth provider for this auth config.
+   *
+   * @var string
+   */
+  protected $provider;
+
+  /**
+   * Provider plugin configuration settings.
+   *
+   * @var array
+   */
+  protected $provider_settings = [];
+
+  /**
+   * Auth manager.
+   *
+   * @var \Drupal\salesforce\SalesforceAuthProviderPluginManager
+   */
+  protected $manager;
+
+  /**
+   * Id getter.
+   */
+  public function id() {
+    return $this->id;
+  }
+
+  /**
+   * Label getter.
+   */
+  public function label() {
+    return $this->label;
+  }
+
+  /**
+   * Plugin getter.
+   *
+   * @return \Drupal\salesforce\SalesforceAuthProviderInterface|null
+   *   The auth provider plugin, or null.
+   */
+  public function getPlugin() {
+    $settings = $this->provider_settings ?: [];
+    $settings += ['id' => $this->id()];
+    return $this->provider ? $this->authManager()->createInstance($this->provider, $settings) : NULL;
+  }
+
+  /**
+   * Plugin id getter.
+   *
+   * @return string|null
+   *   The auth provider plugin id, or null.
+   */
+  public function getPluginId() {
+    return $this->provider ?: NULL;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getLoginUrl() {
+    return $this->getPlugin() ? $this->getPlugin()->getLoginUrl() : '';
+  }
+
+  /**
+   * Auth manager wrapper.
+   *
+   * @return \Drupal\salesforce\SalesforceAuthProviderPluginManager|mixed
+   *   The auth provider plugin manager.
+   */
+  public function authManager() {
+    if (!$this->manager) {
+      $this->manager = \Drupal::service("plugin.manager.salesforce.auth_providers");
+    }
+    return $this->manager;
+  }
+
+  /**
+   * Returns a list of plugins, for use in forms.
+   *
+   * @return array
+   *   The list of plugins, indexed by ID.
+   */
+  public function getPluginsAsOptions() {
+    $options = ['' => t('- Select -')];
+    foreach ($this->authManager()->getDefinitions() as $id => $definition) {
+      $options[$id] = ($definition['label']);
+    }
+    return $options;
+  }
+
+}
diff --git a/src/Plugin/SalesforceAuthProvider/SalesforceOAuthPlugin.php b/src/Plugin/SalesforceAuthProvider/SalesforceOAuthPlugin.php
new file mode 100644
index 0000000000000000000000000000000000000000..d88d1abbd9371473e81d3e6c0b28ebff09afedbd
--- /dev/null
+++ b/src/Plugin/SalesforceAuthProvider/SalesforceOAuthPlugin.php
@@ -0,0 +1,170 @@
+<?php
+
+namespace Drupal\salesforce\Plugin\SalesforceAuthProvider;
+
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Routing\TrustedRedirectResponse;
+use Drupal\salesforce\Consumer\OAuthCredentials;
+use Drupal\salesforce\SalesforceAuthProviderPluginBase;
+use Drupal\salesforce\SalesforceOAuthPluginInterface;
+use Drupal\salesforce\Storage\SalesforceAuthTokenStorageInterface;
+use OAuth\Common\Http\Client\ClientInterface;
+use OAuth\Common\Http\Uri\Uri;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Salesforce OAuth user-agent flow auth provider plugin.
+ *
+ * @Plugin(
+ *   id = "oauth",
+ *   label = @Translation("Salesforce OAuth User-Agent")
+ * )
+ */
+class SalesforceOAuthPlugin extends SalesforceAuthProviderPluginBase implements SalesforceOAuthPluginInterface {
+
+  /**
+   * Credentials.
+   *
+   * @var \Drupal\salesforce\Consumer\OAuthCredentials
+   */
+  protected $credentials;
+
+  /**
+   * {@inheritdoc}
+   */
+  const SERVICE_TYPE = 'oauth';
+
+  /**
+   * {@inheritdoc}
+   */
+  const LABEL = 'OAuth';
+
+  /**
+   * SalesforceOAuthPlugin constructor.
+   *
+   * @param string $id
+   *   The plugin id.
+   * @param \Drupal\salesforce\Consumer\OAuthCredentials $credentials
+   *   The credentials.
+   * @param \OAuth\Common\Http\Client\ClientInterface $httpClient
+   *   The oauth http client.
+   * @param \Drupal\salesforce\Storage\SalesforceAuthTokenStorageInterface $storage
+   *   Auth token storage service.
+   *
+   * @throws \OAuth\OAuth2\Service\Exception\InvalidScopeException
+   *   Comment.
+   */
+  public function __construct($id, OAuthCredentials $credentials, ClientInterface $httpClient, SalesforceAuthTokenStorageInterface $storage) {
+    parent::__construct($credentials, $httpClient, $storage, [], new Uri($credentials->getLoginUrl()));
+    $this->id = $id;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+    $configuration = array_merge(self::defaultConfiguration(), $configuration);
+    $cred = new OAuthCredentials($configuration['consumer_key'], $configuration['login_url'], $configuration['consumer_secret']);
+    return new static($configuration['id'], $cred, $container->get('salesforce.http_client_wrapper'), $container->get('salesforce.auth_token_storage'));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function defaultConfiguration() {
+    $defaults = parent::defaultConfiguration();
+    return array_merge($defaults, [
+      'consumer_secret' => '',
+    ]);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
+    $form['consumer_key'] = [
+      '#title' => t('Salesforce consumer key'),
+      '#type' => 'textfield',
+      '#description' => t('Consumer key of the Salesforce remote application you want to grant access to'),
+      '#required' => TRUE,
+      '#default_value' => $this->credentials->getConsumerKey(),
+    ];
+
+    $form['consumer_secret'] = [
+      '#title' => $this->t('Salesforce consumer secret'),
+      '#type' => 'textfield',
+      '#description' => $this->t('Consumer secret of the Salesforce remote application.'),
+      '#required' => TRUE,
+      '#default_value' => $this->credentials->getConsumerSecret(),
+    ];
+
+    $form['login_url'] = [
+      '#title' => t('Login URL'),
+      '#type' => 'textfield',
+      '#default_value' => $this->credentials->getLoginUrl(),
+      '#description' => t('Enter a login URL, either https://login.salesforce.com or https://test.salesforce.com.'),
+      '#required' => TRUE,
+    ];
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
+    parent::submitConfigurationForm($form, $form_state);
+    $this->setConfiguration($form_state->getValues());
+    $settings = $form_state->getValue('provider_settings');
+    // Write the config id to private temp store, so that we can use the same
+    // callback URL for all OAuth applications in Salesforce.
+    /** @var \Drupal\Core\TempStore\PrivateTempStore $tempstore */
+    $tempstore = \Drupal::service('user.private_tempstore')->get('salesforce_oauth');
+    $tempstore->set('config_id', $form_state->getValue('id'));
+
+    try {
+      $path = $this->getAuthorizationEndpoint();
+      $query = [
+        'redirect_uri' => $this->credentials->getCallbackUrl(),
+        'response_type' => 'code',
+        'client_id' => $settings['consumer_key'],
+      ];
+
+      // Send the user along to the Salesforce OAuth login form. If successful,
+      // the user will be redirected to {redirect_uri} to complete the OAuth
+      // handshake, and thence to the entity listing. Upon failure, the user
+      // redirect URI will send the user back to the edit form.
+      $response = new TrustedRedirectResponse($path . '?' . http_build_query($query), 302);
+      $response->send();
+      return;
+    }
+    catch (\Exception $e) {
+      $this->messenger()->addError(t("Error during authorization: %message", ['%message' => $e->getMessage()]));
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getConsumerSecret() {
+    return $this->credentials->getConsumerSecret();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function finalizeOauth() {
+    $token = $this->requestAccessToken(\Drupal::request()->get('code'));
+
+    // Initialize identity.
+    $headers = [
+      'Authorization' => 'OAuth ' . $token->getAccessToken(),
+      'Content-type' => 'application/json',
+    ];
+    $data = $token->getExtraParams();
+    $response = $this->httpClient->retrieveResponse(new Uri($data['id']), [], $headers);
+    $identity = $this->parseIdentityResponse($response);
+    $this->storage->storeIdentity($this->service(), $identity);
+    return TRUE;
+  }
+
+}
diff --git a/src/Rest/RestClient.php b/src/Rest/RestClient.php
index 84aac08b6db761b6eb0c7ed13996b8723cd253c9..2b734963ff8f5ed23f47097a044df9703d963749 100644
--- a/src/Rest/RestClient.php
+++ b/src/Rest/RestClient.php
@@ -9,6 +9,7 @@ use Drupal\Core\Cache\CacheBackendInterface;
 use Drupal\Core\Config\ConfigFactoryInterface;
 use Drupal\Core\State\StateInterface;
 use Drupal\Core\Url;
+use Drupal\salesforce\SalesforceAuthProviderPluginManager;
 use Drupal\salesforce\SelectQueryInterface;
 use Drupal\salesforce\SFID;
 use Drupal\salesforce\SObject;
@@ -89,6 +90,15 @@ class RestClient implements RestClientInterface {
 
   protected $httpClientOptions;
 
+  /**
+   * Token storage.
+   *
+   * @var \Drupal\salesforce\Storage\SalesforceAuthTokenStorage
+   *
+   * @deprecated BC legacy auth scheme only, do not use, will be removed.
+   */
+  private $storage;
+
   const CACHE_LIFETIME = 300;
   const LONGTERM_CACHE_LIFETIME = 86400;
 
@@ -121,6 +131,21 @@ class RestClient implements RestClientInterface {
     return $this;
   }
 
+  /**
+   * Storage helper.
+   *
+   * @return \Drupal\salesforce\Storage\SalesforceAuthTokenStorage
+   *   The auth token storage service.
+   *
+   * @deprecated BC legacy auth scheme only, do not use, will be removed.
+   */
+  private function storage() {
+    if (!$this->storage) {
+      $this->storage = \Drupal::service('salesforce.auth_token_storage');
+    }
+    return $this->storage;
+  }
+
   /**
    * {@inheritdoc}
    */
@@ -371,6 +396,7 @@ class RestClient implements RestClientInterface {
    */
   public function setConsumerKey($value) {
     $this->mutableConfig->set('consumer_key', $value)->save();
+    SalesforceAuthProviderPluginManager::updateAuthConfig();
     return $this;
   }
 
@@ -386,6 +412,7 @@ class RestClient implements RestClientInterface {
    */
   public function setConsumerSecret($value) {
     $this->mutableConfig->set('consumer_secret', $value)->save();
+    SalesforceAuthProviderPluginManager::updateAuthConfig();
     return $this;
   }
 
@@ -402,6 +429,7 @@ class RestClient implements RestClientInterface {
    */
   public function setLoginUrl($value) {
     $this->mutableConfig->set('login_url', $value)->save();
+    SalesforceAuthProviderPluginManager::updateAuthConfig();
     return $this;
   }
 
@@ -433,13 +461,14 @@ class RestClient implements RestClientInterface {
    */
   public function setAccessToken($token) {
     $this->state->set('salesforce.access_token', $token);
+    $this->storage()->updateToken();
     return $this;
   }
 
   /**
    * Get refresh token.
    */
-  protected function getRefreshToken() {
+  public function getRefreshToken() {
     return $this->state->get('salesforce.refresh_token');
   }
 
@@ -448,6 +477,7 @@ class RestClient implements RestClientInterface {
    */
   public function setRefreshToken($token) {
     $this->state->set('salesforce.refresh_token', $token);
+    $this->storage()->updateToken();
     return $this;
   }
 
@@ -524,6 +554,7 @@ class RestClient implements RestClientInterface {
    */
   public function setIdentity($data) {
     $this->state->set('salesforce.identity', $data);
+    $this->storage()->updateIdentity();
     return $this;
   }
 
diff --git a/src/SalesforceAuthProviderInterface.php b/src/SalesforceAuthProviderInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..130de7a7910102b22da60d29d06204d4ca378033
--- /dev/null
+++ b/src/SalesforceAuthProviderInterface.php
@@ -0,0 +1,153 @@
+<?php
+
+namespace Drupal\salesforce;
+
+use Drupal\Component\Plugin\PluginInspectionInterface;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\Core\Plugin\PluginFormInterface;
+use OAuth\Common\Token\TokenInterface;
+use OAuth\OAuth2\Service\ServiceInterface;
+
+/**
+ * Class SalesforceAuthProvider.
+ */
+interface SalesforceAuthProviderInterface extends ServiceInterface, PluginFormInterface, ContainerFactoryPluginInterface, PluginInspectionInterface {
+
+  const AUTH_TOKEN_PATH = '/services/oauth2/token';
+  const AUTH_ENDPOINT_PATH = '/services/oauth2/authorize';
+  const SOAP_CLASS_PATH = '/services/Soap/class/';
+
+  /**
+   * Id of this service.
+   *
+   * @return string
+   *   Id of this service.
+   */
+  public function id();
+
+  /**
+   * Label of this service.
+   *
+   * @return string
+   *   Id of this service.
+   */
+  public function label();
+
+  /**
+   * Auth type id for this service, e.g. oauth, jwt, etc.
+   *
+   * @return string
+   *   Provider type for this auth provider.
+   */
+  public function type();
+
+  /**
+   * Perform a refresh of the given token.
+   *
+   * @param \OAuth\Common\Token\TokenInterface $token
+   *   The token.
+   *
+   * @return \OAuth\Common\Token\TokenInterface
+   *   The refreshed token.
+   *
+   * @throws \OAuth\OAuth2\Service\Exception\MissingRefreshTokenException
+   *   Comment.
+   */
+  public function refreshAccessToken(TokenInterface $token);
+
+  /**
+   * Login URL, e.g. https://login.salesforce.com, for this plugin.
+   *
+   * @return string
+   *   Login URL.
+   */
+  public function getLoginUrl();
+
+  /**
+   * Consumer key for the connected OAuth app.
+   *
+   * @return string
+   *   Consumer key.
+   */
+  public function getConsumerKey();
+
+  /**
+   * Consumer secret for the connected OAuth app.
+   *
+   * @return string
+   *   Consumer secret.
+   */
+  public function getConsumerSecret();
+
+  /**
+   * Access token for this plugin.
+   *
+   * @return \OAuth\OAuth2\Token\TokenInterface
+   *   The Token.
+   *
+   * @throws \OAuth\Common\Storage\Exception\TokenNotFoundException
+   */
+  public function getAccessToken();
+
+  /**
+   * Identify for this connection.
+   *
+   * @return array
+   *   Identity for this connection.
+   */
+  public function getIdentity();
+
+  /**
+   * TRUE if the connection has a token, regardless of validity.
+   *
+   * @return bool
+   *   TRUE if the connection has a token, regardless of validity.
+   */
+  public function hasAccessToken();
+
+  /**
+   * Default configuration for this plugin type.
+   *
+   * @return array
+   *   Default configuration.
+   */
+  public static function defaultConfiguration();
+
+  /**
+   * Authorization URL for this plugin type.
+   *
+   * @return string
+   *   Authorization URL for this plugin type.
+   */
+  public function getAuthorizationEndpoint();
+
+  /**
+   * Access token URL for this plugin type.
+   *
+   * @return string
+   *   Access token URL for this plugin type.
+   */
+  public function getAccessTokenEndpoint();
+
+  /**
+   * Instance URL for this connection.
+   *
+   * @return string
+   *   Instance URL for this connection.
+   *
+   * @throws \OAuth\Common\Storage\Exception\TokenNotFoundException
+   */
+  public function getInstanceUrl();
+
+  /**
+   * Callback for configuration form after saving config entity.
+   *
+   * @param array $form
+   *   The configuration form.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The form state.
+   */
+  public function save(array $form, FormStateInterface $form_state);
+
+}
diff --git a/src/SalesforceAuthProviderPluginBase.php b/src/SalesforceAuthProviderPluginBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..f65a73e8ac61900a315cd59978679fc0d3f8b9fc
--- /dev/null
+++ b/src/SalesforceAuthProviderPluginBase.php
@@ -0,0 +1,236 @@
+<?php
+
+namespace Drupal\salesforce;
+
+use Drupal\Core\DependencyInjection\DependencySerializationTrait;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Messenger\MessengerTrait;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use OAuth\Common\Http\Exception\TokenResponseException;
+use OAuth\Common\Http\Uri\Uri;
+use OAuth\OAuth2\Service\Salesforce;
+
+/**
+ * Shared methods for auth providers.
+ */
+abstract class SalesforceAuthProviderPluginBase extends Salesforce implements SalesforceAuthProviderInterface {
+
+  use StringTranslationTrait;
+  use DependencySerializationTrait;
+  use MessengerTrait;
+
+  /**
+   * Credentials.
+   *
+   * @var \Drupal\salesforce\Consumer\SalesforceCredentials
+   */
+  protected $credentials;
+
+  /**
+   * Configuration.
+   *
+   * @var array
+   */
+  protected $configuration;
+
+  /**
+   * Token storage.
+   *
+   * @var \Drupal\salesforce\Storage\SalesforceAuthTokenStorageInterface
+   */
+  protected $storage;
+
+  /**
+   * Machine name identifier.
+   *
+   * @var string
+   */
+  protected $id;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function defaultConfiguration() {
+    return [
+      'consumer_key' => '',
+      'login_url' => 'https://test.salesforce.com',
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getPluginId() {
+    return $this->getConfiguration('id');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getPluginDefinition() {
+    return $this->getConfiguration();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getConfiguration($key = NULL) {
+    if ($key !== NULL) {
+      return !empty($this->configuration[$key]) ? $this->configuration[$key] : NULL;
+    }
+    return $this->configuration;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getLoginUrl() {
+    return $this->credentials->getLoginUrl();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getConsumerKey() {
+    return $this->credentials->getConsumerKey();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getConsumerSecret() {
+    return $this->credentials->getConsumerSecret();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setConfiguration(array $configuration) {
+    $this->configuration = $configuration;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
+
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
+
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function save(array $form, FormStateInterface $form_state) {
+
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function id() {
+    return $this->id;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function type() {
+    return static::SERVICE_TYPE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function label() {
+    return static::LABEL;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getAuthorizationEndpoint() {
+    return new Uri($this->credentials->getLoginUrl() . static::AUTH_ENDPOINT_PATH);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getAccessTokenEndpoint() {
+    return new Uri($this->credentials->getLoginUrl() . static::AUTH_TOKEN_PATH);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function hasAccessToken() {
+    return $this->storage->hasAccessToken($this->id());
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getAccessToken() {
+    return $this->storage->retrieveAccessToken($this->id());
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getInstanceUrl() {
+    return $this->getAccessToken()->getExtraParams()['instance_url'];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getIdentity() {
+    return $this->storage->retrieveIdentity($this->id());
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function service() {
+    return $this->id();
+  }
+
+  /**
+   * Handle the identity response from Salesforce.
+   *
+   * @param string $responseBody
+   *   JSON identity response from Salesforce.
+   *
+   * @return array
+   *   The identity.
+   *
+   * @throws \OAuth\Common\Http\Exception\TokenResponseException
+   */
+  protected function parseIdentityResponse($responseBody) {
+    $data = json_decode($responseBody, TRUE);
+
+    if (NULL === $data || !is_array($data)) {
+      throw new TokenResponseException('Unable to parse response.');
+    }
+    elseif (isset($data['error'])) {
+      throw new TokenResponseException('Error in retrieving token: "' . $data['error'] . '"');
+    }
+    return $data;
+  }
+
+  /**
+   * Accessor to the storage adapter to be able to retrieve tokens.
+   *
+   * @return \Drupal\salesforce\Storage\SalesforceAuthTokenStorageInterface
+   *   The token storage.
+   */
+  public function getStorage() {
+    return $this->storage;
+  }
+
+}
diff --git a/src/SalesforceAuthProviderPluginInterface.php b/src/SalesforceAuthProviderPluginInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..9e96fbf4db4b1e9ebdd7545326d556efee1400c0
--- /dev/null
+++ b/src/SalesforceAuthProviderPluginInterface.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace Drupal\salesforce;
+
+use Drupal\Component\Plugin\PluginInspectionInterface;
+use Drupal\Core\Plugin\PluginFormInterface;
+
+/**
+ * Auth provider plugin interface.
+ */
+interface SalesforceAuthProviderPluginInterface extends PluginFormInterface, PluginInspectionInterface {
+
+  /**
+   * The auth provider service.
+   *
+   * @return \Drupal\salesforce\SalesforceAuthProviderInterface
+   *   The auth provider service.
+   */
+  public function service();
+
+  /**
+   * Login URL set for this auth provider.
+   *
+   * @return string
+   *   Login URL set for this auth provider.
+   */
+  public function getLoginUrl();
+
+}
diff --git a/src/SalesforceAuthProviderPluginManager.php b/src/SalesforceAuthProviderPluginManager.php
new file mode 100644
index 0000000000000000000000000000000000000000..3b24f0764e550844056e9ed49be14a9528f19904
--- /dev/null
+++ b/src/SalesforceAuthProviderPluginManager.php
@@ -0,0 +1,222 @@
+<?php
+
+namespace Drupal\salesforce;
+
+use Drupal\Core\Cache\CacheBackendInterface;
+use Drupal\Core\Extension\ModuleHandlerInterface;
+use Drupal\Core\Plugin\DefaultPluginManager;
+
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\salesforce\Entity\SalesforceAuthConfig as SalesforceAuthEntity;
+use Drupal\salesforce\Entity\SalesforceAuthConfig;
+use OAuth\Common\Storage\Exception\TokenNotFoundException;
+use OAuth\OAuth2\Token\StdOAuth2Token;
+
+/**
+ * Auth provider plugin manager.
+ */
+class SalesforceAuthProviderPluginManager extends DefaultPluginManager {
+
+  /**
+   * Config from salesforce.settings.
+   *
+   * @var \Drupal\Core\Config\ImmutableConfig
+   */
+  protected $config;
+
+  /**
+   * Entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $etm;
+
+  /**
+   * Salesforce Auth storage.
+   *
+   * @var \Drupal\Core\Config\Entity\ConfigEntityStorageInterface
+   */
+  protected $authStorage;
+
+  /**
+   * Constructor.
+   *
+   * @param \Traversable $namespaces
+   *   An object that implements \Traversable which contains the root paths
+   *   keyed by the corresponding namespace to look for plugin implementations.
+   * @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
+   *   Cache backend instance to use.
+   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
+   *   The module handler.
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $etm
+   *   Entity type manager service.
+   */
+  public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler, EntityTypeManagerInterface $etm) {
+    parent::__construct('Plugin/SalesforceAuthProvider', $namespaces, $module_handler, 'Drupal\salesforce\SalesforceAuthProviderInterface');
+    $this->alterInfo('salesforce_auth_provider_info');
+    $this->setCacheBackend($cache_backend, 'salesforce_auth_provider');
+    $this->etm = $etm;
+  }
+
+  /**
+   * Backwards-compatibility for legacy singleton auth.
+   *
+   * @deprecated BC legacy auth scheme only, do not use, will be removed.
+   */
+  public static function updateAuthConfig() {
+    $oauth = self::getAuthConfig();
+    $config = \Drupal::configFactory()->getEditable('salesforce.settings');
+    $settings = [
+      'consumer_key' => $config->get('consumer_key'),
+      'consumer_secret' => $config->get('consumer_secret'),
+      'login_url' => $config->get('login_url'),
+    ];
+    $oauth
+      ->set('provider_settings', $settings)
+      ->save();
+  }
+
+  /**
+   * Backwards-compatibility for legacy singleton auth.
+   *
+   * @deprecated BC legacy auth scheme only, do not use, will be removed.
+   */
+  public static function getAuthConfig() {
+    $config = \Drupal::configFactory()->getEditable('salesforce.settings');
+    $auth_provider = $config->get('salesforce_auth_provider');
+    if (!$auth_provider || !$oauth = SalesforceAuthConfig::load($auth_provider)) {
+      // Config to new plugin config system.
+      $values = [
+        'id' => 'oauth_default',
+        'label' => 'OAuth Default',
+        'provider' => 'oauth',
+      ];
+      $oauth = SalesforceAuthConfig::create($values);
+      $config
+        ->set('salesforce_auth_provider', 'oauth_default')
+        ->save();
+    }
+    return $oauth;
+  }
+
+  /**
+   * Wrapper for salesforce_auth storage service.
+   *
+   * @return \Drupal\Core\Config\Entity\ConfigEntityStorageInterface
+   *   Storage for salesforce_auth.
+   *
+   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
+   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
+   */
+  protected function authStorage() {
+    if (empty($this->authStorage)) {
+      $this->authStorage = $this->etm->getStorage('salesforce_auth');
+    }
+    return $this->authStorage;
+  }
+
+  /**
+   * All Salesforce auth providers.
+   *
+   * @return \Drupal\salesforce\Entity\SalesforceAuthConfig[]
+   *   All auth provider plugins.
+   *
+   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
+   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
+   */
+  public function getProviders() {
+    return $this->authStorage()->loadMultiple();
+  }
+
+  /**
+   * TRUE if any auth providers are defined.
+   *
+   * @return bool
+   *   TRUE if any auth providers are defined.
+   *
+   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
+   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
+   */
+  public function hasProviders() {
+    return $this->authStorage()->hasData();
+  }
+
+  /**
+   * Get the active auth service provider, or null if it has not been assigned.
+   *
+   * @return \Drupal\salesforce\Entity\SalesforceAuthConfig|null
+   *   The active service provider, or null if it has not been assigned.
+   */
+  public function getConfig() {
+    $provider_id = $this->config()->get('salesforce_auth_provider');
+    if (empty($provider_id)) {
+      return NULL;
+    }
+    return SalesforceAuthEntity::load($provider_id);
+  }
+
+  /**
+   * The auth provider plugin of the active service provider, or null.
+   *
+   * @return \Drupal\salesforce\SalesforceAuthProviderInterface|null
+   *   The auth provider plugin of the active service provider, or null.
+   */
+  public function getProvider() {
+    if (!$this->getConfig()) {
+      return NULL;
+    }
+    return $this->getConfig()->getPlugin();
+  }
+
+  /**
+   * Get the active token, or null if it has not been assigned.
+   *
+   * @return \OAuth\OAuth2\Token\TokenInterface
+   *   The token of the plugin of the active config, or null.
+   */
+  public function getToken() {
+    if (!$config = $this->getConfig()) {
+      return NULL;
+    }
+    if (!$provider = $config->getPlugin()) {
+      return NULL;
+    }
+    try {
+      return $provider->getAccessToken();
+    }
+    catch (TokenNotFoundException $e) {
+      return NULL;
+    }
+  }
+
+  /**
+   * Force a refresh of the active token and return the fresh token.
+   *
+   * @return \OAuth\OAuth2\Token\TokenInterface|null
+   *   The token.
+   */
+  public function refreshToken() {
+    if (!$config = $this->getConfig()) {
+      return NULL;
+    }
+    if (!$provider = $config->getPlugin()) {
+      return NULL;
+    }
+    $token = $this->getToken() ?: new StdOAuth2Token();
+    return $provider->refreshAccessToken($token);
+  }
+
+  /**
+   * Wrapper for salesforce.settings config.
+   *
+   * @return \Drupal\Core\Config\ImmutableConfig
+   *   Salesforce settings config.
+   */
+  protected function config() {
+    if (!$this->config) {
+      $this->config = \Drupal::config('salesforce.settings');
+    }
+    return $this->config;
+  }
+
+}
diff --git a/src/SalesforceOAuthPluginInterface.php b/src/SalesforceOAuthPluginInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..34d89b7d60caa08e3d46369b7cbbcab3d3d91c6f
--- /dev/null
+++ b/src/SalesforceOAuthPluginInterface.php
@@ -0,0 +1,33 @@
+<?php
+
+namespace Drupal\salesforce;
+
+/**
+ * OAuth user-agent plugin interface.
+ *
+ * OAuth user-agent flow requires a 2-part handshake to complete authentication.
+ * This interface exposes methods to make the handshake possible.
+ */
+interface SalesforceOAuthPluginInterface extends SalesforceAuthProviderPluginInterface {
+
+  /**
+   * Complete the OAuth user-agent handshake.
+   *
+   * @return bool
+   *   TRUE if oauth finalization was successful.
+   *
+   * @throws \OAuth\Common\Http\Exception\TokenResponseException
+   *
+   * @see \Drupal\salesforce\Controller\SalesforceOAuthController
+   */
+  public function finalizeOauth();
+
+  /**
+   * Getter for consumer secret.
+   *
+   * @return string
+   *   The consumer secret.
+   */
+  public function getConsumerSecret();
+
+}
diff --git a/src/Storage/SalesforceAuthTokenStorage.php b/src/Storage/SalesforceAuthTokenStorage.php
new file mode 100644
index 0000000000000000000000000000000000000000..beb74b730cfed230581882eb9626045d0865a463
--- /dev/null
+++ b/src/Storage/SalesforceAuthTokenStorage.php
@@ -0,0 +1,217 @@
+<?php
+
+namespace Drupal\salesforce\Storage;
+
+use Drupal\Core\State\StateInterface;
+use Drupal\salesforce\SalesforceAuthProviderPluginManager;
+use Drupal\salesforce\Token\SalesforceToken;
+use OAuth\Common\Storage\Exception\TokenNotFoundException;
+use OAuth\Common\Token\TokenInterface;
+
+/**
+ * Salesforce auth token storage.
+ */
+class SalesforceAuthTokenStorage implements SalesforceAuthTokenStorageInterface {
+
+  const TOKEN_STORAGE_PREFIX = "salesforce.auth_tokens";
+  const AUTH_STATE_STORAGE_PREFIX = "salesforce.auth_state";
+  const IDENTITY_STORAGE_PREFIX = "salesforce.auth_identity";
+
+  /**
+   * State kv storage.
+   *
+   * @var \Drupal\Core\State\StateInterface
+   */
+  protected $state;
+
+  /**
+   * SalesforceAuthTokenStorage constructor.
+   *
+   * @param \Drupal\Core\State\StateInterface $state
+   *   State service.
+   */
+  public function __construct(StateInterface $state) {
+    $this->state = $state;
+  }
+
+  /**
+   * Backwards-compatibility for legacy singleton auth.
+   *
+   * @return string
+   *   Id of the active oauth.
+   *
+   * @deprecated BC legacy auth scheme only, do not use, will be removed.
+   */
+  private function service() {
+    $oauth = SalesforceAuthProviderPluginManager::getAuthConfig();
+    return $oauth->id();
+  }
+
+  /**
+   * Backwards-compatibility for legacy singleton auth.
+   *
+   * @deprecated BC legacy auth scheme only, do not use, will be removed.
+   */
+  public function updateToken() {
+    $this->storeAccessToken($this->service(),
+      new SalesforceToken(
+        $this->state->get('salesforce.access_token'),
+        $this->state->get('salesforce.refresh_token')));
+    return $this;
+  }
+
+  /**
+   * Backwards-compatibility for legacy singleton auth.
+   *
+   * @deprecated BC legacy auth scheme only, do not use, will be removed.
+   */
+  public function updateIdentity() {
+    $this->storeIdentity($this->service(), $this->state->get('salesforce.identity'));
+    return $this;
+  }
+
+  /**
+   * Token storage key for given service.
+   *
+   * @return string
+   *   Token storage key for given service.
+   */
+  protected static function getTokenStorageId($service) {
+    return self::TOKEN_STORAGE_PREFIX . '.' . $service;
+  }
+
+  /**
+   * Auth state storage key for given service.
+   *
+   * @return string
+   *   Auth state storage key for given service.
+   */
+  protected static function getAuthStateStorageId($service) {
+    return self::AUTH_STATE_STORAGE_PREFIX . '.' . $service;
+  }
+
+  /**
+   * Identity storage key for given service.
+   *
+   * @return string
+   *   Identity storage key for given service.
+   */
+  protected static function getIdentityStorageId($service) {
+    return self::IDENTITY_STORAGE_PREFIX . '.' . $service;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function retrieveAccessToken($service) {
+    if ($token = $this->state->get(self::getTokenStorageId($service))) {
+      return $token;
+    }
+    throw new TokenNotFoundException();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function storeAccessToken($service, TokenInterface $token) {
+    $this->state->set(self::getTokenStorageId($service), $token);
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function hasAccessToken($service) {
+    try {
+      return !empty($this->retrieveAccessToken($service));
+    }
+    catch (TokenNotFoundException $e) {
+      return FALSE;
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function clearToken($service) {
+    $this->state->delete(self::getTokenStorageId($service));
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function clearAllTokens() {
+    // noop. We don't do this. Only here to satisfy interface.
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function storeAuthorizationState($service, $state) {
+    $this->state->set(self::getAuthStateStorageId($service), $state);
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function hasAuthorizationState($service) {
+    return !empty($this->retrieveAuthorizationState($service));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function retrieveAuthorizationState($service) {
+    return $this->state->get(self::getAuthStateStorageId($service));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function clearAuthorizationState($service) {
+    $this->state->delete(self::getAuthStateStorageId($service));
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function clearAllAuthorizationStates() {
+    // noop. only here to satisfy interface. Use clearAuthorizationState().
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function storeIdentity($service, $identity) {
+    $this->state->set(self::getIdentityStorageId($service), $identity);
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function hasIdentity($service) {
+    return !empty($this->retrieveIdentity($service));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function retrieveIdentity($service) {
+    return $this->state->get(self::getIdentityStorageId($service));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function clearIdentity($service) {
+    $this->state->delete(self::getIdentityStorageId($service));
+    return $this;
+  }
+
+}
diff --git a/src/Storage/SalesforceAuthTokenStorageInterface.php b/src/Storage/SalesforceAuthTokenStorageInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..1a5bd54223ad0b45fa180bfd3d4a4c214cb99a08
--- /dev/null
+++ b/src/Storage/SalesforceAuthTokenStorageInterface.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace Drupal\salesforce\Storage;
+
+use OAuth\Common\Storage\TokenStorageInterface;
+
+/**
+ * Add identity handling to token storage.
+ *
+ * @package Drupal\salesforce\Storage
+ */
+interface SalesforceAuthTokenStorageInterface extends TokenStorageInterface {
+
+  /**
+   * Setter for identity storage.
+   *
+   * @return $this
+   */
+  public function storeIdentity($service, $identity);
+
+  /**
+   * Return boolean indicating whether this service has an identity.
+   *
+   * @return bool
+   *   TRUE if the service has an identity.
+   */
+  public function hasIdentity($service);
+
+  /**
+   * Identity for the given service.
+   *
+   * @return array
+   *   Identity.
+   */
+  public function retrieveIdentity($service);
+
+  /**
+   * Clear identity for service.
+   *
+   * @return $this
+   */
+  public function clearIdentity($service);
+
+}
diff --git a/src/Token/SalesforceToken.php b/src/Token/SalesforceToken.php
new file mode 100644
index 0000000000000000000000000000000000000000..c3668eb4773872a26225f72cd8dd6d571b8bc8f7
--- /dev/null
+++ b/src/Token/SalesforceToken.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace Drupal\salesforce\Token;
+
+use OAuth\OAuth2\Token\StdOAuth2Token;
+
+/**
+ * Salesforce auth token.
+ */
+class SalesforceToken extends StdOAuth2Token {
+
+
+}