diff --git a/charts/controller/templates/_helpers.tpl b/charts/controller/templates/_helpers.tpl
index 1ee5df8e4..0182de3ce 100644
--- a/charts/controller/templates/_helpers.tpl
+++ b/charts/controller/templates/_helpers.tpl
@@ -82,8 +82,13 @@ env:
     secretKeyRef:
       name: database-creds
       key: password
+- name: DRYCC_DATABASE_NAME
+  valueFrom:
+    secretKeyRef:
+      name: database-creds
+      key: controller-database-name
 - name: DRYCC_DATABASE_URL
-  value: "postgres://$(DRYCC_DATABASE_USER):$(DRYCC_DATABASE_PASSWORD)@$(DRYCC_DATABASE_SERVICE_HOST):$(DRYCC_DATABASE_SERVICE_PORT)/$(DRYCC_DATABASE_USER)"
+  value: "postgres://$(DRYCC_DATABASE_USER):$(DRYCC_DATABASE_PASSWORD)@$(DRYCC_DATABASE_SERVICE_HOST):$(DRYCC_DATABASE_SERVICE_PORT)/$(DRYCC_DATABASE_NAME)"
 {{- end }}
 - name: WORKFLOW_NAMESPACE
   valueFrom:
@@ -149,6 +154,30 @@ env:
 - name: "DRYCC_RABBITMQ_URL"
   value: "amqp://$(DRYCC_RABBITMQ_USERNAME):$(DRYCC_RABBITMQ_PASSWORD)@drycc-rabbitmq-0.drycc-rabbitmq.{{$.Release.Namespace}}.svc.{{$.Values.global.cluster_domain}}:5672/drycc"
 {{- end }}
+{{- if eq .Values.global.passport_location "on-cluster"}}
+- name: "DRYCC_PASSPORT_DOMAIN"
+  value: drycc-passport.{{ .Values.global.platform_domain }}
+- name: "SOCIAL_AUTH_DRYCC_AUTHORIZATION_URL"
+  value: "$(DRYCC_PASSPORT_DOMAIN)/oauth/token/"
+- name: "SOCIAL_AUTH_DRYCC_ACCESS_API_URL"
+  value: "$(DRYCC_PASSPORT_DOMAIN)/users/"
+- name: "SOCIAL_AUTH_DRYCC_USERINFO_URL"
+  value: "$(DRYCC_PASSPORT_DOMAIN)/oauth/userinfo/"
+- name: "SOCIAL_AUTH_DRYCC_JWKS_URI"
+  value: "$(DRYCC_PASSPORT_DOMAIN)/oauth/.well-known/jwks.json"
+- name: "SOCIAL_AUTH_DRYCC_OIDC_ENDPOINT"
+  value: "$(DRYCC_PASSPORT_DOMAIN)/oauth"
+- name: SOCIAL_AUTH_DRYCC_CONTROLLER_KEY
+  valueFrom:
+    secretKeyRef:
+      name: passport-creds
+      key: social-auth-drycc-controller-key
+- name: SOCIAL_AUTH_DRYCC_CONTROLLER_SECRET
+  valueFrom:
+    secretKeyRef:
+      name: passport-creds
+      key: social-auth-drycc-controller-secret
+{{- end }}
 {{- range $key, $value := .Values.environment }}
 - name: {{ $key }}
   value: {{ $value | quote }}
@@ -168,4 +197,4 @@ resources:
     memory: {{.Values.limits_memory}}
 {{- end }}
 {{- end }}
-{{- end }}
\ No newline at end of file
+{{- end }}
diff --git a/charts/controller/templates/controller-cronjob-daily.yaml b/charts/controller/templates/controller-cronjob-daily.yaml
index 9bf1bbafa..e88cbcfc6 100644
--- a/charts/controller/templates/controller-cronjob-daily.yaml
+++ b/charts/controller/templates/controller-cronjob-daily.yaml
@@ -7,9 +7,10 @@ metadata:
   annotations:
     component.drycc.cc/version: {{ .Values.image_tag }}
 spec:
-  failedJobsHistoryLimit: 1
   schedule: "0 0 * * *"
-  successfulJobsHistoryLimit: 3
+  concurrencyPolicy: {{ .Values.concurrency_policy }}
+  successfulJobsHistoryLimit: 1
+  failedJobsHistoryLimit: 1
   jobTemplate:
     spec:
       template:
diff --git a/charts/controller/templates/controller-cronjob-hourly.yaml b/charts/controller/templates/controller-cronjob-hourly.yaml
new file mode 100644
index 000000000..dd0de7fb5
--- /dev/null
+++ b/charts/controller/templates/controller-cronjob-hourly.yaml
@@ -0,0 +1,31 @@
+apiVersion: batch/v1beta1
+kind: CronJob
+metadata:
+  name: drycc-controller-cronjob-daily
+  labels:
+    heritage: drycc
+  annotations:
+    component.drycc.cc/version: {{ .Values.image_tag }}
+spec:
+  schedule: "0 */1 * * *"
+  concurrencyPolicy: {{ .Values.concurrency_policy }}
+  successfulJobsHistoryLimit: 1
+  failedJobsHistoryLimit: 1
+  jobTemplate:
+    spec:
+      template:
+        spec:
+          restartPolicy: OnFailure
+          serviceAccount: drycc-controller
+          containers:
+          - image: {{.Values.image_registry}}/{{.Values.org}}/controller:{{.Values.image_tag}}
+            imagePullPolicy: {{.Values.pull_policy}}
+            name: drycc-controller-measure-app
+            command:
+              - /bin/bash
+              - -c
+            args:
+              - python -u /app/manage.py measure_app
+            {{- include "controller.envs" . | indent 12 }}
+            {{- include "controller.volumeMounts" . | indent 12 }}
+          {{- include "controller.volumes" . | indent 10 }}
diff --git a/charts/controller/values.yaml b/charts/controller/values.yaml
index c2c2c49b1..be0473725 100644
--- a/charts/controller/values.yaml
+++ b/charts/controller/values.yaml
@@ -22,6 +22,11 @@ app_storage_class: ""
 replicas: 1
 # Set celery replicas
 celery_replicas: 1
+# Set cronjob concurrencyPolicy
+# Allow (default): The cron job allows concurrently running jobs
+# Forbid: The cron job does not allow concurrent runs; if it is time for a new job run and the previous job run hasn't finished yet, the cron job skips the new job run
+# Replace: If it is time for a new job run and the previous job run hasn't finished yet, the cron job replaces the currently running job run with a new job run
+concurrency_policy: "Replace"
 # Configuring this will no longer use the built-in database component
 database_url: ""
 
@@ -30,6 +35,16 @@ database_url: ""
 # this is usually a non required setting.
 environment:
   RESERVED_NAMES: "drycc, drycc-builder, drycc-monitor-grafana"
+##　Oauth settings. If passport_location is off-cluster, set the following environment variables
+#  LOGIN_REDIRECT_URL: ""
+#  SOCIAL_AUTH_DRYCC_AUTHORIZATION_URL: ""
+#  SOCIAL_AUTH_DRYCC_ACCESS_TOKEN_URL: ""
+#  SOCIAL_AUTH_DRYCC_ACCESS_API_URL: ""
+#  SOCIAL_AUTH_DRYCC_USERINFO_URL: ""
+#  SOCIAL_AUTH_DRYCC_JWKS_URI: ""
+#  SOCIAL_AUTH_DRYCC_OIDC_ENDPOINT: ""
+#  SOCIAL_AUTH_DRYCC_KEY: ""
+#  SOCIAL_AUTH_DRYCC_SECRET: ""
 
 redis:
   replicas: 1
@@ -79,3 +94,4 @@ global:
   platform_domain: ""
   # Whether cert_manager is enabled to automatically generate controller certificates
   cert_manager_enabled: "true"
+  passport_location: "on-cluster"
diff --git a/rootfs/Dockerfile b/rootfs/Dockerfile
index 2a6a894ed..87eee433d 100644
--- a/rootfs/Dockerfile
+++ b/rootfs/Dockerfile
@@ -9,7 +9,7 @@ RUN apk add --update --virtual .build-deps \
     cargo \
     libffi-dev \
     musl-dev \
-    openldap-dev \
+    openssl-dev \
   && pip3 install --disable-pip-version-check --no-cache-dir -r /app/requirements.txt \
   && runDeps="$( \
     scanelf --needed --nobanner --format '%n#p' --recursive /usr/local \
diff --git a/rootfs/api/apps_extra/__init__.py b/rootfs/api/apps_extra/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/rootfs/api/apps_extra/social_core/__init__.py b/rootfs/api/apps_extra/social_core/__init__.py
new file mode 100644
index 000000000..fa721b497
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/__init__.py
@@ -0,0 +1 @@
+__version__ = '4.1.0'
diff --git a/rootfs/api/apps_extra/social_core/actions.py b/rootfs/api/apps_extra/social_core/actions.py
new file mode 100644
index 000000000..02f52f741
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/actions.py
@@ -0,0 +1,154 @@
+from urllib.parse import quote
+
+from .utils import sanitize_redirect, user_is_authenticated, \
+                   user_is_active, partial_pipeline_data, setting_url
+
+
+def do_auth(backend, redirect_name='next'):
+    # Save any defined next value into session
+    data = backend.strategy.request_data(merge=False)
+
+    # Save extra data into session.
+    for field_name in backend.setting('FIELDS_STORED_IN_SESSION', []):
+        if field_name in data:
+            backend.strategy.session_set(field_name, data[field_name])
+        else:
+            backend.strategy.session_set(field_name, None)
+    uri = None
+    if redirect_name in data:
+        # Check and sanitize a user-defined GET/POST next field value
+        redirect_uri = data[redirect_name]
+        if backend.setting('SANITIZE_REDIRECTS', True):
+            allowed_hosts = backend.setting('ALLOWED_REDIRECT_HOSTS', []) + \
+                            [backend.strategy.request_host()]
+            redirect_uri = sanitize_redirect(allowed_hosts, redirect_uri)
+        backend.strategy.session_set(
+            redirect_name,
+            redirect_uri or backend.setting('LOGIN_REDIRECT_URL')
+        )
+    response =  backend.start()
+    url = response.url.split('?')[1]
+    def form2json(form_data):
+        from urllib.parse import parse_qs, urlparse
+        query = urlparse('?' + form_data).query
+        params = parse_qs(query)
+        return {key: params[key][0] for key in params}
+    from django.core.cache import cache
+    cache.set("oidc_key_" + data.get('key'), form2json(url).get('state'), 60 * 10)
+    return response
+
+
+def do_complete(backend, login, user=None, redirect_name='next',
+                *args, **kwargs):
+    data = backend.strategy.request_data()
+
+    is_authenticated = user_is_authenticated(user)
+    user = user if is_authenticated else None
+
+    partial = partial_pipeline_data(backend, user, *args, **kwargs)
+    if partial:
+        user = backend.continue_pipeline(partial)
+        # clean partial data after usage
+        backend.strategy.clean_partial_pipeline(partial.token)
+    else:
+        user = backend.complete(user=user, *args, **kwargs)
+
+    # pop redirect value before the session is trashed on login(), but after
+    # the pipeline so that the pipeline can change the redirect if needed
+    redirect_value = backend.strategy.session_get(redirect_name, '') or \
+        data.get(redirect_name, '')
+
+    # check if the output value is something else than a user and just
+    # return it to the client
+    user_model = backend.strategy.storage.user.user_model()
+    if user and not isinstance(user, user_model):
+        return user
+
+    if is_authenticated:
+        if not user:
+            url = setting_url(backend, redirect_value, 'LOGIN_REDIRECT_URL')
+        else:
+            url = setting_url(backend, redirect_value,
+                              'NEW_ASSOCIATION_REDIRECT_URL',
+                              'LOGIN_REDIRECT_URL')
+    elif user:
+        if user_is_active(user):
+            # catch is_new/social_user in case login() resets the instance
+            is_new = getattr(user, 'is_new', False)
+            social_user = user.social_user
+            login(backend, user, social_user)
+            # store last login backend name in session
+            backend.strategy.session_set('social_auth_last_login_backend',
+                                         social_user.provider)
+
+            if is_new:
+                url = setting_url(backend,
+                                  'NEW_USER_REDIRECT_URL',
+                                  redirect_value,
+                                  'LOGIN_REDIRECT_URL')
+            else:
+                url = setting_url(backend, redirect_value,
+                                  'LOGIN_REDIRECT_URL')
+        else:
+            if backend.setting('INACTIVE_USER_LOGIN', False):
+                social_user = user.social_user
+                login(backend, user, social_user)
+            url = setting_url(backend, 'INACTIVE_USER_URL', 'LOGIN_ERROR_URL',
+                              'LOGIN_URL')
+    else:
+        url = setting_url(backend, 'LOGIN_ERROR_URL', 'LOGIN_URL')
+
+    if redirect_value and redirect_value != url:
+        redirect_value = quote(redirect_value)
+        url += ('&' if '?' in url else '?') + \
+            '{0}={1}'.format(redirect_name, redirect_value)
+
+    if backend.setting('SANITIZE_REDIRECTS', True):
+        allowed_hosts = backend.setting('ALLOWED_REDIRECT_HOSTS', []) + \
+                        [backend.strategy.request_host()]
+        url = sanitize_redirect(allowed_hosts, url) or \
+            backend.setting('LOGIN_REDIRECT_URL')
+    response = backend.strategy.redirect(url)
+    social_auth = user.social_auth.filter(provider='drycc').\
+            order_by('-modified').last()
+    response.set_cookie("name", user.username,
+                        max_age=social_auth.extra_data.get('expires_in'))
+    response.set_cookie("id_token", social_auth.extra_data.get('id_token'),
+                        max_age=social_auth.extra_data.get('expires_in'))
+    from django.core.cache import cache
+    cache.set("oidc_state_" + data.get('state'),
+              {'token':social_auth.extra_data.get('id_token', 'fail'),
+               'username': user.username},
+              60 * 10)
+    return response
+
+
+def do_disconnect(backend, user, association_id=None, redirect_name='next',
+                  *args, **kwargs):
+    partial = partial_pipeline_data(backend, user, *args, **kwargs)
+    if partial:
+        if association_id and not partial.kwargs.get('association_id'):
+            partial.extend_kwargs({
+                'association_id': association_id
+            })
+        response = backend.disconnect(*partial.args, **partial.kwargs)
+        # clean partial data after usage
+        backend.strategy.clean_partial_pipeline(partial.token)
+    else:
+        response = backend.disconnect(user=user, association_id=association_id,
+                                      *args, **kwargs)
+
+    if isinstance(response, dict):
+        url = backend.strategy.absolute_uri(
+            backend.strategy.request_data().get(redirect_name, '') or
+            backend.setting('DISCONNECT_REDIRECT_URL') or
+            backend.setting('LOGIN_REDIRECT_URL')
+        )
+        if backend.setting('SANITIZE_REDIRECTS', True):
+            allowed_hosts = backend.setting('ALLOWED_REDIRECT_HOSTS', []) + \
+                            [backend.strategy.request_host()]
+            url = sanitize_redirect(allowed_hosts, url) or \
+                backend.setting('DISCONNECT_REDIRECT_URL') or \
+                backend.setting('LOGIN_REDIRECT_URL')
+        response = backend.strategy.redirect(url)
+    return response
diff --git a/rootfs/api/apps_extra/social_core/backends/__init__.py b/rootfs/api/apps_extra/social_core/backends/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/rootfs/api/apps_extra/social_core/backends/amazon.py b/rootfs/api/apps_extra/social_core/backends/amazon.py
new file mode 100644
index 000000000..55335cccf
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/amazon.py
@@ -0,0 +1,45 @@
+"""
+Amazon OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/amazon.html
+"""
+import ssl
+
+from .oauth import BaseOAuth2
+
+
+class AmazonOAuth2(BaseOAuth2):
+    name = 'amazon'
+    ID_KEY = 'user_id'
+    AUTHORIZATION_URL = 'https://www.amazon.com/ap/oa'
+    ACCESS_TOKEN_URL = 'https://api.amazon.com/auth/o2/token'
+    DEFAULT_SCOPE = ['profile']
+    REDIRECT_STATE = False
+    ACCESS_TOKEN_METHOD = 'POST'
+    SSL_PROTOCOL = ssl.PROTOCOL_TLSv1
+    EXTRA_DATA = [
+        ('refresh_token', 'refresh_token', True),
+        ('user_id', 'user_id'),
+        ('postal_code', 'postal_code')
+    ]
+
+    def get_user_details(self, response):
+        """Return user details from amazon account"""
+        name = response.get('name') or ''
+        fullname, first_name, last_name = self.get_user_names(name)
+        return {'username': name,
+                'email': response.get('email'),
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Grab user profile information from amazon."""
+        response = self.get_json('https://api.amazon.com/user/profile',
+                                 params={'access_token': access_token})
+        if 'Profile' in response:
+            response = {
+                'user_id': response['Profile']['CustomerId'],
+                'name': response['Profile']['Name'],
+                'email': response['Profile']['PrimaryEmail']
+            }
+        return response
diff --git a/rootfs/api/apps_extra/social_core/backends/angel.py b/rootfs/api/apps_extra/social_core/backends/angel.py
new file mode 100644
index 000000000..4d7f51bbd
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/angel.py
@@ -0,0 +1,30 @@
+"""
+Angel OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/angel.html
+"""
+from .oauth import BaseOAuth2
+
+
+class AngelOAuth2(BaseOAuth2):
+    name = 'angel'
+    AUTHORIZATION_URL = 'https://angel.co/api/oauth/authorize/'
+    ACCESS_TOKEN_METHOD = 'POST'
+    ACCESS_TOKEN_URL = 'https://angel.co/api/oauth/token/'
+    REDIRECT_STATE = False
+
+    def get_user_details(self, response):
+        """Return user details from Angel account"""
+        username = response['angellist_url'].split('/')[-1]
+        email = response.get('email', '')
+        fullname, first_name, last_name = self.get_user_names(response['name'])
+        return {'username': username,
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name,
+                'email': email}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        return self.get_json('https://api.angel.co/1/me/', params={
+            'access_token': access_token
+        })
diff --git a/rootfs/api/apps_extra/social_core/backends/aol.py b/rootfs/api/apps_extra/social_core/backends/aol.py
new file mode 100644
index 000000000..fb427a465
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/aol.py
@@ -0,0 +1,10 @@
+"""
+AOL OpenId backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/aol.html
+"""
+from .open_id import OpenIdAuth
+
+
+class AOLOpenId(OpenIdAuth):
+    name = 'aol'
+    URL = 'http://openid.aol.com'
diff --git a/rootfs/api/apps_extra/social_core/backends/apple.py b/rootfs/api/apps_extra/social_core/backends/apple.py
new file mode 100644
index 000000000..edfab3158
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/apple.py
@@ -0,0 +1,168 @@
+"""
+Sign In With Apple authentication backend.
+
+Docs:
+    * https://developer.apple.com/documentation/signinwithapplerestapi
+    * https://developer.apple.com/documentation/signinwithapplerestapi/tokenresponse
+
+Settings:
+    * `TEAM` - your team id;
+    * `KEY` - your key id;
+    * `CLIENT` - your client id;
+    * `AUDIENCE` - a list of authorized client IDs, defaults to [CLIENT].
+                   Use this if you need to accept both service and bundle id to
+                   be able to login both via iOS and ie a web form.
+    * `SECRET` - your secret key;
+    * `SCOPE` (optional) - e.g. `['name', 'email']`;
+    * `EMAIL_AS_USERNAME` - use apple email is username is set, use apple id
+                            otherwise.
+    * `AppleIdAuth.TOKEN_TTL_SEC` - time before JWT token expiration, seconds.
+    * `SOCIAL_AUTH_APPLE_ID_INACTIVE_USER_LOGIN` - allow inactive users email to
+                                                   login
+"""
+
+import json
+import time
+
+import jwt
+from jwt.algorithms import RSAAlgorithm
+from jwt.exceptions import PyJWTError
+
+from social_core.backends.oauth import BaseOAuth2
+from social_core.exceptions import AuthFailed
+
+
+class AppleIdAuth(BaseOAuth2):
+    name = 'apple-id'
+
+    JWK_URL = 'https://appleid.apple.com/auth/keys'
+    AUTHORIZATION_URL = 'https://appleid.apple.com/auth/authorize'
+    ACCESS_TOKEN_URL = 'https://appleid.apple.com/auth/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    RESPONSE_MODE = None
+
+    ID_KEY = 'sub'
+    TOKEN_KEY = 'id_token'
+    STATE_PARAMETER = True
+    REDIRECT_STATE = False
+    SCOPE_SEPARATOR = '%20'
+
+    TOKEN_AUDIENCE = 'https://appleid.apple.com'
+    TOKEN_TTL_SEC = 6 * 30 * 24 * 60 * 60
+
+    def get_audience(self):
+        client_id = self.setting('CLIENT')
+        return self.setting('AUDIENCE', default=[client_id])
+
+    def auth_params(self, *args, **kwargs):
+        """
+        Apple requires to set `response_mode` to `form_post` if `scope`
+        parameter is passed.
+        """
+        params = super().auth_params(*args, **kwargs)
+        if self.RESPONSE_MODE:
+            params['response_mode'] = self.RESPONSE_MODE
+        elif self.get_scope():
+            params['response_mode'] = 'form_post'
+        return params
+
+    def get_private_key(self):
+        """
+        Return contents of the private key file. Override this method to provide
+        secret key from another source if needed.
+        """
+        return self.setting('SECRET')
+
+    def generate_client_secret(self):
+        now = int(time.time())
+        client_id = self.setting('CLIENT')
+        team_id = self.setting('TEAM')
+        key_id = self.setting('KEY')
+        private_key = self.get_private_key()
+
+        headers = {'kid': key_id}
+        payload = {
+            'iss': team_id,
+            'iat': now,
+            'exp': now + self.TOKEN_TTL_SEC,
+            'aud': self.TOKEN_AUDIENCE,
+            'sub': client_id,
+        }
+
+        return jwt.encode(payload, key=private_key, algorithm='ES256',
+                          headers=headers)
+
+    def get_key_and_secret(self):
+        client_id = self.setting('CLIENT')
+        client_secret = self.generate_client_secret()
+        return client_id, client_secret
+
+    def get_apple_jwk(self, kid=None):
+        """
+        Return requested Apple public key or all available.
+        """
+        keys = self.get_json(url=self.JWK_URL).get('keys')
+
+        if not isinstance(keys, list) or not keys:
+            raise AuthFailed(self, 'Invalid jwk response')
+
+        if kid:
+            return json.dumps([key for key in keys if key['kid'] == kid][0])
+        else:
+            return (json.dumps(key) for key in keys)
+
+    def decode_id_token(self, id_token):
+        """
+        Decode and validate JWT token from apple and return payload including
+        user data.
+        """
+        if not id_token:
+            raise AuthFailed(self, 'Missing id_token parameter')
+
+        try:
+            kid = jwt.get_unverified_header(id_token).get('kid')
+            public_key = RSAAlgorithm.from_jwk(self.get_apple_jwk(kid))
+            decoded = jwt.decode(
+                id_token,
+                key=public_key,
+                audience=self.get_audience(),
+                algorithms=['RS256'],
+            )
+        except PyJWTError:
+            raise AuthFailed(self, 'Token validation failed')
+
+        return decoded
+
+    def get_user_details(self, response):
+        name = json.loads(self.data.get('user', '{}')).get('name', {})
+        fullname, first_name, last_name = self.get_user_names(
+            fullname='',
+            first_name=name.get('firstName', ''),
+            last_name=name.get('lastName', '')
+        )
+
+        email = response.get('email', '')
+        apple_id = response.get(self.ID_KEY, '')
+        # prevent updating User with empty strings
+        user_details = {
+            'fullname': fullname or None,
+            'first_name': first_name or None,
+            'last_name': last_name or None,
+            'email': email,
+        }
+        if email and self.setting('EMAIL_AS_USERNAME'):
+            user_details['username'] = email
+        if apple_id and not self.setting('EMAIL_AS_USERNAME'):
+            user_details['username'] = apple_id
+
+        return user_details
+
+    def do_auth(self, access_token, *args, **kwargs):
+        response = kwargs.pop('response', None) or {}
+        jwt_string = response.get(self.TOKEN_KEY) or access_token
+
+        if not jwt_string:
+            raise AuthFailed(self, 'Missing id_token parameter')
+
+        decoded_data = self.decode_id_token(jwt_string)
+        return super().do_auth(access_token, response=decoded_data, *args, **kwargs)
diff --git a/rootfs/api/apps_extra/social_core/backends/appsfuel.py b/rootfs/api/apps_extra/social_core/backends/appsfuel.py
new file mode 100644
index 000000000..fe630e82c
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/appsfuel.py
@@ -0,0 +1,42 @@
+"""
+Appsfueld OAuth2 backend (with sandbox mode support), docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/appsfuel.html
+"""
+from .oauth import BaseOAuth2
+
+
+class AppsfuelOAuth2(BaseOAuth2):
+    name = 'appsfuel'
+    ID_KEY = 'user_id'
+    AUTHORIZATION_URL = 'http://app.appsfuel.com/content/permission'
+    ACCESS_TOKEN_URL = 'https://api.appsfuel.com/v1/live/oauth/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    USER_DETAILS_URL = 'https://api.appsfuel.com/v1/live/user'
+
+    def get_user_details(self, response):
+        """Return user details from Appsfuel account"""
+        email = response.get('email', '')
+        username = email.split('@')[0] if email else ''
+        fullname, first_name, last_name = self.get_user_names(
+            response.get('display_name', '')
+        )
+        return {
+            'username': username,
+            'fullname': fullname,
+            'first_name': first_name,
+            'last_name': last_name,
+            'email': email
+        }
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        return self.get_json(self.USER_DETAILS_URL, params={
+            'access_token': access_token
+        })
+
+
+class AppsfuelOAuth2Sandbox(AppsfuelOAuth2):
+    name = 'appsfuel-sandbox'
+    AUTHORIZATION_URL = 'https://api.appsfuel.com/v1/sandbox/choose'
+    ACCESS_TOKEN_URL = 'https://api.appsfuel.com/v1/sandbox/oauth/token'
+    USER_DETAILS_URL = 'https://api.appsfuel.com/v1/sandbox/user'
diff --git a/rootfs/api/apps_extra/social_core/backends/arcgis.py b/rootfs/api/apps_extra/social_core/backends/arcgis.py
new file mode 100644
index 000000000..fdd387649
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/arcgis.py
@@ -0,0 +1,33 @@
+"""
+ArcGIS OAuth2 backend
+"""
+from .oauth import BaseOAuth2
+
+
+class ArcGISOAuth2(BaseOAuth2):
+    name = 'arcgis'
+    ID_KEY = 'username'
+    AUTHORIZATION_URL = 'https://www.arcgis.com/sharing/rest/oauth2/authorize'
+    ACCESS_TOKEN_URL = 'https://www.arcgis.com/sharing/rest/oauth2/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    EXTRA_DATA = [
+        ('expires_in', 'expires_in')
+    ]
+
+    def get_user_details(self, response):
+        """Return user details from ArcGIS account"""
+        return {'username': response.get('username'),
+                'email': response.get('email'),
+                'fullname': response.get('fullName'),
+                'first_name': response.get('firstName'),
+                'last_name': response.get('lastName')}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        return self.get_json(
+            'https://www.arcgis.com/sharing/rest/community/self',
+            params={
+                'token': access_token,
+                'f': 'json'
+            }
+        )
diff --git a/rootfs/api/apps_extra/social_core/backends/asana.py b/rootfs/api/apps_extra/social_core/backends/asana.py
new file mode 100644
index 000000000..d19fd140b
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/asana.py
@@ -0,0 +1,40 @@
+import datetime
+
+from .oauth import BaseOAuth2
+
+
+class AsanaOAuth2(BaseOAuth2):
+    name = 'asana'
+    AUTHORIZATION_URL = 'https://app.asana.com/-/oauth_authorize'
+    ACCESS_TOKEN_METHOD = 'POST'
+    ACCESS_TOKEN_URL = 'https://app.asana.com/-/oauth_token'
+    REFRESH_TOKEN_URL = 'https://app.asana.com/-/oauth_token'
+    REDIRECT_STATE = False
+    USER_DATA_URL = 'https://app.asana.com/api/1.0/users/me'
+    EXTRA_DATA = [
+        ('expires_in', 'expires'),
+        ('refresh_token', 'refresh_token'),
+        ('name', 'name'),
+    ]
+
+    def get_user_details(self, response):
+        data = response['data']
+        fullname, first_name, last_name = self.get_user_names(data['name'])
+        return {'email': data['email'],
+                'username': data['email'],
+                'fullname': fullname,
+                'last_name': last_name,
+                'first_name': first_name}
+
+    def user_data(self, access_token, *args, **kwargs):
+        return self.get_json(self.USER_DATA_URL, headers={
+            'Authorization': 'Bearer {}'.format(access_token)
+        })
+
+    def extra_data(self, user, uid, response, details=None, *args, **kwargs):
+        data = super().extra_data(user, uid, response, details)
+        if self.setting('ESTIMATE_EXPIRES_ON'):
+            expires_on = datetime.datetime.utcnow() + \
+                         datetime.timedelta(seconds=data['expires'])
+            data['expires_on'] = expires_on.isoformat()
+        return data
diff --git a/rootfs/api/apps_extra/social_core/backends/atlassian.py b/rootfs/api/apps_extra/social_core/backends/atlassian.py
new file mode 100644
index 000000000..f76969f96
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/atlassian.py
@@ -0,0 +1,37 @@
+from social_core.backends.oauth import BaseOAuth2
+
+
+class AtlassianOAuth2(BaseOAuth2):
+    name = 'atlassian'
+    AUTHORIZATION_URL = 'https://auth.atlassian.com/authorize'
+    ACCESS_TOKEN_METHOD = 'POST'
+    ACCESS_TOKEN_URL = 'https://auth.atlassian.com/oauth/token'
+    DEFAULT_SCOPE = ['read:jira-user', 'offline_access']
+    ID_KEY = 'accountId'
+    EXTRA_DATA = [
+        ('resources', 'resources'),
+        ('refresh_token', 'refresh_token'),
+        ('expires_in', 'expires_in'),
+    ]
+
+    def auth_params(self, state=None):
+        params = super().auth_params(state)
+        params.update({'audience': 'api.atlassian.com',
+                       'prompt': 'consent'})
+        return params
+
+    def get_user_details(self, response):
+        fullname, first_name, last_name = self.get_user_names(response['displayName'])
+        return {'username': response['name'],
+                'email': response['emailAddress'],
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
+
+    def user_data(self, access_token, *args, **kwargs):
+        resources = self.get_json('https://api.atlassian.com/oauth/token/accessible-resources',
+                                  headers={'Authorization': 'Bearer {}'.format(access_token)})
+        user_info = self.get_json('https://api.atlassian.com/ex/jira/{}/rest/api/2/myself'.format(resources[0]['id']),
+                                  headers={'Authorization': 'Bearer {}'.format(access_token)})
+        user_info['resources'] = resources
+        return user_info
diff --git a/rootfs/api/apps_extra/social_core/backends/auth0.py b/rootfs/api/apps_extra/social_core/backends/auth0.py
new file mode 100644
index 000000000..23a541623
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/auth0.py
@@ -0,0 +1,53 @@
+"""
+Auth0 implementation based on:
+https://auth0.com/docs/quickstart/webapp/django/01-login
+"""
+from jose import jwt
+
+from .oauth import BaseOAuth2
+
+
+class Auth0OAuth2(BaseOAuth2):
+    """Auth0 OAuth authentication backend"""
+    name = 'auth0'
+    SCOPE_SEPARATOR = ' '
+    ACCESS_TOKEN_METHOD = 'POST'
+    EXTRA_DATA = [
+        ('picture', 'picture')
+    ]
+
+    def api_path(self, path=''):
+        """Build API path for Auth0 domain"""
+        return 'https://{domain}/{path}'.format(domain=self.setting('DOMAIN'),
+                                                path=path)
+
+    def authorization_url(self):
+        return self.api_path('authorize')
+
+    def access_token_url(self):
+        return self.api_path('oauth/token')
+
+    def get_user_id(self, details, response):
+        """Return current user id."""
+        return details['user_id']
+
+    def get_user_details(self, response):
+        # Obtain JWT and the keys to validate the signature
+        id_token = response.get('id_token')
+        jwks = self.get_json(self.api_path('.well-known/jwks.json'))
+        issuer = self.api_path()
+        audience = self.setting('KEY')  # CLIENT_ID
+        payload = jwt.decode(id_token,
+                             jwks,
+                             algorithms=['RS256'],
+                             audience=audience,
+                             issuer=issuer)
+        fullname, first_name, last_name = self.get_user_names(payload['name'])
+        return {'username': payload['nickname'],
+                'email': payload['email'],
+                'email_verified': payload.get('email_verified', False),
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name,
+                'picture': payload['picture'],
+                'user_id': payload['sub']}
diff --git a/rootfs/api/apps_extra/social_core/backends/azuread.py b/rootfs/api/apps_extra/social_core/backends/azuread.py
new file mode 100644
index 000000000..3ca9908ec
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/azuread.py
@@ -0,0 +1,127 @@
+import time
+
+import jwt
+
+from ..exceptions import AuthTokenError
+from .oauth import BaseOAuth2
+
+"""
+Copyright (c) 2015 Microsoft Open Technologies, Inc.
+
+All rights reserved.
+
+MIT License
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+"""
+
+"""
+Azure AD OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/azuread.html
+"""
+
+
+class AzureADOAuth2(BaseOAuth2):
+    name = 'azuread-oauth2'
+    SCOPE_SEPARATOR = ' '
+    AUTHORIZATION_URL = \
+        'https://login.microsoftonline.com/common/oauth2/authorize'
+    ACCESS_TOKEN_URL = 'https://login.microsoftonline.com/common/oauth2/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    REDIRECT_STATE = False
+    DEFAULT_SCOPE = ['openid', 'profile', 'user_impersonation']
+    EXTRA_DATA = [
+        ('access_token', 'access_token'),
+        ('id_token', 'id_token'),
+        ('refresh_token', 'refresh_token'),
+        ('expires_in', 'expires'),
+        ('expires_on', 'expires_on'),
+        ('not_before', 'not_before'),
+        ('given_name', 'first_name'),
+        ('family_name', 'last_name'),
+        ('token_type', 'token_type')
+    ]
+
+    def get_user_id(self, details, response):
+        """Use upn as unique id"""
+        return response.get('upn')
+
+    def get_user_details(self, response):
+        """Return user details from Azure AD account"""
+        fullname, first_name, last_name = (
+            response.get('name', ''),
+            response.get('given_name', ''),
+            response.get('family_name', '')
+        )
+        return {'username': fullname,
+                'email': response.get('upn'),
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
+
+    def user_data(self, access_token, *args, **kwargs):
+        response = kwargs.get('response')
+        if response and response.get('id_token'):
+            id_token = response.get('id_token')
+        else:
+            id_token = access_token
+
+        try:
+            decoded_id_token = jwt.decode(id_token, options={
+                'verify_signature': False
+            })
+        except (jwt.DecodeError, jwt.ExpiredSignatureError) as de:
+            raise AuthTokenError(self, de)
+        return decoded_id_token
+
+    def auth_extra_arguments(self):
+        """Return extra arguments needed on auth process. The defaults can be
+        overridden by GET parameters."""
+        extra_arguments = super().auth_extra_arguments()
+        resource = self.setting('RESOURCE')
+        if resource:
+            extra_arguments.update({'resource': resource})
+        return extra_arguments
+
+    def extra_data(self, user, uid, response, details=None, *args, **kwargs):
+        """Return access_token and extra defined names to store in
+        extra_data field"""
+        data = super().extra_data(user, uid, response, details, *args, **kwargs)
+        data['resource'] = self.setting('RESOURCE')
+        return data
+
+    def refresh_token_params(self, token, *args, **kwargs):
+        return {
+            'client_id': self.setting('KEY'),
+            'client_secret': self.setting('SECRET'),
+            'refresh_token': token,
+            'grant_type': 'refresh_token',
+            'resource': self.setting('RESOURCE')
+        }
+
+    def get_auth_token(self, user_id):
+        """Return the access token for the given user, after ensuring that it
+        has not expired, or refreshing it if so."""
+        user = self.get_user(user_id=user_id)
+        access_token = user.social_user.access_token
+        expires_on = user.social_user.extra_data['expires_on']
+        if expires_on <= int(time.time()):
+            new_token_response = self.refresh_token(token=access_token)
+            access_token = new_token_response['access_token']
+        return access_token
diff --git a/rootfs/api/apps_extra/social_core/backends/azuread_b2c.py b/rootfs/api/apps_extra/social_core/backends/azuread_b2c.py
new file mode 100644
index 000000000..7662f41ed
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/azuread_b2c.py
@@ -0,0 +1,188 @@
+"""
+Copyright (c) 2018 Noderabbit Inc., d.b.a. Appsembler
+
+All rights reserved.
+
+MIT License
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+See https://nicksnettravels.builttoroam.com/post/2017/01/24/Verifying-Azure-Active-Directory-JWT-Tokens.aspx
+    for verifying JWT tokens.
+"""
+
+import json
+
+from cryptography.hazmat.primitives import serialization
+from jwt import DecodeError, ExpiredSignatureError, decode as jwt_decode, \
+    get_unverified_header
+
+try:
+    from jwt.algorithms import RSAAlgorithm
+except ImportError:
+    raise Exception(
+        # Python 3.3 is not supported because of compatibility in
+        # Cryptography package in Python3.3 You are welcome to patch
+        # and open a pull request.
+        'Cryptography library is required for this backend ' \
+        '(AzureADB2COAuth2) to work. Note that this backend is only ' \
+        'supported on Python 2 and Python 3.4+.'
+    )
+
+from ..exceptions import AuthException, AuthTokenError
+from .azuread import AzureADOAuth2
+
+
+class AzureADB2COAuth2(AzureADOAuth2):
+    name = 'azuread-b2c-oauth2'
+
+    BASE_URL = 'https://login.microsoftonline.com/{tenant_id}'
+    AUTHORIZATION_URL = '{base_url}/oauth2/v2.0/authorize'
+    OPENID_CONFIGURATION_URL = '{base_url}/v2.0/.well-known/openid-configuration?p={policy}'
+    ACCESS_TOKEN_URL = '{base_url}/oauth2/v2.0/token?p={policy}'
+    JWKS_URL = '{base_url}/discovery/v2.0/keys?p={policy}'
+    DEFAULT_SCOPE = ['openid', 'email']
+    EXTRA_DATA = [
+        ('access_token', 'access_token'),
+        ('id_token', 'id_token'),
+        ('refresh_token', 'refresh_token'),
+        ('id_token_expires_in', 'expires'),
+        ('exp', 'expires_on'),
+        ('not_before', 'not_before'),
+        ('given_name', 'first_name'),
+        ('family_name', 'last_name'),
+        ('tfp', 'policy'),
+        ('token_type', 'token_type')
+    ]
+
+    @property
+    def tenant_id(self):
+        return self.setting('TENANT_ID', 'common')
+
+    @property
+    def policy(self):
+        policy = self.setting('POLICY')
+        if not policy or not policy.lower().startswith('b2c_'):
+            raise AuthException('SOCIAL_AUTH_AZUREAD_B2C_OAUTH2_POLICY is '
+                                'required and should start with `b2c_`')
+        return policy
+
+    @property
+    def base_url(self):
+        return self.BASE_URL.format(tenant_id=self.tenant_id)
+
+    def openid_configuration_url(self):
+        return self.OPENID_CONFIGURATION_URL.format(base_url=self.base_url,
+                                                    policy=self.policy)
+
+    def authorization_url(self):
+        # Policy is required, but added later by `auth_extra_arguments()`
+        return self.AUTHORIZATION_URL.format(base_url=self.base_url)
+
+    def access_token_url(self):
+        return self.ACCESS_TOKEN_URL.format(base_url=self.base_url,
+                                            policy=self.policy)
+
+    def jwks_url(self):
+        return self.JWKS_URL.format(base_url=self.base_url,
+                                    policy=self.policy)
+
+    def request_access_token(self, *args, **kwargs):
+        """
+        This is probably a hack, but otherwise AzureADOAuth2 expects
+        `access_token`.
+
+        However, B2C backends provides `id_token`.
+        """
+        response = super().request_access_token(
+            *args,
+            **kwargs
+        )
+        if 'access_token' not in response:
+            response['access_token'] = response['id_token']
+        return response
+
+    def auth_extra_arguments(self):
+        """
+        Return extra arguments needed on auth process.
+
+        The defaults can be overridden by GET parameters.
+        """
+        extra_arguments = super().auth_extra_arguments()
+        extra_arguments['p'] = self.policy or self.data.get('p')
+        return extra_arguments
+
+    def jwt_key_to_pem(self, key_json_dict):
+        """
+        Builds a PEM formatted key string from a JWT public key dict.
+        """
+        pub_key = RSAAlgorithm.from_jwk(json.dumps(key_json_dict))
+        return pub_key.public_bytes(
+            encoding=serialization.Encoding.PEM,
+            format=serialization.PublicFormat.SubjectPublicKeyInfo
+        )
+
+    def get_user_id(self, details, response):
+        """Use subject (sub) claim as unique id."""
+        return response.get('sub')
+
+    def get_user_details(self, response):
+        """
+        Email address is returned on a different attribute for AzureAD
+        B2C backends.
+        """
+        details = super().get_user_details(response)
+        if not details['email'] and response.get('emails'):
+            details['email'] = response['emails']
+        if isinstance(details.get('email'), (list, tuple)):
+            details['email'] = details['email'][0]
+        return details
+
+    def get_public_key(self, kid):
+        """
+        Retrieve JWT keys from the URL.
+        """
+        resp = self.request(self.jwks_url(), method='GET')
+        resp.raise_for_status()
+
+        # find the proper key for the kid
+        for key in resp.json()['keys']:
+            if key['kid'] == kid:
+                return self.jwt_key_to_pem(key)
+        raise DecodeError('Cannot find kid={}'.format(kid))
+
+    def user_data(self, access_token, *args, **kwargs):
+        response = kwargs.get('response')
+
+        id_token = response.get('id_token')
+
+        # `kid` is short for key id
+        kid = get_unverified_header(id_token)['kid']
+        key = self.get_public_key(kid)
+
+        try:
+            return jwt_decode(
+                id_token,
+                key=key,
+                algorithms=['RS256'],
+                audience=self.setting('KEY'),
+                leeway=self.setting('JWT_LEEWAY', default=0),
+            )
+        except (DecodeError, ExpiredSignatureError) as error:
+            raise AuthTokenError(self, error)
diff --git a/rootfs/api/apps_extra/social_core/backends/azuread_tenant.py b/rootfs/api/apps_extra/social_core/backends/azuread_tenant.py
new file mode 100644
index 000000000..53b6501ce
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/azuread_tenant.py
@@ -0,0 +1,136 @@
+import base64
+
+from cryptography.x509 import load_der_x509_certificate
+from cryptography.hazmat.backends import default_backend
+from jwt import DecodeError, ExpiredSignatureError, decode as jwt_decode, \
+    get_unverified_header
+
+from ..exceptions import AuthTokenError
+from .azuread import AzureADOAuth2
+
+"""
+Copyright (c) 2015 Microsoft Open Technologies, Inc.
+
+All rights reserved.
+
+MIT License
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+"""
+
+"""
+Azure AD OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/azuread.html
+
+See https://nicksnettravels.builttoroam.com/post/2017/01/24/Verifying-Azure-Active-Directory-JWT-Tokens.aspx
+for verifying JWT tokens.
+"""
+
+
+class AzureADTenantOAuth2(AzureADOAuth2):
+    name = 'azuread-tenant-oauth2'
+    OPENID_CONFIGURATION_URL = \
+        'https://login.microsoftonline.com/{tenant_id}/.well-known/openid-configuration'
+    AUTHORIZATION_URL = \
+        'https://login.microsoftonline.com/{tenant_id}/oauth2/authorize'
+    ACCESS_TOKEN_URL = 'https://login.microsoftonline.com/{tenant_id}/oauth2/token'
+    JWKS_URL = 'https://login.microsoftonline.com/{tenant_id}/discovery/keys'
+
+    @property
+    def tenant_id(self):
+        return self.setting('TENANT_ID', 'common')
+
+    def openid_configuration_url(self):
+        return self.OPENID_CONFIGURATION_URL.format(tenant_id=self.tenant_id)
+
+    def authorization_url(self):
+        return self.AUTHORIZATION_URL.format(tenant_id=self.tenant_id)
+
+    def access_token_url(self):
+        return self.ACCESS_TOKEN_URL.format(tenant_id=self.tenant_id)
+
+    def jwks_url(self):
+        return self.JWKS_URL.format(tenant_id=self.tenant_id)
+
+    def get_certificate(self, kid):
+        # retrieve keys from jwks_url
+        resp = self.request(self.jwks_url(), method='GET')
+        resp.raise_for_status()
+
+        # find the proper key for the kid
+        for key in resp.json()['keys']:
+            if key['kid'] == kid:
+                x5c = key['x5c'][0]
+                break
+        else:
+            raise DecodeError('Cannot find kid={}'.format(kid))
+
+        return load_der_x509_certificate(base64.b64decode(x5c),
+                                         default_backend())
+
+    def get_user_id(self, details, response):
+        """Use subject (sub) claim as unique id."""
+        return response.get('sub')
+
+    def user_data(self, access_token, *args, **kwargs):
+        response = kwargs.get('response')
+        id_token = response.get('id_token')
+
+        # get key id and algorithm
+        key_id = get_unverified_header(id_token)['kid']
+
+        try:
+            # retrieve certificate for key_id
+            certificate = self.get_certificate(key_id)
+
+            return jwt_decode(
+                id_token,
+                key=certificate.public_key(),
+                algorithms=['RS256'],
+                audience=self.setting('KEY')
+            )
+        except (DecodeError, ExpiredSignatureError) as error:
+            raise AuthTokenError(self, error)
+
+
+class AzureADV2TenantOAuth2(AzureADTenantOAuth2):
+    name = 'azuread-v2-tenant-oauth2'
+    OPENID_CONFIGURATION_URL = \
+        'https://login.microsoftonline.com/{tenant_id}/v2.0/.well-known/openid-configuration'
+    AUTHORIZATION_URL = 'https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/authorize'
+    ACCESS_TOKEN_URL = 'https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token'
+    JWKS_URL = 'https://login.microsoftonline.com/{tenant_id}/discovery/v2.0/keys'
+    DEFAULT_SCOPE = ['openid', 'profile', 'offline_access']
+
+    def get_user_id(self, details, response):
+        """Use upn as unique id"""
+        return response.get('preferred_username')
+
+    def get_user_details(self, response):
+        """Return user details from Azure AD account"""
+        fullname, first_name, last_name = (
+            response.get('name', ''),
+            response.get('given_name', ''),
+            response.get('family_name', '')
+        )
+        return {'username': fullname,
+                'email': response.get('preferred_username'),
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
diff --git a/rootfs/api/apps_extra/social_core/backends/base.py b/rootfs/api/apps_extra/social_core/backends/base.py
new file mode 100644
index 000000000..6bc119eb1
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/base.py
@@ -0,0 +1,251 @@
+import time
+
+from requests import request, ConnectionError
+
+from ..utils import SSLHttpAdapter, module_member, parse_qs, user_agent
+from ..exceptions import AuthFailed
+
+
+class BaseAuth:
+    """A authentication backend that authenticates the user based on
+    the provider response"""
+    name = ''  # provider name, it's stored in database
+    supports_inactive_user = False  # Django auth
+    ID_KEY = None
+    EXTRA_DATA = None
+    GET_ALL_EXTRA_DATA = False
+    REQUIRES_EMAIL_VALIDATION = False
+    SEND_USER_AGENT = False
+    SSL_PROTOCOL = None
+
+    def __init__(self, strategy, redirect_uri=None):
+        self.strategy = strategy
+        self.redirect_uri = redirect_uri
+        self.data = self.strategy.request_data()
+        self.redirect_uri = self.strategy.absolute_uri(
+            self.redirect_uri
+        )
+
+    def setting(self, name, default=None):
+        """Return setting value from strategy"""
+        return self.strategy.setting(name, default=default, backend=self)
+
+    def start(self):
+        if self.uses_redirect():
+            return self.strategy.redirect(self.auth_url())
+        else:
+            return self.strategy.html(self.auth_html())
+
+    def complete(self, *args, **kwargs):
+        return self.auth_complete(*args, **kwargs)
+
+    def auth_url(self):
+        """Must return redirect URL to auth provider"""
+        raise NotImplementedError('Implement in subclass')
+
+    def auth_html(self):
+        """Must return login HTML content returned by provider"""
+        raise NotImplementedError('Implement in subclass')
+
+    def auth_complete(self, *args, **kwargs):
+        """Completes login process, must return user instance"""
+        raise NotImplementedError('Implement in subclass')
+
+    def process_error(self, data):
+        """Process data for errors, raise exception if needed.
+        Call this method on any override of auth_complete."""
+        pass
+
+    def authenticate(self, *args, **kwargs):
+        """Authenticate user using social credentials
+
+        Authentication is made if this is the correct backend, backend
+        verification is made by kwargs inspection for current backend
+        name presence.
+        """
+        # Validate backend and arguments. Require that the Social Auth
+        # response be passed in as a keyword argument, to make sure we
+        # don't match the username/password calling conventions of
+        # authenticate.
+        if 'backend' not in kwargs or kwargs['backend'].name != self.name or \
+           'strategy' not in kwargs or 'response' not in kwargs:
+            return None
+
+        self.strategy = kwargs.get('strategy') or self.strategy
+        self.redirect_uri = kwargs.get('redirect_uri') or self.redirect_uri
+        self.data = self.strategy.request_data()
+        kwargs.setdefault('is_new', False)
+        pipeline = self.strategy.get_pipeline(self)
+        args, kwargs = self.strategy.clean_authenticate_args(*args, **kwargs)
+        return self.pipeline(pipeline, *args, **kwargs)
+
+    def pipeline(self, pipeline, pipeline_index=0, *args, **kwargs):
+        out = self.run_pipeline(pipeline, pipeline_index, *args, **kwargs)
+        if not isinstance(out, dict):
+            return out
+        user = out.get('user')
+        if user:
+            user.social_user = out.get('social')
+            user.is_new = out.get('is_new')
+        return user
+
+    def disconnect(self, *args, **kwargs):
+        pipeline = self.strategy.get_disconnect_pipeline(self)
+        kwargs['name'] = self.name
+        kwargs['user_storage'] = self.strategy.storage.user
+        return self.run_pipeline(pipeline, *args, **kwargs)
+
+    def run_pipeline(self, pipeline, pipeline_index=0, *args, **kwargs):
+        out = kwargs.copy()
+        out.setdefault('strategy', self.strategy)
+        out.setdefault('backend', out.pop(self.name, None) or self)
+        out.setdefault('request', self.strategy.request_data())
+        out.setdefault('details', {})
+
+        if not isinstance(pipeline_index, int) or \
+           pipeline_index < 0 or \
+           pipeline_index >= len(pipeline):
+            pipeline_index = 0
+
+        for idx, name in enumerate(pipeline[pipeline_index:]):
+            out['pipeline_index'] = pipeline_index + idx
+            func = module_member(name)
+            result = func(*args, **out) or {}
+            if not isinstance(result, dict):
+                return result
+            out.update(result)
+        return out
+
+    def extra_data(self, user, uid, response, details=None, *args, **kwargs):
+        """Return default extra data to store in extra_data field"""
+        data = {
+            # store the last time authentication toke place
+            'auth_time': int(time.time())
+        }
+        extra_data_entries = []
+        if self.GET_ALL_EXTRA_DATA or self.setting('GET_ALL_EXTRA_DATA', False):
+            extra_data_entries = response.keys()
+        else:
+            extra_data_entries = (self.EXTRA_DATA or []) + self.setting('EXTRA_DATA', [])
+        for entry in extra_data_entries:
+            if not isinstance(entry, (list, tuple)):
+                entry = (entry,)
+            size = len(entry)
+            if size >= 1 and size <= 3:
+                if size == 3:
+                    name, alias, discard = entry
+                elif size == 2:
+                    (name, alias), discard = entry, False
+                elif size == 1:
+                    name = alias = entry[0]
+                    discard = False
+                value = response.get(name) or details.get(name) or details.get(alias)
+                if discard and not value:
+                    continue
+                data[alias] = value
+        return data
+
+    def auth_allowed(self, response, details):
+        """Return True if the user should be allowed to authenticate, by
+        default check if email is whitelisted (if there's a whitelist)"""
+        emails = [email.lower() for email in self.setting('WHITELISTED_EMAILS', [])]
+        domains = [domain.lower() for domain in self.setting('WHITELISTED_DOMAINS', [])]
+        email = details.get('email')
+        allowed = True
+        if email and (emails or domains):
+            email = email.lower()
+            domain = email.split('@', 1)[1]
+            allowed = email in emails or domain in domains
+        return allowed
+
+    def get_user_id(self, details, response):
+        """Return a unique ID for the current user, by default from server
+        response."""
+        return response.get(self.ID_KEY)
+
+    def get_user_details(self, response):
+        """Must return user details in a know internal struct:
+            {'username': <username if any>,
+             'email': <user email if any>,
+             'fullname': <user full name if any>,
+             'first_name': <user first name if any>,
+             'last_name': <user last name if any>}
+        """
+        raise NotImplementedError('Implement in subclass')
+
+    def get_user_names(self, fullname='', first_name='', last_name=''):
+        # Avoid None values
+        fullname = fullname or ''
+        first_name = first_name or ''
+        last_name = last_name or ''
+        if fullname and not (first_name or last_name):
+            try:
+                first_name, last_name = fullname.split(' ', 1)
+            except ValueError:
+                first_name = first_name or fullname or ''
+                last_name = last_name or ''
+        fullname = fullname or ' '.join((first_name, last_name))
+        return fullname.strip(), first_name.strip(), last_name.strip()
+
+    def get_user(self, user_id):
+        """
+        Return user with given ID from the User model used by this backend.
+        This is called by django.contrib.auth.middleware.
+        """
+        return self.strategy.get_user(user_id)
+
+    def continue_pipeline(self, partial):
+        """Continue previous halted pipeline"""
+        return self.strategy.authenticate(self,
+                                          pipeline_index=partial.next_step,
+                                          *partial.args,
+                                          **partial.kwargs)
+
+    def auth_extra_arguments(self):
+        """Return extra arguments needed on auth process. The defaults can be
+        overridden by GET parameters."""
+        extra_arguments = self.setting('AUTH_EXTRA_ARGUMENTS', {}).copy()
+        extra_arguments.update((key, self.data[key]) for key in extra_arguments
+                               if key in self.data)
+        return extra_arguments
+
+    def uses_redirect(self):
+        """Return True if this provider uses redirect url method,
+        otherwise return false."""
+        return True
+
+    def request(self, url, method='GET', *args, **kwargs):
+        kwargs.setdefault('headers', {})
+        if self.setting('PROXIES') is not None:
+            kwargs.setdefault('proxies', self.setting('PROXIES'))
+
+        if self.setting('VERIFY_SSL') is not None:
+            kwargs.setdefault('verify', self.setting('VERIFY_SSL'))
+        kwargs.setdefault('timeout', self.setting('REQUESTS_TIMEOUT') or
+                          self.setting('URLOPEN_TIMEOUT'))
+        if self.SEND_USER_AGENT and 'User-Agent' not in kwargs['headers']:
+            kwargs['headers']['User-Agent'] = self.setting('USER_AGENT') or \
+                                              user_agent()
+
+        try:
+            if self.SSL_PROTOCOL:
+                session = SSLHttpAdapter.ssl_adapter_session(self.SSL_PROTOCOL)
+                response = session.request(method, url, *args, **kwargs)
+            else:
+                response = request(method, url, *args, **kwargs)
+        except ConnectionError as err:
+            raise AuthFailed(self, str(err))
+        response.raise_for_status()
+        return response
+
+    def get_json(self, url, *args, **kwargs):
+        return self.request(url, *args, **kwargs).json()
+
+    def get_querystring(self, url, *args, **kwargs):
+        return parse_qs(self.request(url, *args, **kwargs).text)
+
+    def get_key_and_secret(self):
+        """Return tuple with Consumer Key and Consumer Secret for current
+        service provider. Must return (key, secret), order *must* be respected.
+        """
+        return self.setting('KEY'), self.setting('SECRET')
diff --git a/rootfs/api/apps_extra/social_core/backends/battlenet.py b/rootfs/api/apps_extra/social_core/backends/battlenet.py
new file mode 100644
index 000000000..64511efc6
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/battlenet.py
@@ -0,0 +1,50 @@
+from .oauth import BaseOAuth2
+
+
+# This provides a backend for python-social-auth. This should not be confused
+# with officially battle.net offerings. This piece of code is not officially
+# affiliated with Blizzard Entertainment, copyrights to their respective
+# owners. See http://us.battle.net/en/forum/topic/13979588015 for more details.
+
+
+class BattleNetOAuth2(BaseOAuth2):
+    """ battle.net Oauth2 backend"""
+    name = 'battlenet-oauth2'
+    ID_KEY = 'accountId'
+    REDIRECT_STATE = False
+    AUTHORIZATION_URL = 'https://eu.battle.net/oauth/authorize'
+    ACCESS_TOKEN_URL = 'https://eu.battle.net/oauth/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    REVOKE_TOKEN_METHOD = 'GET'
+    DEFAULT_SCOPE = ['wow.profile']
+    EXTRA_DATA = [
+        ('refresh_token', 'refresh_token', True),
+        ('expires_in', 'expires'),
+        ('token_type', 'token_type', True)
+    ]
+
+    def get_characters(self, access_token):
+        """
+        Fetches the character list from the battle.net API. Returns list of
+        characters or empty list if the request fails.
+        """
+        params = {'access_token': access_token}
+        if self.setting('API_LOCALE'):
+            params['locale'] = self.setting('API_LOCALE')
+
+        response = self.get_json(
+            'https://eu.api.battle.net/wow/user/characters',
+            params=params
+        )
+        return response.get('characters') or []
+
+    def get_user_details(self, response):
+        """ Return user details from Battle.net account """
+        return {'battletag': response.get('battletag')}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """ Loads user data from service """
+        return self.get_json(
+            'https://eu.api.battle.net/account/user',
+            params={'access_token': access_token}
+        )
diff --git a/rootfs/api/apps_extra/social_core/backends/beats.py b/rootfs/api/apps_extra/social_core/backends/beats.py
new file mode 100644
index 000000000..4dda471ac
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/beats.py
@@ -0,0 +1,65 @@
+"""
+Beats backend, docs at:
+    https://developer.beatsmusic.com/docs
+"""
+import base64
+
+from ..utils import handle_http_errors
+from .oauth import BaseOAuth2
+
+
+class BeatsOAuth2(BaseOAuth2):
+    name = 'beats'
+    SCOPE_SEPARATOR = ' '
+    ID_KEY = 'user_context'
+    AUTHORIZATION_URL = \
+        'https://partner.api.beatsmusic.com/v1/oauth2/authorize'
+    ACCESS_TOKEN_URL = 'https://partner.api.beatsmusic.com/oauth2/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    REDIRECT_STATE = False
+
+    def get_user_id(self, details, response):
+        return response['result'][BeatsOAuth2.ID_KEY]
+
+    def auth_headers(self):
+        return {
+            'Authorization': 'Basic {0}'.format(base64.urlsafe_b64encode(
+                ('{0}:{1}'.format(*self.get_key_and_secret()).encode())
+            ))
+        }
+
+    @handle_http_errors
+    def auth_complete(self, *args, **kwargs):
+        """Completes login process, must return user instance"""
+        self.process_error(self.data)
+        response = self.request_access_token(
+            self.ACCESS_TOKEN_URL,
+            data=self.auth_complete_params(self.validate_state()),
+            headers=self.auth_headers(),
+            method=self.ACCESS_TOKEN_METHOD
+        )
+        self.process_error(response)
+        # mashery wraps in jsonrpc
+        if response.get('jsonrpc', None):
+            response = response.get('result', None)
+        return self.do_auth(response['access_token'], response=response,
+                            *args, **kwargs)
+
+    def get_user_details(self, response):
+        """Return user details from Beats account"""
+        response = response['result']
+        fullname, first_name, last_name = self.get_user_names(
+            response.get('display_name')
+        )
+        return {'username': response.get('id'),
+                'email': response.get('email'),
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        return self.get_json(
+            'https://partner.api.beatsmusic.com/v1/api/me',
+            headers={'Authorization': 'Bearer {0}'.format(access_token)}
+        )
diff --git a/rootfs/api/apps_extra/social_core/backends/behance.py b/rootfs/api/apps_extra/social_core/backends/behance.py
new file mode 100644
index 000000000..a41bc7dea
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/behance.py
@@ -0,0 +1,39 @@
+"""
+Behance OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/behance.html
+"""
+from .oauth import BaseOAuth2
+
+
+class BehanceOAuth2(BaseOAuth2):
+    """Behance OAuth authentication backend"""
+    name = 'behance'
+    AUTHORIZATION_URL = 'https://www.behance.net/v2/oauth/authenticate'
+    ACCESS_TOKEN_URL = 'https://www.behance.net/v2/oauth/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    SCOPE_SEPARATOR = '|'
+    EXTRA_DATA = [('username', 'username')]
+    REDIRECT_STATE = False
+
+    def get_user_id(self, details, response):
+        return response['user']['id']
+
+    def get_user_details(self, response):
+        """Return user details from Behance account"""
+        user = response['user']
+        fullname, first_name, last_name = self.get_user_names(
+            user['display_name'], user['first_name'], user['last_name']
+        )
+        return {'username': user['username'],
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name,
+                'email': ''}
+
+    def extra_data(self, user, uid, response, details=None, *args, **kwargs):
+        # Pull up the embedded user attributes so they can be found as extra
+        # data. See the example token response for possible attributes:
+        # http://www.behance.net/dev/authentication#step-by-step
+        data = response.copy()
+        data.update(response['user'])
+        return super().extra_data(user, uid, data, details, *args, **kwargs)
diff --git a/rootfs/api/apps_extra/social_core/backends/belgiumeid.py b/rootfs/api/apps_extra/social_core/backends/belgiumeid.py
new file mode 100644
index 000000000..b85a17ffd
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/belgiumeid.py
@@ -0,0 +1,11 @@
+"""
+Belgium EID OpenId backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/belgium_eid.html
+"""
+from .open_id import OpenIdAuth
+
+
+class BelgiumEIDOpenId(OpenIdAuth):
+    """Belgium e-ID OpenID authentication backend"""
+    name = 'belgiumeid'
+    URL = 'https://www.e-contract.be/eid-idp/endpoints/openid/auth'
diff --git a/rootfs/api/apps_extra/social_core/backends/bitbucket.py b/rootfs/api/apps_extra/social_core/backends/bitbucket.py
new file mode 100644
index 000000000..667cf77b0
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/bitbucket.py
@@ -0,0 +1,98 @@
+"""
+Bitbucket OAuth2 and OAuth1 backends, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/bitbucket.html
+"""
+from ..exceptions import AuthForbidden
+from .oauth import BaseOAuth1, BaseOAuth2
+
+
+class BitbucketOAuthBase:
+    ID_KEY = 'uuid'
+
+    def get_user_id(self, details, response):
+        id_key = self.ID_KEY
+        if self.setting('USERNAME_AS_ID', False):
+            id_key = 'username'
+        return response.get(id_key)
+
+    def get_user_details(self, response):
+        """Return user details from Bitbucket account"""
+        fullname, first_name, last_name = self.get_user_names(
+            response['display_name']
+        )
+
+        return {'username': response.get('username', ''),
+                'email': response.get('email', ''),
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Return user data provided"""
+        emails = self._get_emails(access_token)
+        email = None
+
+        for address in reversed(emails['values']):
+            email = address['email']
+            if address['is_primary']:
+                break
+
+        if self.setting('VERIFIED_EMAILS_ONLY', False) and \
+           not address['is_confirmed']:
+            raise AuthForbidden(
+                self,
+                'Bitbucket account has no verified email'
+            )
+
+        user = self._get_user(access_token)
+        if email:
+            user['email'] = email
+        return user
+
+    def _get_user(self, access_token=None):
+        raise NotImplementedError('Implement in subclass')
+
+    def _get_emails(self, access_token=None):
+        raise NotImplementedError('Implement in subclass')
+
+
+class BitbucketOAuth2(BitbucketOAuthBase, BaseOAuth2):
+    name = 'bitbucket-oauth2'
+    SCOPE_SEPARATOR = ' '
+    AUTHORIZATION_URL = 'https://bitbucket.org/site/oauth2/authorize'
+    ACCESS_TOKEN_URL = 'https://bitbucket.org/site/oauth2/access_token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    REDIRECT_STATE = False
+    EXTRA_DATA = [
+        ('scopes', 'scopes'),
+        ('expires_in', 'expires'),
+        ('token_type', 'token_type'),
+        ('refresh_token', 'refresh_token')
+    ]
+
+    def auth_complete_credentials(self):
+        return self.get_key_and_secret()
+
+    def _get_user(self, access_token=None):
+        return self.get_json('https://api.bitbucket.org/2.0/user',
+                             params={'access_token': access_token})
+
+    def _get_emails(self, access_token=None):
+        return self.get_json('https://api.bitbucket.org/2.0/user/emails',
+                             params={'access_token': access_token})
+
+
+class BitbucketOAuth(BitbucketOAuthBase, BaseOAuth1):
+    """Bitbucket OAuth authentication backend"""
+    name = 'bitbucket'
+    AUTHORIZATION_URL = 'https://bitbucket.org/api/1.0/oauth/authenticate'
+    REQUEST_TOKEN_URL = 'https://bitbucket.org/api/1.0/oauth/request_token'
+    ACCESS_TOKEN_URL = 'https://bitbucket.org/api/1.0/oauth/access_token'
+
+    def _get_user(self, access_token=None):
+        return self.get_json('https://api.bitbucket.org/2.0/user',
+                             auth=self.oauth_auth(access_token))
+
+    def _get_emails(self, access_token=None):
+        return self.get_json('https://api.bitbucket.org/2.0/user/emails',
+                             auth=self.oauth_auth(access_token))
diff --git a/rootfs/api/apps_extra/social_core/backends/box.py b/rootfs/api/apps_extra/social_core/backends/box.py
new file mode 100644
index 000000000..2ccbd45a4
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/box.py
@@ -0,0 +1,55 @@
+"""
+Box.net OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/box.html
+"""
+from .oauth import BaseOAuth2
+
+
+class BoxOAuth2(BaseOAuth2):
+    """Box.net OAuth authentication backend"""
+    name = 'box'
+    AUTHORIZATION_URL = 'https://www.box.com/api/oauth2/authorize'
+    ACCESS_TOKEN_METHOD = 'POST'
+    ACCESS_TOKEN_URL = 'https://www.box.com/api/oauth2/token'
+    REVOKE_TOKEN_URL = 'https://www.box.com/api/oauth2/revoke'
+    SCOPE_SEPARATOR = ','
+    EXTRA_DATA = [
+        ('refresh_token', 'refresh_token', True),
+        ('id', 'id'),
+        ('expires', 'expires'),
+    ]
+
+    def do_auth(self, access_token, response=None, *args, **kwargs):
+        response = response or {}
+        data = self.user_data(access_token)
+
+        data['access_token'] = response.get('access_token')
+        data['refresh_token'] = response.get('refresh_token')
+        data['expires'] = response.get('expires_in')
+        kwargs.update({'backend': self, 'response': data})
+        return self.strategy.authenticate(*args, **kwargs)
+
+    def get_user_details(self, response):
+        """Return user details Box.net account"""
+        fullname, first_name, last_name = self.get_user_names(
+            response.get('name')
+        )
+        return {'username': response.get('login'),
+                'email': response.get('login') or '',
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        params = self.setting('PROFILE_EXTRA_PARAMS', {})
+        params['access_token'] = access_token
+        return self.get_json('https://api.box.com/2.0/users/me',
+                             params=params)
+
+    def refresh_token(self, token, *args, **kwargs):
+        params = self.refresh_token_params(token, *args, **kwargs)
+        request = self.request(self.REFRESH_TOKEN_URL or self.ACCESS_TOKEN_URL,
+                               data=params, headers=self.auth_headers(),
+                               method='POST')
+        return self.process_refresh_token_response(request, *args, **kwargs)
diff --git a/rootfs/api/apps_extra/social_core/backends/bungie.py b/rootfs/api/apps_extra/social_core/backends/bungie.py
new file mode 100644
index 000000000..2cc8fea89
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/bungie.py
@@ -0,0 +1,84 @@
+"""
+Bungie OAuth2 backend
+"""
+from social_core.backends.oauth import BaseOAuth2
+
+
+class BungieOAuth2(BaseOAuth2):
+    name = 'bungie'
+    ID_KEY = 'membership_id'
+    AUTHORIZATION_URL = 'https://www.bungie.net/en/oauth/authorize/'
+    ACCESS_TOKEN_URL = 'https://www.bungie.net/platform/app/oauth/token/'
+    REFRESH_TOKEN_URL = 'https://www.bungie.net/platform/app/oauth/token/'
+    ACCESS_TOKEN_METHOD = 'POST'
+    REDIRECT_STATE = False
+    EXTRA_DATA = [
+        ('refresh_token', 'refresh_token', True),
+        ('access_token', 'access_token', True),
+        ('expires_in', 'expires'),
+        ('membership_id', 'membership_id'),
+        ('refresh_expires_in', 'refresh_expires_in')
+    ]
+
+    def auth_html(self):
+        """Abstract Method Inclusion"""
+        pass
+
+    def auth_headers(self):
+        """Adds X-API-KEY and Origin"""
+        return {
+            'X-API-KEY': self.setting('API_KEY'),
+            'Content-Type': 'application/x-www-form-urlencoded',
+            'Origin': self.setting('ORIGIN'),
+            'Accept': 'application/json'
+        }
+
+    def make_bungie_request(self, url, access_token, kwargs):
+        """Helper function to get username data keyed off displayName"""
+        headers = self.auth_headers()
+        headers['Authorization'] = 'Bearer ' + access_token
+        return self.get_json(url, headers=headers)
+
+    def auth_complete(self, *args, **kwargs):
+        """Completes login process, must return user instance"""
+        self.process_error(self.data)
+        state = self.validate_state()
+        response = self.request_access_token(
+            self.access_token_url(),
+            data=self.auth_complete_params(state),
+            headers=self.auth_headers(),
+            auth=self.auth_complete_credentials(),
+            method=self.ACCESS_TOKEN_METHOD
+        )
+        self.process_error(response)
+        return self.do_auth(response['access_token'],
+                            response=response,
+                            *args, **kwargs)
+
+    def do_auth(self, access_token, *args, **kwargs):
+        """Finish the auth process once the access_token was retrieved"""
+        data = self.user_data(access_token, *args, **kwargs)
+        response = kwargs.get('response') or {}
+        response.update(data or {})
+        if 'access_token' not in response:
+            response['Response']['access_token']['value'] = access_token
+        kwargs.update({'response': response, 'backend': self})
+        return self.strategy.authenticate(*args, **kwargs)
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Grab user profile information from Bunige"""
+        membership_id = kwargs['response']['membership_id']
+        url = 'https://www.bungie.net/Platform/User/GetBungieNetUser/'
+        response = self.make_bungie_request(url, access_token, kwargs)
+        username = response['Response']['user']['displayName']
+        return {'username': username,
+                'uid': membership_id}
+
+    def get_user_details(self, response, *args, **kwargs):
+        """Return user details from Bungie account"""
+        username = response['username']
+        return {
+            'first_name': username,
+            'username': username,
+            'uid': response['uid'],
+        }
diff --git a/rootfs/api/apps_extra/social_core/backends/changetip.py b/rootfs/api/apps_extra/social_core/backends/changetip.py
new file mode 100644
index 000000000..beb134f65
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/changetip.py
@@ -0,0 +1,27 @@
+from .oauth import BaseOAuth2
+
+
+class ChangeTipOAuth2(BaseOAuth2):
+    """ChangeTip OAuth authentication backend
+       https://www.changetip.com/api
+    """
+    name = 'changetip'
+    AUTHORIZATION_URL = 'https://www.changetip.com/o/authorize/'
+    ACCESS_TOKEN_URL = 'https://www.changetip.com/o/token/'
+    ACCESS_TOKEN_METHOD = 'POST'
+    SCOPE_SEPARATOR = ' '
+
+    def get_user_details(self, response):
+        """Return user details from ChangeTip account"""
+        return {
+            'username': response['username'],
+            'email': response.get('email', ''),
+            'first_name': '',
+            'last_name': '',
+        }
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        return self.get_json('https://api.changetip.com/v2/me/', params={
+            'access_token': access_token
+        })
diff --git a/rootfs/api/apps_extra/social_core/backends/chatwork.py b/rootfs/api/apps_extra/social_core/backends/chatwork.py
new file mode 100644
index 000000000..b69a627ed
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/chatwork.py
@@ -0,0 +1,67 @@
+"""
+Chatwork OAuth2 backend
+"""
+import base64
+
+from .oauth import BaseOAuth2
+
+
+class ChatworkOAuth2(BaseOAuth2):
+    """Chatwork OAuth authentication backend"""
+    name = 'chatwork'
+    API_URL = 'https://api.chatwork.com/v2'
+    AUTHORIZATION_URL = 'https://www.chatwork.com/packages/oauth2/login.php'
+    ACCESS_TOKEN_URL = 'https://oauth.chatwork.com/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    REDIRECT_STATE = True
+    DEFAULT_SCOPE = ['users.profile.me:read']
+    ID_KEY = 'account_id'
+    EXTRA_DATA = [
+        ('expires_in', 'expires'),
+        ('refresh_token', 'refresh_token')
+    ]
+
+    def api_url(self, path):
+        api_url = self.setting('API_URL') or self.API_URL
+        return '{0}{1}'.format(api_url.rstrip('/'), path)
+
+    def auth_headers(self):
+        return {
+            'Authorization': b'Basic ' + base64.b64encode(
+                '{0}:{1}'.format(*self.get_key_and_secret()).encode()
+            )
+        }
+
+    def auth_complete_params(self, state=None):
+        return {
+            'grant_type': 'authorization_code',
+            'code': self.data.get('code', ''),
+            'redirect_uri': self.get_redirect_uri(state)
+        }
+
+    def get_user_details(self, response):
+        """Return user details from Chatwork account"""
+        fullname, first_name, last_name = self.get_user_names(
+            response.get('name')
+        )
+        username = response.get('chatwork_id') or \
+            response.get('login_mail') or \
+            response.get('account_id')
+        email = response.get('mail') or \
+            response.get('login_mail') or \
+            ''
+        return {
+            'username': username,
+            'email': email,
+            'fullname': fullname,
+            'first_name': first_name,
+            'last_name': last_name
+        }
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        headers = {'Authorization': 'Bearer ' + access_token}
+        return self.get_json(self.api_url('/me'), headers=headers)
+
+    def refresh_token_params(self, token, *args, **kwargs):
+        return {'refresh_token': token, 'grant_type': 'refresh_token'}
diff --git a/rootfs/api/apps_extra/social_core/backends/classlink.py b/rootfs/api/apps_extra/social_core/backends/classlink.py
new file mode 100644
index 000000000..c05a6a733
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/classlink.py
@@ -0,0 +1,44 @@
+from .oauth import BaseOAuth2
+
+
+class ClasslinkOAuth(BaseOAuth2):
+    """
+    Classlink OAuth authentication backend.
+
+    Docs: https://developer.classlink.com/docs/oauth2-workflow
+    """
+    name = 'classlink'
+    AUTHORIZATION_URL = 'https://launchpad.classlink.com/oauth2/v2/auth'
+    ACCESS_TOKEN_URL = 'https://launchpad.classlink.com/oauth2/v2/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    DEFAULT_SCOPE = ['profile']
+    REDIRECT_STATE = False
+    SCOPE_SEPARATOR = ' '
+
+    def get_user_id(self, details, response):
+        """Return user unique id provided by service"""
+        return response['UserId']
+
+    def get_user_details(self, response):
+        """Return user details from Classlink account"""
+        fullname, first_name, last_name = self.get_user_names(
+            first_name=response.get('FirstName'),
+            last_name=response.get('LastName')
+        )
+
+        return {
+            'username': response.get('Email') or response.get('LoginId'),
+            'email': response.get('Email'),
+            'fullname': fullname,
+            'first_name': first_name,
+            'last_name': last_name,
+        }
+
+    def user_data(self, token, *args, **kwargs):
+        """Loads user data from service"""
+        url = 'https://nodeapi.classlink.com/v2/my/info'
+        auth_header = {'Authorization': 'Bearer %s' % token}
+        try:
+            return self.get_json(url, headers=auth_header)
+        except ValueError:
+            return None
diff --git a/rootfs/api/apps_extra/social_core/backends/clef.py b/rootfs/api/apps_extra/social_core/backends/clef.py
new file mode 100644
index 000000000..206aa13b5
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/clef.py
@@ -0,0 +1,54 @@
+"""
+Clef OAuth support.
+
+This contribution adds support for Clef OAuth service. The settings
+SOCIAL_AUTH_CLEF_KEY and SOCIAL_AUTH_CLEF_SECRET must be defined with the
+values given by Clef application registration process.
+"""
+
+from .oauth import BaseOAuth2
+
+
+class ClefOAuth2(BaseOAuth2):
+    """Clef OAuth authentication backend"""
+    name = 'clef'
+    AUTHORIZATION_URL = 'https://clef.io/iframes/qr'
+    ACCESS_TOKEN_URL = 'https://clef.io/api/v1/authorize'
+    ACCESS_TOKEN_METHOD = 'POST'
+    SCOPE_SEPARATOR = ','
+
+    def auth_params(self, *args, **kwargs):
+        params = super().auth_params(*args, **kwargs)
+        params['app_id'] = params.pop('client_id')
+        params['redirect_url'] = params.pop('redirect_uri')
+        return params
+
+    def get_user_id(self, response, details):
+        return details.get('info').get('id')
+
+    def get_user_details(self, response):
+        """Return user details from Github account"""
+        info = response.get('info')
+        fullname, first_name, last_name = self.get_user_names(
+            first_name=info.get('first_name'),
+            last_name=info.get('last_name')
+        )
+
+        email = info.get('email', '')
+        if email:
+            username = email.split('@', 1)[0]
+        else:
+            username = info.get('id')
+
+        return {
+            'username': username,
+            'email': email,
+            'fullname': fullname,
+            'first_name': first_name,
+            'last_name': last_name,
+            'phone_number': info.get('phone_number', '')
+        }
+
+    def user_data(self, access_token, *args, **kwargs):
+        return self.get_json('https://clef.io/api/v1/info',
+                             params={'access_token': access_token})
diff --git a/rootfs/api/apps_extra/social_core/backends/coding.py b/rootfs/api/apps_extra/social_core/backends/coding.py
new file mode 100644
index 000000000..81f998b18
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/coding.py
@@ -0,0 +1,48 @@
+"""
+Coding OAuth2 backend, docs at:
+"""
+from urllib.parse import urljoin
+
+from .oauth import BaseOAuth2
+
+
+class CodingOAuth2(BaseOAuth2):
+    """Coding OAuth authentication backend"""
+
+    name = 'coding'
+    API_URL = 'https://coding.net/api/'
+    AUTHORIZATION_URL = 'https://coding.net/oauth_authorize.html'
+    ACCESS_TOKEN_URL = 'https://coding.net/api/oauth/access_token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    SCOPE_SEPARATOR = ','
+    DEFAULT_SCOPE = ['user']
+    REDIRECT_STATE = False
+
+    def api_url(self):
+        return self.API_URL
+
+    def get_user_details(self, response):
+        """Return user details from Github account"""
+        fullname, first_name, last_name = self.get_user_names(
+            response.get('name')
+        )
+        return {'username': response.get('name'),
+                'email': response.get('email') or '',
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        data = self._user_data(access_token)
+        if data.get('code') != 0:
+            # 获取失败
+            pass
+        return data.get('data')
+
+    def _user_data(self, access_token, path=None):
+        url = urljoin(
+            self.api_url(),
+            'account/current_user{0}'.format(path or '')
+        )
+        return self.get_json(url, params={'access_token': access_token})
diff --git a/rootfs/api/apps_extra/social_core/backends/cognito.py b/rootfs/api/apps_extra/social_core/backends/cognito.py
new file mode 100644
index 000000000..dabe718ba
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/cognito.py
@@ -0,0 +1,51 @@
+from social_core.backends.oauth import BaseOAuth2
+
+
+class CognitoOAuth2(BaseOAuth2):
+    name = 'cognito'
+    ID_KEY = 'username'
+    DEFAULT_SCOPE = ['openid', 'profile', 'email']
+    ACCESS_TOKEN_METHOD = 'POST'
+    REDIRECT_STATE = False
+
+    def user_pool_domain(self):
+        return self.setting('POOL_DOMAIN')
+
+    def authorization_url(self):
+        return '{}/login'.format(self.user_pool_domain())
+
+    def access_token_url(self):
+        return '{}/oauth2/token'.format(self.user_pool_domain())
+
+    def user_data_url(self):
+        return '{}/oauth2/userInfo'.format(self.user_pool_domain())
+
+    def get_user_details(self, response):
+        """Return user details from their cognito pool account"""
+        first_name = response.get('given_name') or ''
+        last_name = response.get('family_name') or ''
+        fullname, first_name, last_name = self.get_user_names(
+            first_name=first_name,
+            last_name=last_name,
+        )
+        return {'username': response.get('username') or response.get('email'),
+                'email': response.get('email'),
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Grab user profile information from cognito."""
+        response = self.get_json(
+            url=self.user_data_url(),
+            headers={'Authorization': 'Bearer {}'.format(access_token)},
+        )
+
+        user_data = {
+            'given_name': response.get('given_name'),
+            'family_name': response.get('family_name'),
+            'username': response.get('username'),
+            'email': response.get('email'),
+        }
+
+        return user_data
diff --git a/rootfs/api/apps_extra/social_core/backends/coinbase.py b/rootfs/api/apps_extra/social_core/backends/coinbase.py
new file mode 100644
index 000000000..4d0fd97b5
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/coinbase.py
@@ -0,0 +1,38 @@
+"""
+Coinbase OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/coinbase.html
+"""
+from .oauth import BaseOAuth2
+
+
+class CoinbaseOAuth2(BaseOAuth2):
+    name = 'coinbase'
+    SCOPE_SEPARATOR = '+'
+    DEFAULT_SCOPE = ['user', 'balance']
+    AUTHORIZATION_URL = 'https://www.coinbase.com/oauth/authorize'
+    ACCESS_TOKEN_URL = 'https://api.coinbase.com/oauth/token'
+    REVOKE_TOKEN_URL = 'https://api.coinbase.com/oauth/revoke'
+    ACCESS_TOKEN_METHOD = 'POST'
+    REDIRECT_STATE = False
+
+    def get_user_id(self, details, response):
+        return response['data']['id']
+
+    def get_user_details(self, response):
+        """Return user details from Coinbase account"""
+        user_data = response['data']
+        email = user_data.get('email', '')
+        name = user_data['name']
+        username = user_data.get('username')
+        fullname, first_name, last_name = self.get_user_names(name)
+        return {'username': username,
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name,
+                'email': email}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        return self.get_json('https://api.coinbase.com/v2/user', headers={
+            'Authorization': 'Bearer ' + access_token
+        })
diff --git a/rootfs/api/apps_extra/social_core/backends/coursera.py b/rootfs/api/apps_extra/social_core/backends/coursera.py
new file mode 100644
index 000000000..ff5f287ab
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/coursera.py
@@ -0,0 +1,43 @@
+"""
+Coursera OAuth2 backend, docs at:
+    https://tech.coursera.org/app-platform/oauth2/
+"""
+from .oauth import BaseOAuth2
+
+
+class CourseraOAuth2(BaseOAuth2):
+    """Coursera OAuth2 authentication backend"""
+    name = 'coursera'
+    ID_KEY = 'username'
+    AUTHORIZATION_URL = 'https://accounts.coursera.org/oauth2/v1/auth'
+    ACCESS_TOKEN_URL = 'https://accounts.coursera.org/oauth2/v1/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    REDIRECT_STATE = False
+    SCOPE_SEPARATOR = ','
+    DEFAULT_SCOPE = ['view_profile']
+
+    def _get_username_from_response(self, response):
+        elements = response.get('elements', [])
+        for element in elements:
+            if 'id' in element:
+                return element.get('id')
+
+        return None
+
+    def get_user_details(self, response):
+        """Return user details from Coursera account"""
+        return {'username': self._get_username_from_response(response)}
+
+    def get_user_id(self, details, response):
+        """Return a username prepared in get_user_details as uid"""
+        return details.get(self.ID_KEY)
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Load user data from the service"""
+        return self.get_json(
+            'https://api.coursera.org/api/externalBasicProfiles.v1?q=me',
+            headers=self.get_auth_header(access_token)
+        )
+
+    def get_auth_header(self, access_token):
+        return {'Authorization': 'Bearer {0}'.format(access_token)}
diff --git a/rootfs/api/apps_extra/social_core/backends/dailymotion.py b/rootfs/api/apps_extra/social_core/backends/dailymotion.py
new file mode 100644
index 000000000..8f908bef3
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/dailymotion.py
@@ -0,0 +1,24 @@
+"""
+DailyMotion OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/dailymotion.html
+"""
+from .oauth import BaseOAuth2
+
+
+class DailymotionOAuth2(BaseOAuth2):
+    """Dailymotion OAuth authentication backend"""
+    name = 'dailymotion'
+    EXTRA_DATA = [('id', 'id')]
+    ID_KEY = 'username'
+    AUTHORIZATION_URL = 'https://api.dailymotion.com/oauth/authorize'
+    REQUEST_TOKEN_URL = 'https://api.dailymotion.com/oauth/token'
+    ACCESS_TOKEN_URL = 'https://api.dailymotion.com/oauth/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+
+    def get_user_details(self, response):
+        return {'username': response.get('screenname')}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Return user data provided"""
+        return self.get_json('https://api.dailymotion.com/auth/',
+                             params={'access_token': access_token})
diff --git a/rootfs/api/apps_extra/social_core/backends/deezer.py b/rootfs/api/apps_extra/social_core/backends/deezer.py
new file mode 100644
index 000000000..9a29dab9b
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/deezer.py
@@ -0,0 +1,49 @@
+"""
+Deezer backend, docs at:
+    https://developers.deezer.com/api/oauth
+    https://developers.deezer.com/api/permissions
+"""
+from urllib.parse import parse_qsl
+
+from .oauth import BaseOAuth2
+
+
+class DeezerOAuth2(BaseOAuth2):
+    """Deezer OAuth2 authentication backend"""
+    name = 'deezer'
+    ID_KEY = 'name'
+    AUTHORIZATION_URL = 'https://connect.deezer.com/oauth/auth.php'
+    ACCESS_TOKEN_URL = 'https://connect.deezer.com/oauth/access_token.php'
+    ACCESS_TOKEN_METHOD = 'POST'
+    SCOPE_SEPARATOR = ','
+    REDIRECT_STATE = False
+
+    def auth_complete_params(self, state=None):
+        client_id, client_secret = self.get_key_and_secret()
+        return {
+            'app_id': client_id,
+            'secret': client_secret,
+            'code': self.data.get('code')
+        }
+
+    def request_access_token(self, *args, **kwargs):
+        response = self.request(*args, **kwargs)
+        return dict(parse_qsl(response.text))
+
+    def get_user_details(self, response):
+        """Return user details from Deezer account"""
+        fullname, first_name, last_name = self.get_user_names(
+            first_name=response.get('firstname'),
+            last_name=response.get('lastname')
+        )
+        return {'username': response.get('name'),
+                'email': response.get('email'),
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        return self.get_json('http://api.deezer.com/user/me', params={
+            'access_token': access_token
+        })
diff --git a/rootfs/api/apps_extra/social_core/backends/digitalocean.py b/rootfs/api/apps_extra/social_core/backends/digitalocean.py
new file mode 100644
index 000000000..62965e42a
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/digitalocean.py
@@ -0,0 +1,41 @@
+from .oauth import BaseOAuth2
+
+
+class DigitalOceanOAuth(BaseOAuth2):
+    """
+    DigitalOcean OAuth authentication backend.
+
+    Docs: https://developers.digitalocean.com/documentation/oauth/
+    """
+    name = 'digitalocean'
+    AUTHORIZATION_URL = 'https://cloud.digitalocean.com/v1/oauth/authorize'
+    ACCESS_TOKEN_URL = 'https://cloud.digitalocean.com/v1/oauth/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    SCOPE_SEPARATOR = ' '
+    EXTRA_DATA = [
+        ('expires_in', 'expires_in')
+    ]
+
+    def get_user_id(self, details, response):
+        """Return user unique id provided by service"""
+        return response['account'].get('uuid')
+
+    def get_user_details(self, response):
+        """Return user details from DigitalOcean account"""
+        fullname, first_name, last_name = self.get_user_names(
+            response.get('name') or '')
+
+        return {'username': response['account'].get('email'),
+                'email': response['account'].get('email'),
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
+
+    def user_data(self, token, *args, **kwargs):
+        """Loads user data from service"""
+        url = 'https://api.digitalocean.com/v2/account'
+        auth_header = {'Authorization': 'Bearer %s' % token}
+        try:
+            return self.get_json(url, headers=auth_header)
+        except ValueError:
+            return None
diff --git a/rootfs/api/apps_extra/social_core/backends/discord.py b/rootfs/api/apps_extra/social_core/backends/discord.py
new file mode 100644
index 000000000..24cd847f2
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/discord.py
@@ -0,0 +1,31 @@
+"""
+Discord Auth OAuth2 backend, docs at:
+    https://discord.com/developers/docs/topics/oauth2
+"""
+from .oauth import BaseOAuth2
+
+
+class DiscordOAuth2(BaseOAuth2):
+    name = 'discord'
+    HOSTNAME = 'discord.com'
+    AUTHORIZATION_URL = 'https://%s/api/oauth2/authorize' % HOSTNAME
+    ACCESS_TOKEN_URL = 'https://%s/api/oauth2/token' % HOSTNAME
+    ACCESS_TOKEN_METHOD = 'POST'
+    REVOKE_TOKEN_URL = 'https://%s/api/oauth2/token/revoke' % HOSTNAME
+    REVOKE_TOKEN_METHOD = 'GET'
+    DEFAULT_SCOPE = ['identify']
+    SCOPE_SEPARATOR = '+'
+    REDIRECT_STATE = False
+    EXTRA_DATA = [
+        ('expires_in', 'expires'),
+        ('refresh_token', 'refresh_token')
+    ]
+
+    def get_user_details(self, response):
+        return {'username': response.get('username'),
+                'email': response.get('email') or ''}
+
+    def user_data(self, access_token, *args, **kwargs):
+        url = 'https://%s/api/users/@me' % self.HOSTNAME
+        auth_header = {'Authorization': 'Bearer %s' % access_token}
+        return self.get_json(url, headers=auth_header)
diff --git a/rootfs/api/apps_extra/social_core/backends/discourse.py b/rootfs/api/apps_extra/social_core/backends/discourse.py
new file mode 100644
index 000000000..4c8bdc4fd
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/discourse.py
@@ -0,0 +1,115 @@
+import hmac
+import time
+
+from base64 import urlsafe_b64decode, urlsafe_b64encode
+from hashlib import sha256
+from urllib.parse import urlencode
+
+from .base import BaseAuth
+from ..exceptions import AuthException, AuthTokenError
+from ..utils import parse_qs
+
+
+class DiscourseAuth(BaseAuth):
+    name = 'discourse'
+    EXTRA_DATA = ['username', 'name', 'avatar_url']
+
+    def auth_url(self):
+        """
+        Get the URL to which we must redirect in order to authenticate the user
+        """
+        return_url = self.redirect_uri
+        nonce = self.strategy.random_string(64)
+        self.add_nonce(nonce)
+
+        payload = urlencode({
+            'nonce': nonce,
+            'return_sso_url': return_url
+        })
+        base_64_payload = urlsafe_b64encode(
+            payload.encode('utf8')
+        ).decode('ascii')
+
+        payload_signature = hmac.new(
+            self.setting('SECRET').encode('utf8'),
+            base_64_payload.encode('utf8'),
+            sha256,
+        ).hexdigest()
+        encoded_params = urlencode({
+            'sso': base_64_payload,
+            'sig': payload_signature
+        })
+        return '{0}?{1}'.format(self.get_idp_url(), encoded_params)
+
+    def get_idp_url(self):
+        return self.setting('SERVER_URL') + '/session/sso_provider'
+
+    def get_user_id(self, details, response):
+        return response['email']
+
+    def get_user_details(self, response):
+        results = {
+            'username': response.get('username'),
+            'email': response.get('email'),
+            'name': response.get('name'),
+            'groups': response.get('groups', '').split(','),
+            'is_staff': response.get('admin') == 'true' or
+                        response.get('moderator') == 'true',
+            'is_superuser': response.get('admin') == 'true',
+        }
+        return results
+
+    def add_nonce(self, nonce):
+        self.strategy.storage.nonce.use(
+            self.setting('SERVER_URL'),
+            time.time(),
+            nonce
+        )
+
+    def get_nonce(self, nonce):
+        return self.strategy.storage.nonce.get(
+            self.setting('SERVER_URL'),
+            nonce
+        )
+
+    def delete_nonce(self, nonce):
+        self.strategy.storage.nonce.delete(nonce)
+
+    def auth_complete(self, *args, **kwargs):
+        """
+        The user has been redirected back from the IdP and we should
+        now log them in, if everything checks out.
+        """
+        request_data = self.strategy.request_data()
+
+        sso_params = request_data.get('sso')
+        sso_signature = request_data.get('sig')
+
+        param_signature = hmac.new(
+            self.setting('SECRET').encode('utf8'),
+            sso_params.encode('utf8'),
+            sha256
+        ).hexdigest()
+
+        if not hmac.compare_digest(str(sso_signature), str(param_signature)):
+            raise AuthException('Could not verify discourse login')
+
+        decoded_params = urlsafe_b64decode(
+            sso_params.encode('utf8')
+        ).decode('ascii')
+
+        # Validate the nonce to ensure the request was not modified
+        response = parse_qs(decoded_params)
+        nonce_obj = self.get_nonce(response.get('nonce'))
+        if nonce_obj:
+            self.delete_nonce(nonce_obj)
+        else:
+            raise AuthTokenError(self, 'Incorrect id_token: nonce')
+
+        kwargs.update({
+            'sso': '',
+            'sig': '',
+            'backend': self,
+            'response': response
+        })
+        return self.strategy.authenticate(*args, **kwargs)
diff --git a/rootfs/api/apps_extra/social_core/backends/disqus.py b/rootfs/api/apps_extra/social_core/backends/disqus.py
new file mode 100644
index 000000000..799414b9a
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/disqus.py
@@ -0,0 +1,51 @@
+"""
+Disqus OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/disqus.html
+"""
+from .oauth import BaseOAuth2
+
+
+class DisqusOAuth2(BaseOAuth2):
+    name = 'disqus'
+    AUTHORIZATION_URL = 'https://disqus.com/api/oauth/2.0/authorize/'
+    ACCESS_TOKEN_URL = 'https://disqus.com/api/oauth/2.0/access_token/'
+    ACCESS_TOKEN_METHOD = 'POST'
+    REDIRECT_STATE = False
+    SCOPE_SEPARATOR = ','
+    EXTRA_DATA = [
+        ('avatar', 'avatar'),
+        ('connections', 'connections'),
+        ('user_id', 'user_id'),
+        ('email', 'email'),
+        ('email_hash', 'emailHash'),
+        ('expires', 'expires'),
+        ('location', 'location'),
+        ('meta', 'response'),
+        ('name', 'name'),
+        ('username', 'username'),
+    ]
+
+    def get_user_id(self, details, response):
+        return response['response']['id']
+
+    def get_user_details(self, response):
+        """Return user details from Disqus account"""
+        rr = response.get('response', {})
+        return {
+            'username': rr.get('username', ''),
+            'user_id': response.get('user_id', ''),
+            'email': rr.get('email', ''),
+            'name': rr.get('name', ''),
+        }
+
+    def extra_data(self, user, uid, response, details=None, *args, **kwargs):
+        meta_response = dict(response, **response.get('response', {}))
+        return super().extra_data(user, uid, meta_response, details, *args, **kwargs)
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        key, secret = self.get_key_and_secret()
+        return self.get_json(
+            'https://disqus.com/api/3.0/users/details.json',
+            params={'access_token': access_token, 'api_secret': secret}
+        )
diff --git a/rootfs/api/apps_extra/social_core/backends/docker.py b/rootfs/api/apps_extra/social_core/backends/docker.py
new file mode 100644
index 000000000..58c18fc23
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/docker.py
@@ -0,0 +1,46 @@
+"""
+Docker Hub OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/docker.html
+"""
+from .oauth import BaseOAuth2
+
+
+class DockerOAuth2(BaseOAuth2):
+    name = 'docker'
+    ID_KEY = 'user_id'
+    AUTHORIZATION_URL = 'https://hub.docker.com/api/v1.1/o/authorize/'
+    ACCESS_TOKEN_URL = 'https://hub.docker.com/api/v1.1/o/token/'
+    REFRESH_TOKEN_URL = 'https://hub.docker.com/api/v1.1/o/token/'
+    ACCESS_TOKEN_METHOD = 'POST'
+    REDIRECT_STATE = False
+    EXTRA_DATA = [
+        ('refresh_token', 'refresh_token', True),
+        ('user_id', 'user_id'),
+        ('email', 'email'),
+        ('full_name', 'fullname'),
+        ('location', 'location'),
+        ('url', 'url'),
+        ('company', 'company'),
+        ('gravatar_email', 'gravatar_email'),
+    ]
+
+    def get_user_details(self, response):
+        """Return user details from Docker Hub account"""
+        fullname, first_name, last_name = self.get_user_names(
+            response.get('full_name') or response.get('username') or ''
+        )
+        return {
+            'username': response.get('username'),
+            'fullname': fullname,
+            'first_name': first_name,
+            'last_name': last_name,
+            'email': response.get('email', '')
+        }
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Grab user profile information from Docker Hub."""
+        username = kwargs['response']['username']
+        return self.get_json(
+            'https://hub.docker.com/api/v1.1/users/%s/' % username,
+            headers={'Authorization': 'Bearer %s' % access_token}
+        )
diff --git a/rootfs/api/apps_extra/social_core/backends/douban.py b/rootfs/api/apps_extra/social_core/backends/douban.py
new file mode 100644
index 000000000..ff931c0a7
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/douban.py
@@ -0,0 +1,59 @@
+"""
+Douban OAuth1 and OAuth2 backends, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/douban.html
+"""
+from .oauth import BaseOAuth2, BaseOAuth1
+
+
+class DoubanOAuth(BaseOAuth1):
+    """Douban OAuth authentication backend"""
+    name = 'douban'
+    EXTRA_DATA = [('id', 'id')]
+    AUTHORIZATION_URL = 'http://www.douban.com/service/auth/authorize'
+    REQUEST_TOKEN_URL = 'http://www.douban.com/service/auth/request_token'
+    ACCESS_TOKEN_URL = 'http://www.douban.com/service/auth/access_token'
+
+    def get_user_id(self, details, response):
+        return response['db:uid']['$t']
+
+    def get_user_details(self, response):
+        """Return user details from Douban"""
+        return {'username': response['db:uid']['$t'],
+                'email': ''}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Return user data provided"""
+        return self.get_json('http://api.douban.com/people/%40me?&alt=json',
+                             auth=self.oauth_auth(access_token))
+
+
+class DoubanOAuth2(BaseOAuth2):
+    """Douban OAuth authentication backend"""
+    name = 'douban-oauth2'
+    AUTHORIZATION_URL = 'https://www.douban.com/service/auth2/auth'
+    ACCESS_TOKEN_URL = 'https://www.douban.com/service/auth2/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    REDIRECT_STATE = False
+    EXTRA_DATA = [
+        ('id', 'id'),
+        ('uid', 'username'),
+        ('refresh_token', 'refresh_token'),
+    ]
+
+    def get_user_details(self, response):
+        """Return user details from Douban"""
+        fullname, first_name, last_name = self.get_user_names(
+            response.get('name', '')
+        )
+        return {'username': response.get('uid', ''),
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name,
+                'email': ''}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Return user data provided"""
+        return self.get_json(
+            'https://api.douban.com/v2/user/~me',
+            headers={'Authorization': 'Bearer {0}'.format(access_token)}
+        )
diff --git a/rootfs/api/apps_extra/social_core/backends/dribbble.py b/rootfs/api/apps_extra/social_core/backends/dribbble.py
new file mode 100644
index 000000000..7381e6d42
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/dribbble.py
@@ -0,0 +1,62 @@
+"""
+Dribbble OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/dribbble.html
+    http://developer.dribbble.com/v1/oauth/
+"""
+
+from .oauth import BaseOAuth2
+
+
+class DribbbleOAuth2(BaseOAuth2):
+    """Dribbble OAuth authentication backend"""
+    name = 'dribbble'
+    AUTHORIZATION_URL = 'https://dribbble.com/oauth/authorize'
+    ACCESS_TOKEN_URL = 'https://dribbble.com/oauth/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    SCOPE_SEPARATOR = ','
+    EXTRA_DATA = [
+        ('id', 'id'),
+        ('name', 'name'),
+        ('html_url', 'html_url'),
+        ('avatar_url', 'avatar_url'),
+        ('bio', 'bio'),
+        ('location', 'location'),
+        ('links', 'links'),
+        ('buckets_count', 'buckets_count'),
+        ('comments_received_count', 'comments_received_count'),
+        ('followers_count', 'followers_count'),
+        ('followings_count', 'followings_count'),
+        ('likes_count', 'likes_count'),
+        ('likes_received_count', 'likes_received_count'),
+        ('projects_count', 'projects_count'),
+        ('rebounds_received_count', 'rebounds_received_count'),
+        ('shots_count', 'shots_count'),
+        ('teams_count', 'teams_count'),
+        ('pro', 'pro'),
+        ('buckets_url', 'buckets_url'),
+        ('followers_url', 'followers_url'),
+        ('following_url', 'following_url'),
+        ('likes_url', 'shots_url'),
+        ('teams_url', 'teams_url'),
+        ('created_at', 'created_at'),
+        ('updated_at', 'updated_at'),
+    ]
+
+    def get_user_details(self, response):
+        """Return user details from Dribbble account"""
+        fullname, first_name, last_name = self.get_user_names(
+            response.get('name')
+        )
+        return {'username': response.get('username'),
+                'email': response.get('email', ''),
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        return self.get_json(
+            'https://api.dribbble.com/v1/user',
+            headers={
+                'Authorization': 'Bearer {0}'.format(access_token)
+            })
diff --git a/rootfs/api/apps_extra/social_core/backends/drip.py b/rootfs/api/apps_extra/social_core/backends/drip.py
new file mode 100644
index 000000000..e3b62c064
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/drip.py
@@ -0,0 +1,25 @@
+"""
+Drip OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/drip.html
+"""
+from .oauth import BaseOAuth2
+
+
+class DripOAuth(BaseOAuth2):
+    name = 'drip'
+    AUTHORIZATION_URL = 'https://www.getdrip.com/oauth/authorize'
+    ACCESS_TOKEN_URL = 'https://www.getdrip.com/oauth/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+
+    def get_user_id(self, details, response):
+        return details['email']
+
+    def get_user_details(self, response):
+        return {'email': response['users'][0]['email'],
+                'fullname': response['users'][0]['name'],
+                'username': response['users'][0]['email']}
+
+    def user_data(self, access_token, *args, **kwargs):
+        return self.get_json('https://api.getdrip.com/v2/user', headers={
+            'Authorization': 'Bearer %s' % access_token
+        })
diff --git a/rootfs/api/apps_extra/social_core/backends/dropbox.py b/rootfs/api/apps_extra/social_core/backends/dropbox.py
new file mode 100644
index 000000000..a22b79b13
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/dropbox.py
@@ -0,0 +1,31 @@
+"""
+Dropbox OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/dropbox.html
+"""
+
+from .oauth import BaseOAuth2
+
+class DropboxOAuth2V2(BaseOAuth2):
+    name = 'dropbox-oauth2'
+    ID_KEY = 'uid'
+    AUTHORIZATION_URL = 'https://www.dropbox.com/oauth2/authorize'
+    ACCESS_TOKEN_URL = 'https://api.dropboxapi.com/oauth2/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    REDIRECT_STATE = False
+
+    def get_user_details(self, response):
+        """Return user details from Dropbox account"""
+        name = response.get('name')
+        return {'username': str(response.get('account_id')),
+                'email': response.get('email'),
+                'fullname': name.get('display_name'),
+                'first_name': name.get('given_name'),
+                'last_name': name.get('surname')}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        return self.get_json(
+            'https://api.dropboxapi.com/2/users/get_current_account',
+            headers={'Authorization': 'Bearer {0}'.format(access_token)},
+            method='POST'
+        )
diff --git a/rootfs/api/apps_extra/social_core/backends/echosign.py b/rootfs/api/apps_extra/social_core/backends/echosign.py
new file mode 100644
index 000000000..e75fad90d
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/echosign.py
@@ -0,0 +1,24 @@
+from .oauth import BaseOAuth2
+
+
+class EchosignOAuth2(BaseOAuth2):
+    name = 'echosign'
+    REDIRECT_STATE = False
+    ACCESS_TOKEN_METHOD = 'POST'
+    REFRESH_TOKEN_METHOD = 'POST'
+    REVOKE_TOKEN_METHOD = 'POST'
+    AUTHORIZATION_URL = 'https://secure.echosign.com/public/oauth'
+    ACCESS_TOKEN_URL = 'https://secure.echosign.com/oauth/token'
+    REFRESH_TOKEN_URL = 'https://secure.echosign.com/oauth/refresh'
+    REVOKE_TOKEN_URL = 'https://secure.echosign.com/oauth/revoke'
+
+    def get_user_details(self, response):
+        return response
+
+    def get_user_id(self, details, response):
+        return details['userInfoList'][0]['userId']
+
+    def user_data(self, access_token, *args, **kwargs):
+        return self.get_json(
+            'https://api.echosign.com/api/rest/v3/users',
+            headers={'Access-Token': access_token})
diff --git a/rootfs/api/apps_extra/social_core/backends/edmodo.py b/rootfs/api/apps_extra/social_core/backends/edmodo.py
new file mode 100644
index 000000000..4ac796405
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/edmodo.py
@@ -0,0 +1,34 @@
+"""
+Edmodo OAuth2 Sign-in backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/edmodo.html
+"""
+from .oauth import BaseOAuth2
+
+
+class EdmodoOAuth2(BaseOAuth2):
+    """Edmodo OAuth2"""
+    name = 'edmodo'
+    AUTHORIZATION_URL = 'https://api.edmodo.com/oauth/authorize'
+    ACCESS_TOKEN_URL = 'https://api.edmodo.com/oauth/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+
+    def get_user_details(self, response):
+        """Return user details from Edmodo account"""
+        fullname, first_name, last_name = self.get_user_names(
+            first_name=response.get('first_name'),
+            last_name=response.get('last_name')
+        )
+        return {
+            'username': response.get('username'),
+            'email': response.get('email'),
+            'fullname': fullname,
+            'first_name': first_name,
+            'last_name': last_name
+        }
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from Edmodo"""
+        return self.get_json(
+            'https://api.edmodo.com/users/me',
+            params={'access_token': access_token}
+        )
diff --git a/rootfs/api/apps_extra/social_core/backends/elixir.py b/rootfs/api/apps_extra/social_core/backends/elixir.py
new file mode 100644
index 000000000..b88035533
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/elixir.py
@@ -0,0 +1,32 @@
+"""
+Backend for OpenID Connect ELIXIR AAI
+https://www.elixir-europe.org/services/compute/aai
+"""
+
+from social_core.backends.open_id_connect import OpenIdConnectAuth
+
+
+class ElixirOpenIdConnect(OpenIdConnectAuth):
+    name = 'elixir'
+    OIDC_ENDPOINT = 'https://login.elixir-czech.org/oidc'
+    EXTRA_DATA = [
+        ('expires_in', 'expires_in', True),
+        ('refresh_token', 'refresh_token', True),
+        ('id_token', 'id_token', True),
+        ('other_tokens', 'other_tokens', True),
+    ]
+    # In order to get any scopes, you have to register your service with
+    # ELIXIR, see documentation at
+    # https://www.elixir-europe.org/services/compute/aai
+    DEFAULT_SCOPE = ['openid', 'email']
+    JWT_DECODE_OPTIONS = {'verify_at_hash': False}
+
+    def get_user_details(self, response):
+        username_key = self.setting('USERNAME_KEY', default=self.USERNAME_KEY)
+        name = response.get('name') or ''
+        fullname, first_name, last_name = self.get_user_names(name)
+        return {'username': response.get(username_key),
+                'email': response.get('email'),
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
diff --git a/rootfs/api/apps_extra/social_core/backends/email.py b/rootfs/api/apps_extra/social_core/backends/email.py
new file mode 100644
index 000000000..417b49880
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/email.py
@@ -0,0 +1,12 @@
+"""
+Legacy Email backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/email.html
+"""
+from .legacy import LegacyAuth
+
+
+class EmailAuth(LegacyAuth):
+    name = 'email'
+    ID_KEY = 'email'
+    REQUIRES_EMAIL_VALIDATION = True
+    EXTRA_DATA = ['email']
diff --git a/rootfs/api/apps_extra/social_core/backends/eventbrite.py b/rootfs/api/apps_extra/social_core/backends/eventbrite.py
new file mode 100644
index 000000000..2219b00cb
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/eventbrite.py
@@ -0,0 +1,29 @@
+from .oauth import BaseOAuth2
+
+
+class EventbriteOAuth2(BaseOAuth2):
+    """Eventbrite OAuth2 authentication backend"""
+    name = 'eventbrite'
+    AUTHORIZATION_URL = 'https://www.eventbrite.com/oauth/authorize'
+    ACCESS_TOKEN_URL = 'https://www.eventbrite.com/oauth/token'
+    METADATA_URL = 'https://www.eventbriteapi.com/v3/users/me'
+    ACCESS_TOKEN_METHOD = 'POST'
+    STATE_PARAMETER = False
+    REDIRECT_STATE = False
+
+    def get_user_details(self, response):
+        """Return user details from an Eventbrite metadata response"""
+        email = next(iter(filter(lambda x: x['primary'], response['emails'])))['email']
+
+        return {
+            'username': email,
+            'email': email,
+            'first_name': response['first_name'],
+            'last_name': response['last_name']
+        }
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data and datacenter information from service"""
+        return self.get_json(self.METADATA_URL, headers={
+          'Authorization': 'Bearer ' + access_token
+        })
diff --git a/rootfs/api/apps_extra/social_core/backends/eveonline.py b/rootfs/api/apps_extra/social_core/backends/eveonline.py
new file mode 100644
index 000000000..1b7e5dc10
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/eveonline.py
@@ -0,0 +1,42 @@
+"""
+EVE Online Single Sign-On (SSO) OAuth2 backend
+Documentation at https://eveonline-third-party-documentation.readthedocs.io/en/latest/sso/index.html
+"""
+from .oauth import BaseOAuth2
+
+
+class EVEOnlineOAuth2(BaseOAuth2):
+    """EVE Online OAuth authentication backend"""
+    name = 'eveonline'
+    BASE_URL = 'https://login.eveonline.com/oauth'
+    AUTHORIZATION_URL = BASE_URL + '/authorize'
+    ACCESS_TOKEN_URL = BASE_URL + '/token'
+    ID_KEY = 'CharacterID'
+    ACCESS_TOKEN_METHOD = 'POST'
+    EXTRA_DATA = [
+        ('CharacterID', 'id'),
+        ('expires_in', 'expires'),
+        ('CharacterOwnerHash', 'owner_hash', True),
+        ('refresh_token', 'refresh_token', True),
+    ]
+
+    def get_user_details(self, response):
+        """Return user details from EVE Online account"""
+        user_data = self.user_data(response['access_token'])
+        fullname, first_name, last_name = self.get_user_names(
+            user_data['CharacterName']
+        )
+        return {
+            'email': '',
+            'username': fullname,
+            'fullname': fullname,
+            'first_name': first_name,
+            'last_name': last_name
+        }
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Get Character data from EVE server"""
+        return self.get_json(
+            'https://login.eveonline.com/oauth/verify',
+            headers={'Authorization': 'Bearer {0}'.format(access_token)}
+        )
diff --git a/rootfs/api/apps_extra/social_core/backends/evernote.py b/rootfs/api/apps_extra/social_core/backends/evernote.py
new file mode 100644
index 000000000..b58166b23
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/evernote.py
@@ -0,0 +1,74 @@
+"""
+Evernote OAuth1 backend (with sandbox mode support), docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/evernote.html
+"""
+from requests import HTTPError
+
+from ..exceptions import AuthCanceled
+from .oauth import BaseOAuth1
+
+
+class EvernoteOAuth(BaseOAuth1):
+    """
+    Evernote OAuth authentication backend.
+
+    Possible Values:
+       {'edam_expires': ['1367525289541'],
+        'edam_noteStoreUrl': [
+            'https://sandbox.evernote.com/shard/s1/notestore'
+        ],
+        'edam_shard': ['s1'],
+        'edam_userId': ['123841'],
+        'edam_webApiUrlPrefix': ['https://sandbox.evernote.com/shard/s1/'],
+        'oauth_token': [
+            'S=s1:U=1e3c1:E=13e66dbee45:C=1370f2ac245:P=185:A=my_user:' \
+            'H=411443c5e8b20f8718ed382a19d4ae38'
+        ]}
+    """
+    name = 'evernote'
+    ID_KEY = 'edam_userId'
+    AUTHORIZATION_URL = 'https://www.evernote.com/OAuth.action'
+    REQUEST_TOKEN_URL = 'https://www.evernote.com/oauth'
+    ACCESS_TOKEN_URL = 'https://www.evernote.com/oauth'
+    EXTRA_DATA = [
+        ('access_token', 'access_token'),
+        ('oauth_token', 'oauth_token'),
+        ('edam_noteStoreUrl', 'store_url'),
+        ('edam_expires', 'expires')
+    ]
+
+    def get_user_details(self, response):
+        """Return user details from Evernote account"""
+        return {'username': response['edam_userId'],
+                'email': ''}
+
+    def access_token(self, token):
+        """Return request for access token value"""
+        try:
+            return self.get_querystring(self.ACCESS_TOKEN_URL,
+                                        auth=self.oauth_auth(token))
+        except HTTPError as err:
+            # Evernote returns a 401 error when AuthCanceled
+            if err.response.status_code == 401:
+                raise AuthCanceled(self, response=err.response)
+            else:
+                raise
+
+    def extra_data(self, user, uid, response, details=None, *args, **kwargs):
+        data = super().extra_data(user, uid, response, details, *args, **kwargs)
+        # Evernote returns expiration timestamp in milliseconds, so it needs to
+        # be normalized.
+        if 'expires' in data:
+            data['expires'] = int(data['expires']) / 1000
+        return data
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Return user data provided"""
+        return access_token.copy()
+
+
+class EvernoteSandboxOAuth(EvernoteOAuth):
+    name = 'evernote-sandbox'
+    AUTHORIZATION_URL = 'https://sandbox.evernote.com/OAuth.action'
+    REQUEST_TOKEN_URL = 'https://sandbox.evernote.com/oauth'
+    ACCESS_TOKEN_URL = 'https://sandbox.evernote.com/oauth'
diff --git a/rootfs/api/apps_extra/social_core/backends/exacttarget.py b/rootfs/api/apps_extra/social_core/backends/exacttarget.py
new file mode 100644
index 000000000..a066cdde7
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/exacttarget.py
@@ -0,0 +1,104 @@
+"""
+ExactTarget OAuth support.
+Support Authentication from IMH using JWT token and pre-shared key.
+Requires package pyjwt
+"""
+from datetime import timedelta, datetime
+
+import jwt
+
+from ..exceptions import AuthFailed, AuthCanceled
+from .oauth import BaseOAuth2
+
+
+class ExactTargetOAuth2(BaseOAuth2):
+    name = 'exacttarget'
+
+    def get_user_details(self, response):
+        """Use the email address of the user, suffixed by _et"""
+        user = response.get('token', {})\
+                       .get('request', {})\
+                       .get('user', {})
+        if 'email' in user:
+            user['username'] = user['email']
+        return user
+
+    def get_user_id(self, details, response):
+        """
+        Create a user ID from the ET user ID. Uses details rather than the
+        default response, as only the token is available in response. details
+        is much richer:
+            {
+                'expiresIn': 1200,
+                'username': 'example@example.com',
+                'refreshToken': '1234567890abcdef',
+                'internalOauthToken': 'jwttoken.......',
+                'oauthToken': 'yetanothertoken',
+                'id': 123456,
+                'culture': 'en-US',
+                'timezone': {
+                    'shortName': 'CST',
+                    'offset': -6.0,
+                    'dst': False,
+                    'longName': '(GMT-06:00) Central Time (No Daylight Saving)'
+                },
+                'email': 'example@example.com'
+            }
+        """
+        return '{0}'.format(details.get('id'))
+
+    def uses_redirect(self):
+        return False
+
+    def auth_url(self):
+        return None
+
+    def process_error(self, data):
+        if data.get('error'):
+            error = self.data.get('error_description') or self.data['error']
+            raise AuthFailed(self, error)
+
+    def do_auth(self, token, *args, **kwargs):
+        dummy, secret = self.get_key_and_secret()
+        try:  # Decode the token, using the Application Signature from settings
+            decoded = jwt.decode(token, secret, algorithms=['HS256'])
+        except jwt.DecodeError:  # Wrong signature, fail authentication
+            raise AuthCanceled(self)
+        kwargs.update({'response': {'token': decoded}, 'backend': self})
+        return self.strategy.authenticate(*args, **kwargs)
+
+    def auth_complete(self, *args, **kwargs):
+        """Completes login process, must return user instance"""
+        token = self.data.get('jwt', {})
+        if not token:
+            raise AuthFailed(self, 'Authentication Failed')
+        return self.do_auth(token, *args, **kwargs)
+
+    def extra_data(self, user, uid, response, details=None, *args, **kwargs):
+        """Load extra details from the JWT token"""
+        data = {
+            'id': details.get('id'),
+            'email': details.get('email'),
+            # OAuth token, for use with legacy SOAP API calls:
+            #   http://bit.ly/13pRHfo
+            'internalOauthToken': details.get('internalOauthToken'),
+            # Token for use with the Application ClientID for the FUEL API
+            'oauthToken': details.get('oauthToken'),
+            # If the token has expired, use the FUEL API to get a new token see
+            # http://bit.ly/10v1K5l and http://bit.ly/11IbI6F - set legacy=1
+            'refreshToken': details.get('refreshToken'),
+        }
+
+        # The expiresIn value determines how long the tokens are valid for.
+        # Take a bit off, then convert to an int timestamp
+        expiresSeconds = details.get('expiresIn', 0) - 30
+        expires = datetime.utcnow() + timedelta(seconds=expiresSeconds)
+        data['expires'] = (expires - datetime(1970, 1, 1)).total_seconds()
+
+        if response.get('token'):
+            token = response['token']
+            org = token.get('request', {}).get('organization')
+            if org:
+                data['stack'] = org.get('stackKey')
+                data['enterpriseId'] = org.get('enterpriseId')
+        return data
diff --git a/rootfs/api/apps_extra/social_core/backends/facebook.py b/rootfs/api/apps_extra/social_core/backends/facebook.py
new file mode 100644
index 000000000..eb2766f62
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/facebook.py
@@ -0,0 +1,233 @@
+"""
+Facebook OAuth2 and Canvas Application backends, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/facebook.html
+"""
+import hmac
+import time
+import json
+import base64
+import hashlib
+
+from ..utils import parse_qs, constant_time_compare, handle_http_errors
+from .oauth import BaseOAuth2
+from ..exceptions import AuthException, AuthCanceled, AuthUnknownError, \
+                         AuthMissingParameter
+
+
+API_VERSION = 8.0
+
+
+class FacebookOAuth2(BaseOAuth2):
+    """Facebook OAuth2 authentication backend"""
+    name = 'facebook'
+    REDIRECT_STATE = False
+    RESPONSE_TYPE = None
+    SCOPE_SEPARATOR = ','
+    AUTHORIZATION_URL = 'https://www.facebook.com/v{version}/dialog/oauth'
+    ACCESS_TOKEN_URL = \
+        'https://graph.facebook.com/v{version}/oauth/access_token'
+    REVOKE_TOKEN_URL = \
+        'https://graph.facebook.com/v{version}/{uid}/permissions'
+    REVOKE_TOKEN_METHOD = 'DELETE'
+    USER_DATA_URL = 'https://graph.facebook.com/v{version}/me'
+    EXTRA_DATA = [
+        ('id', 'id'),
+        ('expires', 'expires'),
+        ('granted_scopes', 'granted_scopes'),
+        ('denied_scopes', 'denied_scopes')
+    ]
+
+    def auth_params(self, state=None):
+        params = super().auth_params(state)
+        params['return_scopes'] = 'true'
+        return params
+
+    def authorization_url(self):
+        version = self.setting('API_VERSION', API_VERSION)
+        return self.AUTHORIZATION_URL.format(version=version)
+
+    def access_token_url(self):
+        version = self.setting('API_VERSION', API_VERSION)
+        return self.ACCESS_TOKEN_URL.format(version=version)
+
+    def get_user_details(self, response):
+        """Return user details from Facebook account"""
+        fullname, first_name, last_name = self.get_user_names(
+            response.get('name', ''),
+            response.get('first_name', ''),
+            response.get('last_name', '')
+        )
+        return {'username': response.get('username', response.get('name')),
+                'email': response.get('email', ''),
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        params = self.setting('PROFILE_EXTRA_PARAMS', {})
+        params['access_token'] = access_token
+
+        if self.setting('APPSECRET_PROOF', True):
+            _, secret = self.get_key_and_secret()
+            params['appsecret_proof'] = hmac.new(
+                secret.encode('utf8'),
+                msg=access_token.encode('utf8'),
+                digestmod=hashlib.sha256
+            ).hexdigest()
+
+        version = self.setting('API_VERSION', API_VERSION)
+        return self.get_json(self.USER_DATA_URL.format(version=version),
+                             params=params)
+
+    def process_error(self, data):
+        super().process_error(data)
+        if data.get('error_code'):
+            raise AuthCanceled(self, data.get('error_message') or
+                               data.get('error_code'))
+
+    @handle_http_errors
+    def auth_complete(self, *args, **kwargs):
+        """Completes login process, must return user instance"""
+        self.process_error(self.data)
+        if not self.data.get('code'):
+            raise AuthMissingParameter(self, 'code')
+        state = self.validate_state()
+        key, secret = self.get_key_and_secret()
+        response = self.request(self.access_token_url(), params={
+            'client_id': key,
+            'redirect_uri': self.get_redirect_uri(state),
+            'client_secret': secret,
+            'code': self.data['code']
+        })
+        # API v2.3 returns a JSON, according to the documents linked at issue
+        # #592, but it seems that this needs to be enabled(?), otherwise the
+        # usual querystring type response is returned.
+        try:
+            response = response.json()
+        except ValueError:
+            response = parse_qs(response.text)
+        access_token = response['access_token']
+        return self.do_auth(access_token, response, *args, **kwargs)
+
+    def process_refresh_token_response(self, response, *args, **kwargs):
+        try:
+            return response.json()
+        except ValueError:
+            return parse_qs(response.content)
+
+    def refresh_token_params(self, token, *args, **kwargs):
+        client_id, client_secret = self.get_key_and_secret()
+        return {
+            'fb_exchange_token': token,
+            'grant_type': 'fb_exchange_token',
+            'client_id': client_id,
+            'client_secret': client_secret
+        }
+
+    def do_auth(self, access_token, response=None, *args, **kwargs):
+        response = response or {}
+
+        data = self.user_data(access_token)
+
+        if not isinstance(data, dict):
+            # From time to time Facebook responds back a JSON with just
+            # False as value, the reason is still unknown, but since the
+            # data is needed (it contains the user ID used to identify the
+            # account on further logins), this app cannot allow it to
+            # continue with the auth process.
+            raise AuthUnknownError(self, 'An error occurred while retrieving '
+                                         'users Facebook data')
+
+        data['access_token'] = access_token
+        if 'expires_in' in response:
+            data['expires'] = response['expires_in']
+
+        if self.data.get('granted_scopes'):
+            data['granted_scopes'] = self.data['granted_scopes'].split(',')
+
+        if self.data.get('denied_scopes'):
+            data['denied_scopes'] = self.data['denied_scopes'].split(',')
+
+        kwargs.update({'backend': self, 'response': data})
+        return self.strategy.authenticate(*args, **kwargs)
+
+    def revoke_token_url(self, token, uid):
+        version = self.setting('API_VERSION', API_VERSION)
+        return self.REVOKE_TOKEN_URL.format(version=version, uid=uid)
+
+    def revoke_token_params(self, token, uid):
+        return {'access_token': token}
+
+    def process_revoke_token_response(self, response):
+        return super().process_revoke_token_response(
+            response
+        ) and response.content == 'true'
+
+
+class FacebookAppOAuth2(FacebookOAuth2):
+    """Facebook Application Authentication support"""
+    name = 'facebook-app'
+
+    def uses_redirect(self):
+        return False
+
+    def auth_complete(self, *args, **kwargs):
+        access_token = self.data.get('access_token')
+        response = {}
+
+        if 'signed_request' in self.data:
+            key, secret = self.get_key_and_secret()
+            response = self.load_signed_request(self.data['signed_request'])
+            if 'user_id' not in response and 'oauth_token' not in response:
+                raise AuthException(self)
+
+            if response is not None:
+                access_token = response.get('access_token') or \
+                               response.get('oauth_token') or \
+                               self.data.get('access_token')
+
+        if access_token is None:
+            if self.data.get('error') == 'access_denied':
+                raise AuthCanceled(self)
+            else:
+                raise AuthException(self)
+        return self.do_auth(access_token, response, *args, **kwargs)
+
+    def auth_html(self):
+        key, secret = self.get_key_and_secret()
+        namespace = self.setting('NAMESPACE', None)
+        scope = self.setting('SCOPE', '')
+        if scope:
+            scope = self.SCOPE_SEPARATOR.join(scope)
+        ctx = {
+            'FACEBOOK_APP_NAMESPACE': namespace or key,
+            'FACEBOOK_KEY': key,
+            'FACEBOOK_EXTENDED_PERMISSIONS': scope,
+            'FACEBOOK_COMPLETE_URI': self.redirect_uri,
+        }
+        tpl = self.setting('LOCAL_HTML', 'facebook.html')
+        return self.strategy.render_html(tpl=tpl, context=ctx)
+
+    def load_signed_request(self, signed_request):
+        def base64_url_decode(data):
+            data = data.encode('ascii')
+            data += '='.encode('ascii') * (4 - (len(data) % 4))
+            return base64.urlsafe_b64decode(data)
+
+        key, secret = self.get_key_and_secret()
+        try:
+            sig, payload = signed_request.split('.', 1)
+        except ValueError:
+            pass  # ignore if can't split on dot
+        else:
+            sig = base64_url_decode(sig)
+            payload_json_bytes = base64_url_decode(payload)
+            data = json.loads(payload_json_bytes.decode('utf-8', 'replace'))
+            expected_sig = hmac.new(secret.encode('ascii'),
+                                    msg=payload.encode('ascii'),
+                                    digestmod=hashlib.sha256).digest()
+            # allow the signed_request to function for upto 1 day
+            if constant_time_compare(sig, expected_sig) and \
+               data['issued_at'] > (time.time() - 86400):
+                return data
diff --git a/rootfs/api/apps_extra/social_core/backends/fedora.py b/rootfs/api/apps_extra/social_core/backends/fedora.py
new file mode 100644
index 000000000..cfd6d225a
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/fedora.py
@@ -0,0 +1,11 @@
+"""
+Fedora OpenId backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/fedora.html
+"""
+from .open_id import OpenIdAuth
+
+
+class FedoraOpenId(OpenIdAuth):
+    name = 'fedora'
+    URL = 'https://id.fedoraproject.org'
+    USERNAME_KEY = 'nickname'
diff --git a/rootfs/api/apps_extra/social_core/backends/fence.py b/rootfs/api/apps_extra/social_core/backends/fence.py
new file mode 100644
index 000000000..1b498f309
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/fence.py
@@ -0,0 +1,38 @@
+from urllib.parse import urljoin
+
+from social_core.utils import cache
+
+from ..utils import append_slash
+from .open_id_connect import OpenIdConnectAuth
+
+
+class Fence(OpenIdConnectAuth):
+
+    name = 'fence'
+    OIDC_ENDPOINT = 'https://nci-crdc.datacommons.io'
+    ID_KEY = 'username'
+    ACCESS_TOKEN_METHOD = 'POST'
+    DEFAULT_SCOPE = ['openid', 'user']
+    JWT_DECODE_OPTIONS = {'verify_at_hash': False}
+
+    def _url(self, path):
+        return urljoin(append_slash(self.OIDC_ENDPOINT), path)
+
+    def authorization_url(self):
+        return self._url('user/oauth2/authorize')
+
+    def access_token_url(self):
+        return self._url('user/oauth2/token')
+
+    @cache(ttl=86400)
+    def oidc_config(self):
+        return self.get_json(self._url('.well-known/openid-configuration'))
+
+    def get_user_details(self, response):
+        return {
+            'username': response.get('preferred_username'),
+            'email': response.get('username'),
+            'fullname': response.get('name'),
+            'first_name': response.get('given_name'),
+            'last_name': response.get('family_name'),
+        }
diff --git a/rootfs/api/apps_extra/social_core/backends/fitbit.py b/rootfs/api/apps_extra/social_core/backends/fitbit.py
new file mode 100644
index 000000000..a2f4cde35
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/fitbit.py
@@ -0,0 +1,67 @@
+"""
+Fitbit OAuth backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/fitbit.html
+"""
+import base64
+
+from .oauth import BaseOAuth1, BaseOAuth2
+
+
+class FitbitOAuth1(BaseOAuth1):
+    """Fitbit OAuth1 authentication backend"""
+    name = 'fitbit'
+    AUTHORIZATION_URL = 'https://www.fitbit.com/oauth/authorize'
+    REQUEST_TOKEN_URL = 'https://api.fitbit.com/oauth/request_token'
+    ACCESS_TOKEN_URL = 'https://api.fitbit.com/oauth/access_token'
+    ID_KEY = 'encodedId'
+    EXTRA_DATA = [('encodedId', 'id'),
+                  ('displayName', 'username')]
+
+    def get_user_details(self, response):
+        """Return user details from Fitbit account"""
+        return {'username': response.get('displayName'),
+                'email': ''}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        return self.get_json(
+            'https://api.fitbit.com/1/user/-/profile.json',
+            auth=self.oauth_auth(access_token)
+        )['user']
+
+
+class FitbitOAuth2(BaseOAuth2):
+    """Fitbit OAuth2 authentication backend"""
+    name = 'fitbit'
+    AUTHORIZATION_URL = 'https://www.fitbit.com/oauth2/authorize'
+    ACCESS_TOKEN_URL = 'https://api.fitbit.com/oauth2/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    REFRESH_TOKEN_URL = 'https://api.fitbit.com/oauth2/token'
+    DEFAULT_SCOPE = ['profile']
+    ID_KEY = 'encodedId'
+    REDIRECT_STATE = False
+    EXTRA_DATA = [('expires_in', 'expires'),
+                  ('refresh_token', 'refresh_token', True),
+                  ('encodedId', 'id'),
+                  ('displayName', 'username')]
+
+    def get_user_details(self, response):
+        """Return user details from Fitbit account"""
+        return {'username': response.get('displayName'),
+                'email': ''}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        auth_header = {'Authorization': 'Bearer %s' % access_token}
+        return self.get_json(
+            'https://api.fitbit.com/1/user/-/profile.json',
+            headers=auth_header
+        )['user']
+
+    def auth_headers(self):
+        tokens = '{0}:{1}'.format(*self.get_key_and_secret())
+        tokens = base64.urlsafe_b64encode(tokens.encode())
+        tokens = tokens.decode()
+        return {
+            'Authorization': 'Basic {0}'.format(tokens)
+        }
diff --git a/rootfs/api/apps_extra/social_core/backends/five_hundred_px.py b/rootfs/api/apps_extra/social_core/backends/five_hundred_px.py
new file mode 100644
index 000000000..8c36bc57f
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/five_hundred_px.py
@@ -0,0 +1,34 @@
+"""
+500px OAuth1 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/five_hundred_px.html
+"""
+from .oauth import BaseOAuth1
+
+
+class FiveHundredPxOAuth(BaseOAuth1):
+    """500px OAuth authentication backend"""
+    name = '500px'
+    AUTHORIZATION_URL = 'https://api.500px.com/v1/oauth/authorize'
+    REQUEST_TOKEN_URL = 'https://api.500px.com/v1/oauth/request_token'
+    ACCESS_TOKEN_URL = 'https://api.500px.com/v1/oauth/access_token'
+
+    def get_user_details(self, user):
+        """Return user details from 500px account"""
+        fullname, first_name, last_name = self.get_user_names(
+            user.get('fullname')
+        )
+        return {
+            'username': user.get('username') or user.get('id'),
+            'email': user.get('email'),
+            'fullname': fullname,
+            'first_name': first_name,
+            'last_name': last_name
+        }
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Return user data provided"""
+        response = self.get_json(
+            'https://api.500px.com/v1/users',
+            auth=self.oauth_auth(access_token)
+        )
+        return response.get('user')
diff --git a/rootfs/api/apps_extra/social_core/backends/flat.py b/rootfs/api/apps_extra/social_core/backends/flat.py
new file mode 100644
index 000000000..d8e59e256
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/flat.py
@@ -0,0 +1,31 @@
+"""
+Flat OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/flat.html
+"""
+from .oauth import BaseOAuth2
+
+
+class FlatOAuth2(BaseOAuth2):
+    """Flat OAuth2"""
+    name = 'flat'
+    DEFAULT_SCOPE = ['account.public_profile']
+    AUTHORIZATION_URL = 'https://flat.io/auth/oauth'
+    ACCESS_TOKEN_URL = 'https://api.flat.io/oauth/access_token'
+    ACCESS_TOKEN_METHOD = 'POST'
+
+    def get_user_id(self, details, response):
+        return response.get('id')
+
+    def get_user_details(self, response):
+        """Return user details from Flat account"""
+        return {
+            'email': response.get('email'),
+            'username': response.get('username'),
+            'fullname': response.get('printableName')
+        }
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        return self.get_json('https://api.flat.io/v2/me', headers={
+            'Authorization': 'Bearer ' + access_token
+        })
diff --git a/rootfs/api/apps_extra/social_core/backends/flickr.py b/rootfs/api/apps_extra/social_core/backends/flickr.py
new file mode 100644
index 000000000..ef8eb8762
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/flickr.py
@@ -0,0 +1,43 @@
+"""
+Flickr OAuth1 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/flickr.html
+"""
+from .oauth import BaseOAuth1
+
+
+class FlickrOAuth(BaseOAuth1):
+    """Flickr OAuth authentication backend"""
+    name = 'flickr'
+    AUTHORIZATION_URL = 'https://www.flickr.com/services/oauth/authorize'
+    REQUEST_TOKEN_URL = 'https://www.flickr.com/services/oauth/request_token'
+    ACCESS_TOKEN_URL = 'https://www.flickr.com/services/oauth/access_token'
+    EXTRA_DATA = [
+        ('id', 'id'),
+        ('username', 'username'),
+        ('expires', 'expires')
+    ]
+
+    def get_user_details(self, response):
+        """Return user details from Flickr account"""
+        fullname, first_name, last_name = self.get_user_names(
+            response.get('fullname')
+        )
+        return {'username': response.get('username') or response.get('id'),
+                'email': '',
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        return {
+            'id': access_token['user_nsid'],
+            'username': access_token['username'],
+            'fullname': access_token.get('fullname', ''),
+        }
+
+    def auth_extra_arguments(self):
+        params = super().auth_extra_arguments() or {}
+        if 'perms' not in params:
+            params['perms'] = 'read'
+        return params
diff --git a/rootfs/api/apps_extra/social_core/backends/foursquare.py b/rootfs/api/apps_extra/social_core/backends/foursquare.py
new file mode 100644
index 000000000..3ed4302e5
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/foursquare.py
@@ -0,0 +1,36 @@
+"""
+Foursquare OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/foursquare.html
+"""
+from .oauth import BaseOAuth2
+
+
+class FoursquareOAuth2(BaseOAuth2):
+    name = 'foursquare'
+    AUTHORIZATION_URL = 'https://foursquare.com/oauth2/authenticate'
+    ACCESS_TOKEN_URL = 'https://foursquare.com/oauth2/access_token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    API_VERSION = '20140128'
+
+    def get_user_id(self, details, response):
+        return response['response']['user']['id']
+
+    def get_user_details(self, response):
+        """Return user details from Foursquare account"""
+        info = response['response']['user']
+        email = info['contact']['email']
+        fullname, first_name, last_name = self.get_user_names(
+            first_name=info.get('firstName', ''),
+            last_name=info.get('lastName', '')
+        )
+        return {'username': first_name + ' ' + last_name,
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name,
+                'email': email}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        return self.get_json('https://api.foursquare.com/v2/users/self',
+                             params={'oauth_token': access_token,
+                                     'v': self.API_VERSION})
diff --git a/rootfs/api/apps_extra/social_core/backends/gae.py b/rootfs/api/apps_extra/social_core/backends/gae.py
new file mode 100644
index 000000000..a6aa8e3e3
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/gae.py
@@ -0,0 +1,40 @@
+"""
+Google App Engine support using User API
+"""
+from __future__ import absolute_import
+
+from google.appengine.api import users
+
+from .base import BaseAuth
+from ..exceptions import AuthException
+
+
+class GoogleAppEngineAuth(BaseAuth):
+    """GoogleAppengine authentication backend"""
+    name = 'google-appengine'
+
+    def get_user_id(self, details, response):
+        """Return current user id."""
+        user = users.get_current_user()
+        if user:
+            return user.user_id()
+
+    def get_user_details(self, response):
+        """Return user basic information (id and email only)."""
+        user = users.get_current_user()
+        return {'username': user.user_id(),
+                'email': user.email(),
+                'fullname': '',
+                'first_name': '',
+                'last_name': ''}
+
+    def auth_url(self):
+        """Build and return complete URL."""
+        return users.create_login_url(self.redirect_uri)
+
+    def auth_complete(self, *args, **kwargs):
+        """Completes login process, must return user instance."""
+        if not users.get_current_user():
+            raise AuthException('Authentication error')
+        kwargs.update({'response': '', 'backend': self})
+        return self.strategy.authenticate(*args, **kwargs)
diff --git a/rootfs/api/apps_extra/social_core/backends/github.py b/rootfs/api/apps_extra/social_core/backends/github.py
new file mode 100644
index 000000000..42a8dac6c
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/github.py
@@ -0,0 +1,147 @@
+"""
+Github OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/github.html
+"""
+from urllib.parse import urljoin
+
+from requests import HTTPError
+
+from .oauth import BaseOAuth2
+from ..exceptions import AuthFailed
+
+
+class GithubOAuth2(BaseOAuth2):
+    """Github OAuth authentication backend"""
+    name = 'github'
+    API_URL = 'https://api.github.com/'
+    AUTHORIZATION_URL = 'https://github.com/login/oauth/authorize'
+    ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    SCOPE_SEPARATOR = ','
+    REDIRECT_STATE = False
+    STATE_PARAMETER = True
+    SEND_USER_AGENT = True
+    EXTRA_DATA = [
+        ('id', 'id'),
+        ('expires', 'expires'),
+        ('login', 'login')
+    ]
+
+    def api_url(self):
+        return self.API_URL
+
+    def get_user_details(self, response):
+        """Return user details from Github account"""
+        fullname, first_name, last_name = self.get_user_names(
+            response.get('name')
+        )
+        return {'username': response.get('login'),
+                'email': response.get('email') or '',
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        data = self._user_data(access_token)
+        if not data.get('email'):
+            try:
+                emails = self._user_data(access_token, '/emails')
+            except (HTTPError, ValueError, TypeError):
+                emails = []
+
+            if emails:
+                email = emails[0]
+                primary_emails = [
+                    e for e in emails
+                    if not isinstance(e, dict) or e.get('primary')
+                ]
+                if primary_emails:
+                    email = primary_emails[0]
+                if isinstance(email, dict):
+                    email = email.get('email', '')
+                data['email'] = email
+        return data
+
+    def _user_data(self, access_token, path=None):
+        url = urljoin(self.api_url(), 'user{0}'.format(path or ''))
+        return self.get_json(url, headers={'Authorization': 'token {0}'.format(access_token)})
+
+
+class GithubMemberOAuth2(GithubOAuth2):
+    no_member_string = ''
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        user_data = super().user_data(access_token, *args, **kwargs)
+        headers = {'Authorization': 'token {0}'.format(access_token)}
+        try:
+            self.request(self.member_url(user_data), headers=headers)
+        except HTTPError as err:
+            # if the user is a member of the organization, response code
+            # will be 204, see http://bit.ly/ZS6vFl
+            if err.response.status_code != 204:
+                raise AuthFailed(self,
+                                 'User doesn\'t belong to the organization')
+        return user_data
+
+    def member_url(self, user_data):
+        raise NotImplementedError('Implement in subclass')
+
+
+class GithubOrganizationOAuth2(GithubMemberOAuth2):
+    """Github OAuth2 authentication backend for organizations"""
+    name = 'github-org'
+    no_member_string = 'User doesn\'t belong to the organization'
+
+    def member_url(self, user_data):
+        return urljoin(
+            self.api_url(),
+            'orgs/{org}/members/{username}'.format(
+                org=self.setting('NAME'),
+                username=user_data.get('login')
+            )
+        )
+
+
+class GithubTeamOAuth2(GithubMemberOAuth2):
+    """Github OAuth2 authentication backend for teams"""
+    name = 'github-team'
+    no_member_string = 'User doesn\'t belong to the team'
+
+    def member_url(self, user_data):
+        return urljoin(
+            self.api_url(),
+            'teams/{team_id}/members/{username}'.format(
+                team_id=self.setting('ID'),
+                username=user_data.get('login')
+            )
+        )
+
+
+class GithubAppAuth(GithubOAuth2):
+    """GitHub App OAuth authentication backend"""
+    name = 'github-app'
+
+    def validate_state(self):
+        """
+        Scenario 1: user clicks an icon/button on your website and initiates
+                    social login. This works exacltly like standard OAuth and we
+                    have `state` and `redirect_uri`.
+
+        Scenario 2: user starts from http://github.com/apps/your-app and clicks
+                    'Install & Authorize' button! They still get a temporary
+                    `code` (used to fetch `access_token`) but there's no `state`
+                    or `redirect_uri` here.
+
+        Note: Scenario 2 only happens when your GitHub App is configured
+              with `Request user authorization (OAuth) during installation`
+              turned on! This causes GitHub to redirect the person back to
+              `/complete/github/`. If the above setting is turned off then
+              GitHub will redirect to another URL called Setup URL and the
+              person may need to login first before they can continue!
+        """
+        if self.data.get('installation_id') and self.data.get('setup_action'):
+            return None
+
+        return super().validate_state()
diff --git a/rootfs/api/apps_extra/social_core/backends/github_enterprise.py b/rootfs/api/apps_extra/social_core/backends/github_enterprise.py
new file mode 100644
index 000000000..00a18babc
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/github_enterprise.py
@@ -0,0 +1,42 @@
+"""
+Github Enterprise OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/github_enterprise.html
+"""
+from urllib.parse import urljoin
+
+from ..utils import append_slash
+from .github import GithubOAuth2, GithubOrganizationOAuth2, \
+                    GithubTeamOAuth2
+
+
+class GithubEnterpriseMixin:
+    def api_url(self):
+        return append_slash(self.setting('API_URL'))
+
+    def authorization_url(self):
+        return self._url('login/oauth/authorize')
+
+    def access_token_url(self):
+        return self._url('login/oauth/access_token')
+
+    def _url(self, path):
+        return urljoin(append_slash(self.setting('URL')), path)
+
+
+class GithubEnterpriseOAuth2(GithubEnterpriseMixin, GithubOAuth2):
+    """Github Enterprise OAuth authentication backend"""
+    name = 'github-enterprise'
+
+
+class GithubEnterpriseOrganizationOAuth2(GithubEnterpriseMixin,
+                                         GithubOrganizationOAuth2):
+    """Github Enterprise OAuth2 authentication backend for
+    organizations"""
+    name = 'github-enterprise-org'
+    DEFAULT_SCOPE = ['read:org']
+
+
+class GithubEnterpriseTeamOAuth2(GithubEnterpriseMixin, GithubTeamOAuth2):
+    """Github Enterprise OAuth2 authentication backend for teams"""
+    name = 'github-enterprise-team'
+    DEFAULT_SCOPE = ['read:org']
diff --git a/rootfs/api/apps_extra/social_core/backends/gitlab.py b/rootfs/api/apps_extra/social_core/backends/gitlab.py
new file mode 100644
index 000000000..13f515002
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/gitlab.py
@@ -0,0 +1,53 @@
+"""
+GitLab OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/gitlab.html
+
+Thanks to [@saily](https://github.com/saily) who published an
+implementation for GitLab support on his blog post [Weblate with
+GitLab as OAuth provider](http://widerin.net/blog/weblate-gitlab-oauth-login/).
+His code was a great reference when working on this implementation.
+"""
+from .oauth import BaseOAuth2
+
+
+class GitLabOAuth2(BaseOAuth2):
+    """GitLab OAuth authentication backend"""
+    name = 'gitlab'
+    API_URL = 'https://gitlab.com'
+    AUTHORIZATION_URL = 'https://gitlab.com/oauth/authorize'
+    ACCESS_TOKEN_URL = 'https://gitlab.com/oauth/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    REDIRECT_STATE = False
+    DEFAULT_SCOPE = ['read_user']
+    EXTRA_DATA = [
+        ('id', 'id'),
+        ('expires_in', 'expires'),
+        ('refresh_token', 'refresh_token')
+    ]
+
+    def api_url(self, path):
+        api_url = self.setting('API_URL') or self.API_URL
+        return '{0}{1}'.format(api_url.rstrip('/'), path)
+
+    def authorization_url(self):
+        return self.api_url('/oauth/authorize')
+
+    def access_token_url(self):
+        return self.api_url('/oauth/token')
+
+    def get_user_details(self, response):
+        """Return user details from GitLab account"""
+        fullname, first_name, last_name = self.get_user_names(
+            response.get('name')
+        )
+        return {'username': response.get('username'),
+                'email': response.get('email') or '',
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        return self.get_json(self.api_url('/api/v4/user'), params={
+            'access_token': access_token
+        })
diff --git a/rootfs/api/apps_extra/social_core/backends/globus.py b/rootfs/api/apps_extra/social_core/backends/globus.py
new file mode 100644
index 000000000..a9368081e
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/globus.py
@@ -0,0 +1,28 @@
+"""
+Globus Auth OpenID Connect backend, docs at:
+    https://docs.globus.org/api/auth
+    http://globus-integration-examples.readthedocs.io
+"""
+
+from social_core.backends.open_id_connect import OpenIdConnectAuth
+
+
+class GlobusOpenIdConnect(OpenIdConnectAuth):
+    name = 'globus'
+    OIDC_ENDPOINT = 'https://auth.globus.org'
+    EXTRA_DATA = [
+        ('expires_in', 'expires_in', True),
+        ('refresh_token', 'refresh_token', True),
+        ('id_token', 'id_token', True),
+        ('other_tokens', 'other_tokens', True),
+    ]
+
+    def get_user_details(self, response):
+        username_key = self.setting('USERNAME_KEY', default=self.USERNAME_KEY)
+        name = response.get('name') or ''
+        fullname, first_name, last_name = self.get_user_names(name)
+        return {'username': response.get(username_key),
+                'email': response.get('email'),
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
diff --git a/rootfs/api/apps_extra/social_core/backends/goclio.py b/rootfs/api/apps_extra/social_core/backends/goclio.py
new file mode 100644
index 000000000..4d75c1ca6
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/goclio.py
@@ -0,0 +1,35 @@
+from .oauth import BaseOAuth2
+
+
+class GoClioOAuth2(BaseOAuth2):
+    name = 'goclio'
+    AUTHORIZATION_URL = 'https://app.goclio.com/oauth/authorize/'
+    ACCESS_TOKEN_METHOD = 'POST'
+    ACCESS_TOKEN_URL = 'https://app.goclio.com/oauth/token/'
+    REDIRECT_STATE = False
+    STATE_PARAMETER = False
+
+    def get_user_details(self, response):
+        """Return user details from GoClio account"""
+        user = response.get('user', {})
+        username = user.get('id', None)
+        email = user.get('email', None)
+        first_name, last_name = (user.get('first_name', None),
+                                 user.get('last_name', None))
+        fullname = '%s %s' % (first_name, last_name)
+
+        return {'username': username,
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name,
+                'email': email}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        return self.get_json(
+            'https://app.goclio.com/api/v2/users/who_am_i',
+            params={'access_token': access_token}
+        )
+
+    def get_user_id(self, details, response):
+        return response.get('user', {}).get('id')
diff --git a/rootfs/api/apps_extra/social_core/backends/goclioeu.py b/rootfs/api/apps_extra/social_core/backends/goclioeu.py
new file mode 100644
index 000000000..0b25ddff8
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/goclioeu.py
@@ -0,0 +1,14 @@
+from .goclio import GoClioOAuth2
+
+
+class GoClioEuOAuth2(GoClioOAuth2):
+    name = 'goclioeu'
+    AUTHORIZATION_URL = 'https://app.goclio.eu/oauth/authorize/'
+    ACCESS_TOKEN_URL = 'https://app.goclio.eu/oauth/token/'
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        return self.get_json(
+            'https://app.goclio.eu/api/v2/users/who_am_i',
+            params={'access_token': access_token}
+        )
diff --git a/rootfs/api/apps_extra/social_core/backends/google.py b/rootfs/api/apps_extra/social_core/backends/google.py
new file mode 100644
index 000000000..28acdc671
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/google.py
@@ -0,0 +1,169 @@
+"""
+Google OpenId, OAuth2, OAuth1, Google+ Sign-in backends, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/google.html
+"""
+from ..utils import handle_http_errors
+from .oauth import BaseOAuth2, BaseOAuth1
+from ..exceptions import AuthMissingParameter
+
+
+class BaseGoogleAuth:
+    def get_user_id(self, details, response):
+        """Use google email as unique id"""
+        if self.setting('USE_UNIQUE_USER_ID', False):
+            if 'sub' in response:
+                return response['sub']
+            else:
+                return response['id']
+        else:
+            return details['email']
+
+    def get_user_details(self, response):
+        """Return user details from Google API account"""
+        if 'email' in response:
+            email = response['email']
+        else:
+            email = ''
+
+        name, given_name, family_name = (
+            response.get('name', ''),
+            response.get('given_name', ''),
+            response.get('family_name', ''),
+        )
+
+        fullname, first_name, last_name = self.get_user_names(
+            name, given_name, family_name
+        )
+        return {'username': email.split('@', 1)[0],
+                'email': email,
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
+
+
+class BaseGoogleOAuth2API(BaseGoogleAuth):
+    def user_data(self, access_token, *args, **kwargs):
+        """Return user data from Google API"""
+        return self.get_json(
+            'https://www.googleapis.com/oauth2/v3/userinfo',
+            headers={
+                'Authorization': 'Bearer %s' % access_token,
+            },
+        )
+
+    def revoke_token_params(self, token, uid):
+        return {'token': token}
+
+    def revoke_token_headers(self, token, uid):
+        return {'Content-type': 'application/json'}
+
+
+class GoogleOAuth2(BaseGoogleOAuth2API, BaseOAuth2):
+    """Google OAuth2 authentication backend"""
+    name = 'google-oauth2'
+    REDIRECT_STATE = False
+    AUTHORIZATION_URL = 'https://accounts.google.com/o/oauth2/auth'
+    ACCESS_TOKEN_URL = 'https://accounts.google.com/o/oauth2/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    REVOKE_TOKEN_URL = 'https://accounts.google.com/o/oauth2/revoke'
+    REVOKE_TOKEN_METHOD = 'GET'
+    # The order of the default scope is important
+    DEFAULT_SCOPE = ['openid', 'email', 'profile']
+    EXTRA_DATA = [
+        ('refresh_token', 'refresh_token', True),
+        ('expires_in', 'expires'),
+        ('token_type', 'token_type', True)
+    ]
+
+
+class GooglePlusAuth(BaseGoogleOAuth2API, BaseOAuth2):
+    name = 'google-plus'
+    REDIRECT_STATE = False
+    STATE_PARAMETER = False
+    AUTHORIZATION_URL = 'https://accounts.google.com/o/oauth2/auth'
+    ACCESS_TOKEN_URL = 'https://accounts.google.com/o/oauth2/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    REVOKE_TOKEN_URL = 'https://accounts.google.com/o/oauth2/revoke'
+    REVOKE_TOKEN_METHOD = 'GET'
+    DEFAULT_SCOPE = [
+        'https://www.googleapis.com/auth/plus.login',
+        'https://www.googleapis.com/auth/plus.me',
+    ]
+    EXTRA_DATA = [
+        ('id', 'user_id'),
+        ('refresh_token', 'refresh_token', True),
+        ('expires_in', 'expires'),
+        ('access_type', 'access_type', True),
+        ('code', 'code')
+    ]
+
+    def auth_complete_params(self, state=None):
+        params = super().auth_complete_params(state)
+        if self.data.get('access_token'):
+            # Don't add postmessage if this is plain server-side workflow
+            params['redirect_uri'] = 'postmessage'
+        return params
+
+    @handle_http_errors
+    def auth_complete(self, *args, **kwargs):
+        if 'access_token' in self.data:  # Client-side workflow
+            token = self.data.get('access_token')
+            response = self.get_json(
+                'https://www.googleapis.com/oauth2/v3/tokeninfo',
+                params={'access_token': token}
+            )
+            self.process_error(response)
+            return self.do_auth(token, response=response, *args, **kwargs)
+        elif 'code' in self.data:  # Server-side workflow
+            response = self.request_access_token(
+                self.ACCESS_TOKEN_URL,
+                data=self.auth_complete_params(),
+                headers=self.auth_headers(),
+                method=self.ACCESS_TOKEN_METHOD
+            )
+            self.process_error(response)
+            return self.do_auth(response['access_token'],
+                                response=response,
+                                *args, **kwargs)
+        elif 'id_token' in self.data:  # Client-side workflow
+            token = self.data.get('id_token')
+            return self.do_auth(token, *args, **kwargs)
+        else:
+            raise AuthMissingParameter(self, 'access_token, id_token, or code')
+
+    def user_data(self, access_token, *args, **kwargs):
+        if 'id_token' not in self.data:
+            return super().user_data(access_token, *args, **kwargs)
+        response = self.get_json(
+            'https://www.googleapis.com/oauth2/v3/tokeninfo',
+            params={'id_token': access_token}
+        )
+        self.process_error(response)
+        return response
+
+
+class GoogleOAuth(BaseGoogleAuth, BaseOAuth1):
+    """Google OAuth authorization mechanism"""
+    name = 'google-oauth'
+    AUTHORIZATION_URL = 'https://www.google.com/accounts/OAuthAuthorizeToken'
+    REQUEST_TOKEN_URL = 'https://www.google.com/accounts/OAuthGetRequestToken'
+    ACCESS_TOKEN_URL = 'https://www.google.com/accounts/OAuthGetAccessToken'
+    DEFAULT_SCOPE = ['https://www.googleapis.com/auth/userinfo#email']
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Return user data from Google API"""
+        return self.get_querystring(
+            'https://www.googleapis.com/userinfo/email',
+            auth=self.oauth_auth(access_token)
+        )
+
+    def get_key_and_secret(self):
+        """Return Google OAuth Consumer Key and Consumer Secret pair, uses
+        anonymous by default, beware that this marks the application as not
+        registered and a security badge is displayed on authorization page.
+        http://code.google.com/apis/accounts/docs/OAuth_ref.html#SigningOAuth
+        """
+        key_secret = super().get_key_and_secret()
+        if key_secret == (None, None):
+            key_secret = ('anonymous', 'anonymous')
+        return key_secret
diff --git a/rootfs/api/apps_extra/social_core/backends/google_openidconnect.py b/rootfs/api/apps_extra/social_core/backends/google_openidconnect.py
new file mode 100644
index 000000000..840738576
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/google_openidconnect.py
@@ -0,0 +1,21 @@
+"""
+Google OpenIdConnect:
+    https://python-social-auth.readthedocs.io/en/latest/backends/google.html
+"""
+from .open_id_connect import OpenIdConnectAuth
+from .google import GoogleOAuth2
+
+
+class GoogleOpenIdConnect(GoogleOAuth2, OpenIdConnectAuth):
+    name = 'google-openidconnect'
+    OIDC_ENDPOINT = 'https://accounts.google.com'
+    # differs from value in discovery document
+    # http://openid.net/specs/openid-connect-core-1_0.html#rfc.section.15.6.2
+    ID_TOKEN_ISSUER = 'accounts.google.com'
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Return user data from Google API"""
+        return self.get_json(
+            'https://openidconnect.googleapis.com/v1/userinfo',
+            params={'access_token': access_token, 'alt': 'json'}
+        )
diff --git a/rootfs/api/apps_extra/social_core/backends/hubspot.py b/rootfs/api/apps_extra/social_core/backends/hubspot.py
new file mode 100644
index 000000000..a81638222
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/hubspot.py
@@ -0,0 +1,34 @@
+"""
+HubSpot OAuth2 backend, docs at:
+    https://developers.hubspot.com/docs/methods/oauth2/oauth2-overview
+"""
+from .oauth import BaseOAuth2
+
+
+class HubSpotOAuth2(BaseOAuth2):
+    """HubSpot OAuth2 authentication backend"""
+    name = 'hubspot'
+    AUTHORIZATION_URL = 'https://app.hubspot.com/oauth/authorize'
+    ACCESS_TOKEN_URL = 'https://api.hubapi.com/oauth/v1/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    USER_DATA_URL = 'https://api.hubapi.com/oauth/v1/access-tokens/'
+    DEFAULT_SCOPE = ['oauth']
+    EXTRA_DATA = [
+        ('hub_domain', 'hub_domain'),
+        ('hub_id', 'hub_id'),
+        ('app_id', 'app_id'),
+        ('user_id', 'user_id'),
+        ('refresh_token', 'refresh_token'),
+        ('expires_in', 'expires')
+    ]
+
+    def get_user_details(self, response):
+        """Return user details"""
+        response['email'] = response['user']
+        return response
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data information from service"""
+        return self.get_json(self.USER_DATA_URL + access_token, headers={
+          'Authorization': 'Bearer ' + access_token
+        })
diff --git a/rootfs/api/apps_extra/social_core/backends/instagram.py b/rootfs/api/apps_extra/social_core/backends/instagram.py
new file mode 100644
index 000000000..d5934be2c
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/instagram.py
@@ -0,0 +1,44 @@
+"""
+Instagram OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/instagram.html
+"""
+from .oauth import BaseOAuth2
+
+
+class InstagramOAuth2(BaseOAuth2):
+    name = 'instagram'
+    AUTHORIZATION_URL = 'https://api.instagram.com/oauth/authorize'
+    ACCESS_TOKEN_URL = 'https://api.instagram.com/oauth/access_token'
+    ACCESS_TOKEN_METHOD = 'POST'
+
+    def get_user_id(self, details, response):
+        user = response.get('user') or {}
+        return user.get('id')
+
+    def get_user_details(self, response):
+        """Return user details from Instagram account"""
+        user = response.get('user') or {}
+        username = user['username']
+        email = user.get('email', '')
+        fullname, first_name, last_name = self.get_user_names(
+            user.get('full_name', '')
+        )
+        return {'username': username,
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name,
+                'email': email}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        # For more fields see:
+        # https://developers.facebook.com/docs/instagram-basic-display-api/reference/user#fields
+        # In fact there are not very many of them.
+        fields = 'id,username'
+        params = {'access_token': access_token, 'fields': fields}
+        response = self.get_json('https://graph.instagram.com/me',
+                             params=params)
+        return {'user': response}
+
+    def auth_html(self):
+        pass
diff --git a/rootfs/api/apps_extra/social_core/backends/itembase.py b/rootfs/api/apps_extra/social_core/backends/itembase.py
new file mode 100644
index 000000000..7938e9324
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/itembase.py
@@ -0,0 +1,86 @@
+import time
+
+from .oauth import BaseOAuth2
+from ..utils import handle_http_errors
+
+
+class ItembaseOAuth2(BaseOAuth2):
+    name = 'itembase'
+    ID_KEY = 'uuid'
+    AUTHORIZATION_URL = 'https://accounts.itembase.com/oauth/v2/auth'
+    ACCESS_TOKEN_URL = 'https://accounts.itembase.com/oauth/v2/token'
+    USER_DETAILS_URL = 'https://users.itembase.com/v1/me'
+    ACTIVATION_ENDPOINT = 'https://solutionservice.itembase.com/activate'
+    DEFAULT_SCOPE = ['user.minimal']
+    EXTRA_DATA = [
+        ('access_token', 'access_token'),
+        ('token_type', 'token_type'),
+        ('refresh_token', 'refresh_token'),
+        ('expires_in', 'expires_in'),  # seconds to expiration
+        ('expires', 'expires'),  # expiration timestamp in UTC
+        ('uuid', 'uuid'),
+        ('username', 'username'),
+        ('email', 'email'),
+        ('first_name', 'first_name'),
+        ('middle_name', 'middle_name'),
+        ('last_name', 'last_name'),
+        ('name_format', 'name_format'),
+        ('locale', 'locale'),
+        ('preferred_currency', 'preferred_currency'),
+    ]
+
+    def add_expires(self, data):
+        data['expires'] = int(time.time()) + data.get('expires_in', 0)
+        return data
+
+    def extra_data(self, user, uid, response, details=None, *args, **kwargs):
+        data = BaseOAuth2.extra_data(self, user, uid, response,
+                                     details=details,
+                                     *args, **kwargs)
+        return self.add_expires(data)
+
+    def process_refresh_token_response(self, response, *args, **kwargs):
+        data = BaseOAuth2.process_refresh_token_response(self, response,
+                                                         *args, **kwargs)
+        return self.add_expires(data)
+
+    def get_user_details(self, response):
+        """Return user details from Itembase account"""
+        return response
+
+    def user_data(self, access_token, *args, **kwargs):
+        return self.get_json(self.USER_DETAILS_URL, headers={
+            'Authorization': 'Bearer {0}'.format(access_token)
+        })
+
+    def activation_data(self, response):
+        # returns activation_data dict with activation_url inside
+        # see http://developers.itembase.com/authentication/activation
+        return self.get_json(self.ACTIVATION_ENDPOINT, headers={
+            'Authorization': 'Bearer {0}'.format(response['access_token'])
+        })
+
+    @handle_http_errors
+    def auth_complete(self, *args, **kwargs):
+        """Completes login process, must return user instance"""
+        state = self.validate_state()
+        self.process_error(self.data)
+        # itembase needs GET request with params instead of just data
+        response = self.request_access_token(
+            self.access_token_url(),
+            params=self.auth_complete_params(state),
+            headers=self.auth_headers(),
+            auth=self.auth_complete_credentials(),
+            method=self.ACCESS_TOKEN_METHOD
+        )
+        self.process_error(response)
+        return self.do_auth(response['access_token'], response=response,
+                            *args, **kwargs)
+
+
+class ItembaseOAuth2Sandbox(ItembaseOAuth2):
+    name = 'itembase-sandbox'
+    AUTHORIZATION_URL = 'http://sandbox.accounts.itembase.io/oauth/v2/auth'
+    ACCESS_TOKEN_URL = 'http://sandbox.accounts.itembase.io/oauth/v2/token'
+    USER_DETAILS_URL = 'http://sandbox.users.itembase.io/v1/me'
+    ACTIVATION_ENDPOINT = 'http://sandbox.solutionservice.itembase.io/activate'
diff --git a/rootfs/api/apps_extra/social_core/backends/jawbone.py b/rootfs/api/apps_extra/social_core/backends/jawbone.py
new file mode 100644
index 000000000..9805b5f02
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/jawbone.py
@@ -0,0 +1,77 @@
+"""
+Jawbone OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/jawbone.html
+"""
+from ..utils import handle_http_errors
+from .oauth import BaseOAuth2
+from ..exceptions import AuthCanceled, AuthUnknownError
+
+
+class JawboneOAuth2(BaseOAuth2):
+    name = 'jawbone'
+    AUTHORIZATION_URL = 'https://jawbone.com/auth/oauth2/auth'
+    ACCESS_TOKEN_URL = 'https://jawbone.com/auth/oauth2/token'
+    SCOPE_SEPARATOR = ' '
+    REDIRECT_STATE = False
+
+    def get_user_id(self, details, response):
+        return response['data']['xid']
+
+    def get_user_details(self, response):
+        """Return user details from Jawbone account"""
+        data = response['data']
+        fullname, first_name, last_name = self.get_user_names(
+            first_name=data.get('first', ''),
+            last_name=data.get('last', '')
+        )
+        return {
+            'username': first_name + ' ' + last_name,
+            'fullname': fullname,
+            'first_name': first_name,
+            'last_name': last_name,
+            'dob': data.get('dob', ''),
+            'gender': data.get('gender', ''),
+            'height': data.get('height', ''),
+            'weight': data.get('weight', '')
+        }
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        return self.get_json(
+            'https://jawbone.com/nudge/api/users/@me',
+            headers={'Authorization': 'Bearer ' + access_token},
+        )
+
+    def process_error(self, data):
+        error = data.get('error')
+        if error:
+            if error == 'access_denied':
+                raise AuthCanceled(self)
+            else:
+                raise AuthUnknownError(self, 'Jawbone error was {0}'.format(
+                    error
+                ))
+        return super().process_error(data)
+
+    def auth_complete_params(self, state=None):
+        client_id, client_secret = self.get_key_and_secret()
+        return {
+            'grant_type': 'authorization_code',  # request auth code
+            'code': self.data.get('code', ''),  # server response code
+            'client_id': client_id,
+            'client_secret': client_secret,
+        }
+
+    @handle_http_errors
+    def auth_complete(self, *args, **kwargs):
+        """Completes login process, must return user instance"""
+        self.process_error(self.data)
+        response = self.request_access_token(
+            self.ACCESS_TOKEN_URL,
+            params=self.auth_complete_params(self.validate_state()),
+            headers=self.auth_headers(),
+            method=self.ACCESS_TOKEN_METHOD
+        )
+        self.process_error(response)
+        return self.do_auth(response['access_token'], response=response,
+                            *args, **kwargs)
diff --git a/rootfs/api/apps_extra/social_core/backends/justgiving.py b/rootfs/api/apps_extra/social_core/backends/justgiving.py
new file mode 100644
index 000000000..57c89a522
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/justgiving.py
@@ -0,0 +1,56 @@
+from requests.auth import HTTPBasicAuth
+from ..utils import handle_http_errors
+from .oauth import BaseOAuth2
+
+
+class JustGivingOAuth2(BaseOAuth2):
+    """Just Giving OAuth authentication backend"""
+    name = 'justgiving'
+    ID_KEY = 'userId'
+    AUTHORIZATION_URL = 'https://identity.justgiving.com/connect/authorize'
+    ACCESS_TOKEN_URL = 'https://identity.justgiving.com/connect/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    USER_DATA_URL = 'https://api.justgiving.com/v1/account'
+    DEFAULT_SCOPE = ['openid', 'account', 'profile', 'email', 'fundraise']
+
+    def get_user_details(self, response):
+        """Return user details from Just Giving account"""
+        fullname, first_name, last_name = self.get_user_names(
+            '',
+            response.get('firstName'),
+            response.get('lastName'))
+        return {
+            'username': response.get('email'),
+            'email': response.get('email'),
+            'fullname': fullname,
+            'first_name': first_name,
+            'last_name': last_name
+        }
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        key, secret = self.get_key_and_secret()
+        return self.get_json(self.USER_DATA_URL, headers={
+            'Authorization': 'Bearer {0}'.format(access_token),
+            'Content-Type': 'application/json',
+            'x-application-key': secret,
+            'x-api-key': key
+        })
+
+    @handle_http_errors
+    def auth_complete(self, *args, **kwargs):
+        """Completes login process, must return user instance"""
+        state = self.validate_state()
+        self.process_error(self.data)
+
+        key, secret = self.get_key_and_secret()
+        response = self.request_access_token(
+            self.access_token_url(),
+            data=self.auth_complete_params(state),
+            headers=self.auth_headers(),
+            auth=HTTPBasicAuth(key, secret),
+            method=self.ACCESS_TOKEN_METHOD
+        )
+        self.process_error(response)
+        return self.do_auth(response['access_token'], response=response,
+                            *args, **kwargs)
diff --git a/rootfs/api/apps_extra/social_core/backends/kakao.py b/rootfs/api/apps_extra/social_core/backends/kakao.py
new file mode 100644
index 000000000..ceea26fc7
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/kakao.py
@@ -0,0 +1,55 @@
+"""
+Kakao OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/kakao.html
+"""
+from .oauth import BaseOAuth2
+
+
+class KakaoOAuth2(BaseOAuth2):
+    """Kakao OAuth authentication backend"""
+    name = 'kakao'
+    AUTHORIZATION_URL = 'https://kauth.kakao.com/oauth/authorize'
+    ACCESS_TOKEN_URL = 'https://kauth.kakao.com/oauth/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    REDIRECT_STATE = False
+    EXTRA_DATA = [
+        ('properties', 'properties'),
+    ]
+
+    def get_user_id(self, details, response):
+        return response['id']
+
+    def get_user_details(self, response):
+        """Return user details from Kakao account"""
+
+        kakao_account = response.get('kakao_account', '')
+        kaccount_email = kakao_account.get('email', '')
+        properties = response.get('properties', '')
+        nickname = properties.get('nickname') if properties else ''
+        return {
+            'username': nickname,
+            'email': kaccount_email,
+            'fullname': nickname,
+            'first_name': nickname[1:] if nickname else '',
+            'last_name': nickname[0] if nickname else '',
+        }
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        return self.get_json(
+            'https://kapi.kakao.com/v2/user/me',
+            headers={
+                'Authorization': 'Bearer {0}'.format(access_token),
+                'Content_Type': 'application/x-www-form-urlencoded;charset=utf-8',
+            },
+            params={'access_token': access_token}
+        )
+
+    def auth_complete_params(self, state=None):
+        client_id, client_secret = self.get_key_and_secret()
+        return {
+            'grant_type': 'authorization_code',
+            'code': self.data.get('code', ''),
+            'client_id': client_id,
+            'client_secret': client_secret,
+        }
diff --git a/rootfs/api/apps_extra/social_core/backends/keycloak.py b/rootfs/api/apps_extra/social_core/backends/keycloak.py
new file mode 100644
index 000000000..f28f22a36
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/keycloak.py
@@ -0,0 +1,138 @@
+import jwt
+
+from social_core.backends.oauth import BaseOAuth2
+
+
+class KeycloakOAuth2(BaseOAuth2):  # pylint: disable=abstract-method
+    """Keycloak OAuth2 authentication backend
+
+    This backend has been tested working with a standard Keycloak installation,
+    but you might have to specialize it and tune the parameters per your
+    configuration.
+
+    This setup specializes the OAuth2 backend which, strictly speaking, offers
+    authorization without authentication capabilities.
+
+    Keycloak does offer a full OpenID Connect implementation, but the
+    implementation is rather labor intensive to implement.
+
+    This backend is configured to get an access token instead, and assume that
+    the access token contains the necessary user details for authentication.
+
+    The integrity of the authentication process is followed by public key
+    verification for the `access_token` along with OpenID Connect specification
+    `aud` field checking.
+
+    To set up, please take the following steps:
+
+    1. Create a new Keycloak client in the Clients section.
+
+    2. Configure the following parameters in the Client setup:
+
+        Settings >
+            Client ID (copy to settings as `KEY` value)
+        Credentials >
+            Client Authenticator >
+                Secret (copy to settings as `SECRET` value)
+
+    3. For the tokens to work with the JWT setup the following configuration has
+       to be made in Keycloak:
+
+        Settings >
+            Access Type >
+                confidential
+        Settings >
+            Fine Grain OpenID Connect Configuration >
+                User Info Signed
+        Response Algorithm >
+            RS256
+        Settings >
+            Fine Grain OpenID Connect Configuration >
+                Request Object Signature Algorithm > RS256
+
+    4. Get the public key (copy to settings as `PUBLIC_KEY` value) to be used
+       with the backend:
+
+        Realm Settings > Keys > Public key
+
+    5. Configure access token fields are configured via the Keycloak Client
+       mappers:
+
+        Clients > Client ID > Mappers
+
+    They have to include at least the `ID_KEY` value and the dictionary keys
+    defined in the `get_user_details` method.
+
+    6. Configure your web backend. Example setting values for Django settings
+       could be:
+
+        SOCIAL_AUTH_KEYCLOAK_KEY = 'example'
+        SOCIAL_AUTH_KEYCLOAK_SECRET = '1234abcd-1234-abcd-1234-abcd1234adcd'
+        SOCIAL_AUTH_KEYCLOAK_PUBLIC_KEY = \
+          'pempublickeythatis2048bitsinbase64andhaseg392characters'
+        SOCIAL_AUTH_KEYCLOAK_AUTHORIZATION_URL = \
+          'https://sso.com/auth/realms/example/protocol/openid-connect/auth'
+        SOCIAL_AUTH_KEYCLOAK_ACCESS_TOKEN_URL = \
+          'https://sso.com/auth/realms/example/protocol/openid-connect/token'
+
+    7. The default behaviour is to associate users via username field, but you
+       can change the key with e.g.
+
+            SOCIAL_AUTH_KEYCLOAK_ID_KEY = 'email'
+
+    Please make sure your Keycloak user database and Django user database do not
+    conflict and that there is no risk of user account hijacking by false
+    account association.
+    """
+
+    name = 'keycloak'
+    ID_KEY = 'username'
+    ACCESS_TOKEN_METHOD = 'POST'
+
+    def authorization_url(self):
+        return self.setting('AUTHORIZATION_URL')
+
+    def access_token_url(self):
+        return self.setting('ACCESS_TOKEN_URL')
+
+    def audience(self):
+        return self.setting('KEY')
+
+    def algorithm(self):
+        return self.setting('ALGORITHM', default='RS256')
+
+    def public_key(self):
+        return '\n'.join([
+            '-----BEGIN PUBLIC KEY-----',
+            self.setting('PUBLIC_KEY'),
+            '-----END PUBLIC KEY-----',
+        ])
+
+    def user_data(self, access_token, *args, **kwargs):  # pylint: disable=unused-argument
+        """Decode user data from the access_token
+
+        You can specialize this method to e.g. get information
+        from the Keycloak backend if you do not want to include
+        the user information in the access_token.
+        """
+
+        return jwt.decode(
+            access_token,
+            key=self.public_key(),
+            algorithms=self.algorithm(),
+            audience=self.audience(),
+        )
+
+    def get_user_details(self, response):
+        """Map fields in user_data into Django User fields"""
+        return {
+            'username': response.get('preferred_username'),
+            'email': response.get('email'),
+            'fullname': response.get('name'),
+            'first_name': response.get('given_name'),
+            'last_name': response.get('family_name'),
+        }
+
+    def get_user_id(self, details, response):
+        """Get and associate Django User by the field indicated by ID_KEY"""
+        return details.get(self.ID_KEY)
diff --git a/rootfs/api/apps_extra/social_core/backends/khanacademy.py b/rootfs/api/apps_extra/social_core/backends/khanacademy.py
new file mode 100644
index 000000000..b310e0eb9
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/khanacademy.py
@@ -0,0 +1,118 @@
+"""
+Khan Academy OAuth backend, docs at:
+    https://github.com/Khan/khan-api/wiki/Khan-Academy-API-Authentication
+"""
+from urllib.parse import urlencode
+
+from oauthlib.oauth1 import SIGNATURE_HMAC, SIGNATURE_TYPE_QUERY
+from requests_oauthlib import OAuth1
+
+from .oauth import BaseOAuth1
+
+
+class BrowserBasedOAuth1(BaseOAuth1):
+    """Browser based mechanism OAuth authentication, fill the needed
+    parameters to communicate properly with authentication service.
+
+        REQUEST_TOKEN_URL       Request token URL (opened in web browser)
+        ACCESS_TOKEN_URL        Access token URL
+    """
+    REQUEST_TOKEN_URL = ''
+    OAUTH_TOKEN_PARAMETER_NAME = 'oauth_token'
+    REDIRECT_URI_PARAMETER_NAME = 'redirect_uri'
+    ACCESS_TOKEN_URL = ''
+
+    def auth_url(self):
+        """Return redirect url"""
+        return self.unauthorized_token_request()
+
+    def get_unauthorized_token(self):
+        return self.strategy.request_data()
+
+    def unauthorized_token_request(self):
+        """Return request for unauthorized token (first stage)"""
+
+        params = self.request_token_extra_arguments()
+        params.update(self.get_scope_argument())
+        key, secret = self.get_key_and_secret()
+        state = self.get_or_create_state()
+        auth = OAuth1(
+            key,
+            secret,
+            callback_uri=self.get_redirect_uri(state),
+            signature_method=SIGNATURE_HMAC,
+            signature_type=SIGNATURE_TYPE_QUERY,
+            decoding=None
+        )
+        url = self.REQUEST_TOKEN_URL + '?' + urlencode(params)
+        url, _, _ = auth.client.sign(url)
+        return url
+
+    def oauth_auth(self, token=None, oauth_verifier=None):
+        key, secret = self.get_key_and_secret()
+        oauth_verifier = oauth_verifier or self.data.get('oauth_verifier')
+        token = token or {}
+        state = self.get_or_create_state()
+        return OAuth1(key, secret,
+                      resource_owner_key=token.get('oauth_token'),
+                      resource_owner_secret=token.get('oauth_token_secret'),
+                      callback_uri=self.get_redirect_uri(state),
+                      verifier=oauth_verifier,
+                      signature_method=SIGNATURE_HMAC,
+                      signature_type=SIGNATURE_TYPE_QUERY,
+                      decoding=None)
+
+
+class KhanAcademyOAuth1(BrowserBasedOAuth1):
+    """
+    Class used for autorising with Khan Academy.
+
+    Flow of Khan Academy is a bit different than most OAuth 1.0 and consinsts
+    of the following steps:
+    1. Create signed params to attach to the REQUEST_TOKEN_URL
+    2. Redirect user to the REQUEST_TOKEN_URL that will respond with
+       oauth_secret, oauth_token, oauth_verifier that should be used with
+       ACCESS_TOKEN_URL
+    3. Go to ACCESS_TOKEN_URL and grab oauth_token_secret.
+
+    Note that we don't use the AUTHORIZATION_URL.
+
+    REQUEST_TOKEN_URL requires the following arguments:
+    oauth_consumer_key - Your app's consumer key
+    oauth_nonce - Random 64-bit, unsigned number encoded as an ASCII string
+        in decimal format. The nonce/timestamp pair should always be unique.
+    oauth_version - OAuth version used by your app. Must be "1.0" for now.
+    oauth_signature - String generated using the referenced signature method.
+    oauth_signature_method - Signature algorithm (currently only support
+        "HMAC-SHA1")
+    oauth_timestamp - Integer representing the time the request is sent.
+        The timestamp should be expressed in number of seconds
+        after January 1, 1970 00:00:00 GMT.
+    oauth_callback (optional) - URL to redirect to after request token is
+        received and authorized by the user's chosen identity provider.
+    """
+    name = 'khanacademy-oauth1'
+    ID_KEY = 'user_id'
+    REQUEST_TOKEN_URL = 'http://www.khanacademy.org/api/auth/request_token'
+    ACCESS_TOKEN_URL = 'https://www.khanacademy.org/api/auth/access_token'
+    REDIRECT_URI_PARAMETER_NAME = 'oauth_callback'
+    USER_DATA_URL = 'https://www.khanacademy.org/api/v1/user'
+
+    EXTRA_DATA = [('user_id', 'user_id')]
+
+    def get_user_details(self, response):
+        """Return user details from Khan Academy account"""
+        return {
+            'username': response.get('email'),
+            'email': response.get('email'),
+            'fullname': response.get('nickname'),
+            'first_name': '',
+            'last_name': '',
+            'user_id': response.get('user_id')
+        }
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        auth = self.oauth_auth(access_token)
+        url, _, _ = auth.client.sign(self.USER_DATA_URL)
+        return self.get_json(url)
diff --git a/rootfs/api/apps_extra/social_core/backends/lastfm.py b/rootfs/api/apps_extra/social_core/backends/lastfm.py
new file mode 100644
index 000000000..617fc33e7
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/lastfm.py
@@ -0,0 +1,59 @@
+import hashlib
+
+from ..utils import handle_http_errors
+from .base import BaseAuth
+
+
+class LastFmAuth(BaseAuth):
+    """
+    Last.Fm authentication backend. Requires two settings:
+        SOCIAL_AUTH_LASTFM_KEY
+        SOCIAL_AUTH_LASTFM_SECRET
+
+    Don't forget to set the Last.fm callback to something sensible like
+        http://your.site/lastfm/complete
+    """
+    name = 'lastfm'
+    AUTH_URL = 'http://www.last.fm/api/auth/?api_key={api_key}'
+    EXTRA_DATA = [
+        ('key', 'session_key')
+    ]
+
+    def auth_url(self):
+        return self.AUTH_URL.format(api_key=self.setting('KEY'))
+
+    @handle_http_errors
+    def auth_complete(self, *args, **kwargs):
+        """Completes login process, must return user instance"""
+        key, secret = self.get_key_and_secret()
+        token = self.data['token']
+
+        signature = hashlib.md5(''.join(
+            ('api_key', key, 'methodauth.getSession', 'token', token, secret)
+        ).encode()).hexdigest()
+
+        response = self.get_json('http://ws.audioscrobbler.com/2.0/', data={
+            'method': 'auth.getSession',
+            'api_key': key,
+            'token': token,
+            'api_sig': signature,
+            'format': 'json'
+        }, method='POST')
+
+        kwargs.update({'response': response['session'], 'backend': self})
+        return self.strategy.authenticate(*args, **kwargs)
+
+    def get_user_id(self, details, response):
+        """Return a unique ID for the current user, by default from server
+        response."""
+        return response.get('name')
+
+    def get_user_details(self, response):
+        fullname, first_name, last_name = self.get_user_names(response['name'])
+        return {
+            'username': response['name'],
+            'email': '',
+            'fullname': fullname,
+            'first_name': first_name,
+            'last_name': last_name
+        }
diff --git a/rootfs/api/apps_extra/social_core/backends/launchpad.py b/rootfs/api/apps_extra/social_core/backends/launchpad.py
new file mode 100644
index 000000000..ecde01ec6
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/launchpad.py
@@ -0,0 +1,11 @@
+"""
+Launchpad OpenId backend
+"""
+
+from .open_id import OpenIdAuth
+
+
+class LaunchpadOpenId(OpenIdAuth):
+    name = 'launchpad'
+    URL = 'https://login.launchpad.net'
+    USERNAME_KEY = 'nickname'
diff --git a/rootfs/api/apps_extra/social_core/backends/legacy.py b/rootfs/api/apps_extra/social_core/backends/legacy.py
new file mode 100644
index 000000000..c82d1f58f
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/legacy.py
@@ -0,0 +1,44 @@
+from .base import BaseAuth
+from ..exceptions import AuthMissingParameter
+
+
+class LegacyAuth(BaseAuth):
+    def get_user_id(self, details, response):
+        return details.get(self.ID_KEY) or \
+               response.get(self.ID_KEY)
+
+    def auth_url(self):
+        return self.setting('FORM_URL')
+
+    def auth_html(self):
+        return self.strategy.render_html(tpl=self.setting('FORM_HTML'))
+
+    def uses_redirect(self):
+        return self.setting('FORM_URL') and not \
+               self.setting('FORM_HTML')
+
+    def auth_complete(self, *args, **kwargs):
+        """Completes login process, must return user instance"""
+        if self.ID_KEY not in self.data:
+            raise AuthMissingParameter(self, self.ID_KEY)
+        kwargs.update({'response': self.data, 'backend': self})
+        return self.strategy.authenticate(*args, **kwargs)
+
+    def get_user_details(self, response):
+        """Return user details"""
+        email = response.get('email', '')
+        username = response.get('username', '')
+        fullname, first_name, last_name = self.get_user_names(
+            response.get('fullname', ''),
+            response.get('first_name', ''),
+            response.get('last_name', '')
+        )
+        if email and not username:
+            username = email.split('@', 1)[0]
+        return {
+            'username': username,
+            'email': email,
+            'fullname': fullname,
+            'first_name': first_name,
+            'last_name': last_name
+        }
diff --git a/rootfs/api/apps_extra/social_core/backends/line.py b/rootfs/api/apps_extra/social_core/backends/line.py
new file mode 100644
index 000000000..b1cfcf4b3
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/line.py
@@ -0,0 +1,105 @@
+"""
+LINE Login OAuth2 backend, docs at:
+    https://developers.line.me/en/docs/line-login/
+"""
+import requests
+import json
+
+from .oauth import BaseOAuth2
+from ..exceptions import AuthFailed
+from ..utils import handle_http_errors
+
+
+class LineOAuth2(BaseOAuth2):
+    name = 'line'
+    AUTHORIZATION_URL = 'https://access.line.me/oauth2/v2.1/authorize'
+    ACCESS_TOKEN_URL = 'https://api.line.me/oauth2/v2.1/token'
+    BASE_API_URL = 'https://api.line.me'
+    USER_INFO_URL = BASE_API_URL + '/v2/profile'
+    ACCESS_TOKEN_METHOD = 'POST'
+    STATE_PARAMETER = True
+    DEFAULT_SCOPE = ['profile']
+    REDIRECT_STATE = True
+    ID_KEY = 'userId'
+    EXTRA_DATA = [
+        ('userId', 'id'),
+        ('picture_url', 'picture_url'),
+        ('status_message', 'status_message'),
+        ('expires_in', 'expire'),
+        ('refresh_token', 'refresh_token')
+    ]
+
+    def auth_params(self, state=None):
+        client_id, client_secret = self.get_key_and_secret()
+        return {
+            'response_type': self.RESPONSE_TYPE,
+            'client_id': client_id,
+            'redirect_uri': self.get_redirect_uri(),
+            'state': self.get_or_create_state(),
+            'scope': self.get_scope()
+        }
+
+    def process_error(self, data):
+        error_code = data.get('errorCode') or \
+            data.get('statusCode') or \
+            data.get('error')
+        error_message = data.get('errorMessage') or \
+            data.get('error_description')
+        if error_code is not None or error_message is not None:
+            raise AuthFailed(self, error_message or error_code)
+
+    @handle_http_errors
+    def auth_complete(self, *args, **kwargs):
+        """Completes login process, must return user instance"""
+        self.process_error(self.data)
+
+        try:
+            response = self.request_access_token(
+                self.access_token_url(),
+                method=self.ACCESS_TOKEN_METHOD,
+                headers=self.auth_headers(),
+                data=self.auth_complete_params()
+            )
+            self.process_error(response)
+
+            return self.do_auth(response['access_token'], response=response,
+                                *args, **kwargs)
+        except requests.HTTPError as err:
+            self.process_error(json.loads(err.response.content))
+
+    def get_user_details(self, response):
+        fullname, first_name, last_name = self.get_user_names(
+            response.get('displayName')
+        )
+        username = response.get('userId')
+        picture_url = response.get('pictureUrl')
+        status_message = response.get('statusMessage')
+        return {
+            'username': username,
+            'fullname': fullname,
+            'first_name': first_name,
+            'last_name': last_name,
+            'picture_url': picture_url,
+            'status_message': status_message
+        }
+
+    def get_user_id(self, details, response):
+        """
+        Return a unique ID for the current user, by default from
+        server response.
+        """
+        return response.get(self.ID_KEY)
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        try:
+            response = self.get_json(
+                self.USER_INFO_URL,
+                headers={
+                    'Authorization': 'Bearer {}'.format(access_token)
+                }
+            )
+            self.process_error(response)
+            return response
+        except requests.HTTPError as err:
+            self.process_error(err.response.json())
diff --git a/rootfs/api/apps_extra/social_core/backends/linkedin.py b/rootfs/api/apps_extra/social_core/backends/linkedin.py
new file mode 100644
index 000000000..6d252a605
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/linkedin.py
@@ -0,0 +1,126 @@
+"""
+LinkedIn OAuth1 and OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/linkedin.html
+"""
+from social_core.exceptions import AuthCanceled
+from .oauth import BaseOAuth2
+
+
+class LinkedinOAuth2(BaseOAuth2):
+    name = 'linkedin-oauth2'
+    AUTHORIZATION_URL = \
+        'https://www.linkedin.com/oauth/v2/authorization'
+    ACCESS_TOKEN_URL = 'https://www.linkedin.com/oauth/v2/accessToken'
+    USER_DETAILS_URL = \
+        'https://api.linkedin.com/v2/me?projection=({projection})'
+    USER_EMAILS_URL = 'https://api.linkedin.com/v2/emailAddress' \
+                      '?q=members&projection=(elements*(handle~))'
+    ACCESS_TOKEN_METHOD = 'POST'
+    REDIRECT_STATE = False
+    DEFAULT_SCOPE = ['r_liteprofile']
+    EXTRA_DATA = [
+        ('id', 'id'),
+        ('expires_in', 'expires'),
+        ('firstName', 'first_name'),
+        ('lastName', 'last_name')
+    ]
+
+    def user_details_url(self):
+        # use set() since LinkedIn fails when values are duplicated
+        fields_selectors = list(set(['id', 'firstName', 'lastName'] +
+                                    self.setting('FIELD_SELECTORS', [])))
+        # user sort to ease the tests URL mocking
+        fields_selectors.sort()
+        fields_selectors = ','.join(fields_selectors)
+        return self.USER_DETAILS_URL.format(projection=fields_selectors)
+
+    def user_emails_url(self):
+        return self.USER_EMAILS_URL
+
+    def user_data(self, access_token, *args, **kwargs):
+        response = self.get_json(
+            self.user_details_url(),
+            headers=self.user_data_headers(access_token)
+        )
+
+        if 'emailAddress' in set(self.setting('FIELD_SELECTORS', [])):
+            emails = self.email_data(access_token, *args, **kwargs)
+            if emails:
+                response['emailAddress'] = emails[0]
+
+        return response
+
+    def email_data(self, access_token, *args, **kwargs):
+        response = self.get_json(
+            self.user_emails_url(),
+            headers=self.user_data_headers(access_token)
+        )
+        email_addresses = []
+        for element in response.get('elements', []):
+            email_address = element.get('handle~', {}).get('emailAddress')
+            email_addresses.append(email_address)
+        return list(filter(None, email_addresses))
+
+    def get_user_details(self, response):
+        """Return user details from Linkedin account"""
+
+        def get_localized_name(name):
+            """
+            FirstName & Last Name object
+            {
+                  'localized': {
+                     'en_US': 'Smith'
+                  },
+                  'preferredLocale': {
+                     'country': 'US',
+                     'language': 'en'
+                  }
+            }
+            :return the localizedName from the lastName object
+            """
+            locale = '{}_{}'.format(
+                name['preferredLocale']['language'],
+                name['preferredLocale']['country']
+            )
+            return name['localized'].get(locale, '')
+
+        fullname, first_name, last_name = self.get_user_names(
+            first_name=get_localized_name(response['firstName']),
+            last_name=get_localized_name(response['lastName'])
+        )
+        email = response.get('emailAddress', '')
+        return {'username': first_name + last_name,
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name,
+                'email': email}
+
+    def user_data_headers(self, access_token):
+        headers = {}
+        lang = self.setting('FORCE_PROFILE_LANGUAGE')
+        if lang:
+            headers['Accept-Language'] = lang if lang is not True \
+                else self.strategy.get_language()
+        headers['Authorization'] = 'Bearer {access_token}'.format(
+            access_token=access_token)
+        return headers
+
+    def request_access_token(self, *args, **kwargs):
+        # LinkedIn expects a POST request with querystring parameters, despite
+        # the spec http://tools.ietf.org/html/rfc6749#section-4.1.3
+        kwargs['params'] = kwargs.pop('data')
+        return super().request_access_token(*args, **kwargs)
+
+    def process_error(self, data):
+        super().process_error(data)
+        if data.get('serviceErrorCode'):
+            raise AuthCanceled(self, data.get('message') or data.get('status'))
+
+
+class LinkedinMobileOAuth2(LinkedinOAuth2):
+    name = 'linkedin-mobile-oauth2'
+
+    def user_data_headers(self, access_token):
+        headers = super().user_data_headers(access_token)
+        headers['x-li-src'] = 'msdk'
+        return headers
diff --git a/rootfs/api/apps_extra/social_core/backends/live.py b/rootfs/api/apps_extra/social_core/backends/live.py
new file mode 100644
index 000000000..e18f3ae39
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/live.py
@@ -0,0 +1,44 @@
+"""
+Live OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/live.html
+"""
+from .oauth import BaseOAuth2
+
+
+class LiveOAuth2(BaseOAuth2):
+    name = 'live'
+    AUTHORIZATION_URL = 'https://login.live.com/oauth20_authorize.srf'
+    ACCESS_TOKEN_URL = 'https://login.live.com/oauth20_token.srf'
+    ACCESS_TOKEN_METHOD = 'POST'
+    SCOPE_SEPARATOR = ','
+    DEFAULT_SCOPE = ['wl.basic', 'wl.emails']
+    EXTRA_DATA = [
+        ('id', 'id'),
+        ('access_token', 'access_token'),
+        ('authentication_token', 'authentication_token'),
+        ('refresh_token', 'refresh_token'),
+        ('expires_in', 'expires'),
+        ('email', 'email'),
+        ('first_name', 'first_name'),
+        ('last_name', 'last_name'),
+        ('token_type', 'token_type'),
+    ]
+    REDIRECT_STATE = False
+
+    def get_user_details(self, response):
+        """Return user details from Live Connect account"""
+        fullname, first_name, last_name = self.get_user_names(
+            first_name=response.get('first_name'),
+            last_name=response.get('last_name')
+        )
+        return {'username': response.get('name'),
+                'email': response.get('emails', {}).get('account', ''),
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        return self.get_json('https://apis.live.net/v5.0/me', params={
+            'access_token': access_token
+        })
diff --git a/rootfs/api/apps_extra/social_core/backends/livejournal.py b/rootfs/api/apps_extra/social_core/backends/livejournal.py
new file mode 100644
index 000000000..3e1df99d3
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/livejournal.py
@@ -0,0 +1,26 @@
+"""
+LiveJournal OpenId backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/livejournal.html
+"""
+from urllib.parse import urlsplit
+
+from .open_id import OpenIdAuth
+from ..exceptions import AuthMissingParameter
+
+
+class LiveJournalOpenId(OpenIdAuth):
+    """LiveJournal OpenID authentication backend"""
+    name = 'livejournal'
+
+    def get_user_details(self, response):
+        """Generate username from identity url"""
+        values = super().get_user_details(response)
+        values['username'] = values.get('username') or \
+            urlsplit(response.identity_url).netloc.split('.', 1)[0]
+        return values
+
+    def openid_url(self):
+        """Returns LiveJournal authentication URL"""
+        if not self.data.get('openid_lj_user'):
+            raise AuthMissingParameter(self, 'openid_lj_user')
+        return 'http://{0}.livejournal.com'.format(self.data['openid_lj_user'])
diff --git a/rootfs/api/apps_extra/social_core/backends/loginradius.py b/rootfs/api/apps_extra/social_core/backends/loginradius.py
new file mode 100644
index 000000000..49bfa55d5
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/loginradius.py
@@ -0,0 +1,69 @@
+"""
+LoginRadius BaseOAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/loginradius.html
+"""
+from .oauth import BaseOAuth2
+
+
+class LoginRadiusAuth(BaseOAuth2):
+    """LoginRadius BaseOAuth2 authentication backend."""
+    name = 'loginradius'
+    ID_KEY = 'ID'
+    ACCESS_TOKEN_URL = 'https://api.loginradius.com/api/v2/access_token'
+    PROFILE_URL = 'https://api.loginradius.com/api/v2/userprofile'
+    ACCESS_TOKEN_METHOD = 'GET'
+    REDIRECT_STATE = False
+    STATE_PARAMETER = False
+
+    def uses_redirect(self):
+        """Return False because we return HTML instead."""
+        return False
+
+    def auth_html(self):
+        key, secret = self.get_key_and_secret()
+        tpl = self.setting('TEMPLATE', 'loginradius.html')
+        return self.strategy.render_html(tpl=tpl, context={
+            'backend': self,
+            'LOGINRADIUS_KEY': key,
+            'LOGINRADIUS_REDIRECT_URL': self.get_redirect_uri()
+        })
+
+    def request_access_token(self, *args, **kwargs):
+        return self.get_json(params={
+            'token': self.data.get('token'),
+            'secret': self.setting('SECRET')
+        }, *args, **kwargs)
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service. Implement in subclass."""
+        return self.get_json(
+            self.PROFILE_URL,
+            params={'access_token': access_token},
+            data=self.auth_complete_params(self.validate_state()),
+            headers=self.auth_headers(),
+            method=self.ACCESS_TOKEN_METHOD
+        )
+
+    def get_user_details(self, response):
+        """Must return user details in a know internal struct:
+            {'username': <username if any>,
+             'email': <user email if any>,
+             'fullname': <user full name if any>,
+             'first_name': <user first name if any>,
+             'last_name': <user last name if any>}
+        """
+        profile = {
+            'username': response['NickName'] or '',
+            'email': response['Email'][0]['Value'] or '',
+            'fullname': response['FullName'] or '',
+            'first_name': response['FirstName'] or '',
+            'last_name': response['LastName'] or ''
+        }
+        return profile
+
+    def get_user_id(self, details, response):
+        """Return a unique ID for the current user, by default from server
+        response. Since LoginRadius handles multiple providers, we need to
+        distinguish them to prevent conflicts."""
+        return '{0}-{1}'.format(response.get('Provider'),
+                                response.get(self.ID_KEY))
diff --git a/rootfs/api/apps_extra/social_core/backends/lyft.py b/rootfs/api/apps_extra/social_core/backends/lyft.py
new file mode 100644
index 000000000..8c016b45a
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/lyft.py
@@ -0,0 +1,57 @@
+"""
+Lyft OAuth2 backend. Read more about the
+    API at https://developer.lyft.com/docs
+"""
+from .oauth import BaseOAuth2
+
+
+class LyftOAuth2(BaseOAuth2):
+    name = 'lyft'
+    ID_KEY = 'id'
+    SCOPE_SEPARATOR = ' '
+    AUTHORIZATION_URL = 'https://api.lyft.com/oauth/authorize'
+    ACCESS_TOKEN_URL = 'https://api.lyft.com/oauth/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    REFRESH_TOKEN_URL = 'https://api.lyft.com/oauth/token'
+    USER_DATA_URL = 'https://api.lyft.com/v1/profile'
+    DEFAULT_SCOPE = ['public', 'profile', 'rides.read', 'rides.request']
+    RESPONSE_TYPE = 'code'
+    STATE_PARAMETER = 'asdf'
+    EXTRA_DATA = [
+        ('id', 'id'),
+        ('username', 'username'),
+        ('access_token', 'access_token'),
+        ('refresh_token', 'refresh_token'),
+        ('token_type', 'token_type'),
+        ('expires_in', 'expires_in'),
+        ('scope', 'scope'),
+    ]
+
+    def get_user_details(self, response):
+        """Return user details from Lyft account"""
+        return {
+            'id': response['id'],
+            'username': response['id']
+        }
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        return self.get_json(self.USER_DATA_URL, headers={
+          'Authorization': 'Bearer {0}'.format(access_token)
+        })
+
+    def auth_complete_params(self, state=None):
+        client_id, client_secret = self.get_key_and_secret()
+        return {
+            'grant_type': 'authorization_code',
+            'code': self.data['code']
+        }
+
+    def auth_complete_credentials(self):
+        return self.get_key_and_secret()
+
+    def refresh_token_params(self, refresh_token, *args, **kwargs):
+        return {
+            'refresh_token': refresh_token,
+            'grant_type': 'refresh_token'
+        }
diff --git a/rootfs/api/apps_extra/social_core/backends/mailchimp.py b/rootfs/api/apps_extra/social_core/backends/mailchimp.py
new file mode 100644
index 000000000..e6b322266
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/mailchimp.py
@@ -0,0 +1,32 @@
+from .oauth import BaseOAuth2
+
+
+class MailChimpOAuth2(BaseOAuth2):
+    """MailChimp OAuth2 authentication backend"""
+    name = 'mailchimp'
+    AUTHORIZATION_URL = 'https://login.mailchimp.com/oauth2/authorize'
+    ACCESS_TOKEN_URL = 'https://login.mailchimp.com/oauth2/token'
+    METADATA_URL = 'https://login.mailchimp.com/oauth2/metadata'
+    ACCESS_TOKEN_METHOD = 'POST'
+    STATE_PARAMETER = False
+    REDIRECT_STATE = False
+    ID_KEY = 'user_id'
+    EXTRA_DATA = [
+        ('accountname', 'accountname'),
+        ('api_endpoint', 'api_endpoint'),
+        ('role', 'role'),
+        ('login', 'login')
+    ]
+
+    def get_user_details(self, response):
+        """Return user details from a Mailchimp metadata response"""
+        return {
+            'username': response['login']['login_name'],
+            'email': response['login']['email']
+        }
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data and datacenter information from service"""
+        return self.get_json(self.METADATA_URL, headers={
+          'Authorization': 'OAuth ' + access_token
+        })
diff --git a/rootfs/api/apps_extra/social_core/backends/mailru.py b/rootfs/api/apps_extra/social_core/backends/mailru.py
new file mode 100644
index 000000000..f12d87fb2
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/mailru.py
@@ -0,0 +1,74 @@
+"""
+Mail.ru OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/mailru.html
+"""
+from hashlib import md5
+from urllib.parse import unquote
+
+from .oauth import BaseOAuth2
+
+
+class MailruOAuth2(BaseOAuth2):
+    """Mail.ru authentication backend"""
+    name = 'mailru-oauth2'
+    ID_KEY = 'uid'
+    AUTHORIZATION_URL = 'https://connect.mail.ru/oauth/authorize'
+    ACCESS_TOKEN_URL = 'https://connect.mail.ru/oauth/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    EXTRA_DATA = [('refresh_token', 'refresh_token'),
+                  ('expires_in', 'expires')]
+
+    def get_user_details(self, response):
+        """Return user details from Mail.ru request"""
+        fullname, first_name, last_name = self.get_user_names(
+            first_name=unquote(response['first_name']),
+            last_name=unquote(response['last_name'])
+        )
+        return {'username': unquote(response['nick']),
+                'email': unquote(response['email']),
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Return user data from Mail.ru REST API"""
+        key, secret = self.get_key_and_secret()
+        data = {'method': 'users.getInfo',
+                'session_key': access_token,
+                'app_id': key,
+                'secure': '1'}
+        param_list = sorted(list(item + '=' + data[item] for item in data))
+        data['sig'] = md5(
+            (''.join(param_list) + secret).encode('utf-8')
+        ).hexdigest()
+        return self.get_json('http://www.appsmail.ru/platform/api',
+                             params=data)[0]
+
+
+class MRGOAuth2(BaseOAuth2):
+
+    name = 'mailru'
+    ID_KEY = 'email'
+    AUTHORIZATION_URL = 'https://oauth.mail.ru/login'
+    ACCESS_TOKEN_URL = 'https://oauth.mail.ru/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    EXTRA_DATA = [('refresh_token', 'refresh_token'),
+                  ('expires_in', 'expires')]
+    REDIRECT_STATE = False
+
+    def get_user_details(self, response):
+        return {
+            'gender': response.get('gender'),
+            'fullname': response.get('name'),
+            'username': response.get('name'),
+            'first_name': response.get('first_name'),
+            'last_name': response.get('last_name'),
+            'locale': response.get('locale'),
+            'email': response.get('email'),
+            'address': response.get('address'),
+            'birthday': response.get('birthday'),
+            'image': response.get('image'),
+        }
+
+    def user_data(self, access_token, *args, **kwargs):
+        return self.get_json('https://oauth.mail.ru/userinfo', params={'access_token': access_token})
diff --git a/rootfs/api/apps_extra/social_core/backends/mapmyfitness.py b/rootfs/api/apps_extra/social_core/backends/mapmyfitness.py
new file mode 100644
index 000000000..7c202b9b5
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/mapmyfitness.py
@@ -0,0 +1,49 @@
+"""
+MapMyFitness OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/mapmyfitness.html
+"""
+from .oauth import BaseOAuth2
+
+
+class MapMyFitnessOAuth2(BaseOAuth2):
+    """MapMyFitness OAuth authentication backend"""
+    name = 'mapmyfitness'
+    AUTHORIZATION_URL = 'https://www.mapmyfitness.com/v7.0/oauth2/authorize'
+    ACCESS_TOKEN_URL = \
+        'https://oauth2-api.mapmyapi.com/v7.0/oauth2/access_token'
+    REQUEST_TOKEN_METHOD = 'POST'
+    ACCESS_TOKEN_METHOD = 'POST'
+    REDIRECT_STATE = False
+    EXTRA_DATA = [
+        ('refresh_token', 'refresh_token'),
+    ]
+
+    def auth_headers(self):
+        key = self.get_key_and_secret()[0]
+        return {
+            'Api-Key': key
+        }
+
+    def get_user_id(self, details, response):
+        return response['id']
+
+    def get_user_details(self, response):
+        first = response.get('first_name', '')
+        last = response.get('last_name', '')
+        full = (first + last).strip()
+        return {
+            'username': response['username'],
+            'email': response['email'],
+            'fullname': full,
+            'first_name': first,
+            'last_name': last,
+        }
+
+    def user_data(self, access_token, *args, **kwargs):
+        key = self.get_key_and_secret()[0]
+        url = 'https://oauth2-api.mapmyapi.com/v7.0/user/self/'
+        headers = {
+            'Authorization': 'Bearer {0}'.format(access_token),
+            'Api-Key': key
+        }
+        return self.get_json(url, headers=headers)
diff --git a/rootfs/api/apps_extra/social_core/backends/mediawiki.py b/rootfs/api/apps_extra/social_core/backends/mediawiki.py
new file mode 100644
index 000000000..16565658a
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/mediawiki.py
@@ -0,0 +1,182 @@
+"""
+MediaWiki OAuth1 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/mediawiki.html
+"""
+
+import re
+import time
+from urllib.parse import parse_qs, urlencode, urlparse
+
+import requests
+import jwt
+from requests_oauthlib import OAuth1
+
+from .oauth import BaseOAuth1
+from ..exceptions import AuthException
+
+
+def force_unicode(value):
+    """
+    Return string in unicode.
+    """
+    if isinstance(value, str):
+        return value
+    else:
+        return str(value, 'unicode-escape')
+
+
+class MediaWiki(BaseOAuth1):
+    """
+    Handles the handshake with Mediawiki and fetching of user data.
+    """
+    name = 'mediawiki'
+    MEDIAWIKI_URL = 'https://meta.wikimedia.org/w/index.php'
+    SOCIAL_AUTH_MEDIAWIKI_CALLBACK = 'oob'
+    LEEWAY = 10.0
+
+    def unauthorized_token(self):
+        """
+        Return request for unauthorized token (first stage)
+
+        Mediawiki request token is requested from e.g.:
+         * https://en.wikipedia.org/w/index.php?title=Special:OAuth/initiate
+        """
+        params = self.request_token_extra_arguments()
+        params.update(self.get_scope_argument())
+        params['title'] = 'Special:OAuth/initiate'
+        key, secret = self.get_key_and_secret()
+        response = self.request(
+            self.setting('MEDIAWIKI_URL'),
+            params=params,
+            auth=OAuth1(
+                key,
+                secret,
+                callback_uri=self.setting('CALLBACK')
+            ),
+            method=self.REQUEST_TOKEN_METHOD
+        )
+
+        if response.content.decode().startswith('Error'):
+            raise AuthException(self, response.content.decode())
+
+        return response.content.decode()
+
+    def oauth_authorization_request(self, token):
+        """
+        Generates the URL for the authorization link
+        """
+        if not isinstance(token, dict):
+            token = parse_qs(token)
+
+        oauth_token = token.get(self.OAUTH_TOKEN_PARAMETER_NAME)[0]
+        state = self.get_or_create_state()
+        base_url = self.setting('MEDIAWIKI_URL')
+
+        return '{0}?{1}'.format(base_url, urlencode({
+            'title': 'Special:Oauth/authenticate',
+            self.OAUTH_TOKEN_PARAMETER_NAME: oauth_token,
+            self.REDIRECT_URI_PARAMETER_NAME: self.get_redirect_uri(state)
+        }))
+
+    def access_token(self, token):
+        """
+        Fetches the Mediawiki access token.
+        """
+        auth_token = self.oauth_auth(token)
+
+        response = requests.post(
+            url=self.setting('MEDIAWIKI_URL'),
+            params={'title': 'Special:Oauth/token'},
+            auth=auth_token
+        )
+        credentials = parse_qs(response.content)
+        oauth_token_key = credentials.get(b'oauth_token')[0]
+        oauth_token_secret = credentials.get(b'oauth_token_secret')[0]
+        oauth_token_key = oauth_token_key.decode()
+        oauth_token_secret = oauth_token_secret.decode()
+
+        return {
+            'oauth_token': oauth_token_key,
+            'oauth_token_secret': oauth_token_secret
+        }
+
+    def get_user_details(self, response):
+        """
+        Gets the user details from Special:OAuth/identify
+        """
+        key, secret = self.get_key_and_secret()
+        access_token = response['access_token']
+
+        auth = OAuth1(key, client_secret=secret,
+                      resource_owner_key=access_token['oauth_token'],
+                      resource_owner_secret=access_token['oauth_token_secret'])
+
+        req_resp = requests.post(url=self.setting('MEDIAWIKI_URL'),
+                                 params={'title': 'Special:OAuth/identify'},
+                                 auth=auth)
+
+        try:
+            identity = jwt.decode(req_resp.content, secret,
+                                  audience=key, algorithms=['HS256'],
+                                  leeway=self.LEEWAY)
+        except jwt.InvalidTokenError as exception:
+            raise AuthException(
+                self,
+                'An error occurred while trying to read json ' +
+                'content: {0}'.format(exception)
+            )
+
+        issuer = urlparse(identity['iss']).netloc
+        expected_domain = urlparse(self.setting('MEDIAWIKI_URL')).netloc
+
+        if not issuer == expected_domain:
+            raise AuthException(
+                self,
+                'Unexpected issuer {0}, expected {1}'.format(
+                    issuer,
+                    expected_domain
+                )
+            )
+
+        now = time.time()
+        issued_at = float(identity['iat'])
+        if not now >= (issued_at - self.LEEWAY):
+            raise AuthException(
+                self,
+                'Identity issued {0} seconds in the future'.format(
+                    issued_at - now
+                )
+            )
+
+        authorization_header = force_unicode(
+            req_resp.request.headers['Authorization']
+        )
+        request_nonce = re.search(r'oauth_nonce="(.*?)"',
+                                  authorization_header).group(1)
+
+        if identity['nonce'] != request_nonce:
+            raise AuthException(
+                self,
+                'Replay attack detected: {0} != {1}'.format(
+                    identity['nonce'],
+                    request_nonce
+                )
+            )
+
+        return {
+            'username': identity['username'],
+            'userID': identity['sub'],
+            'email': identity.get('email'),
+            'confirmed_email': identity.get('confirmed_email'),
+            'editcount': identity.get('editcount'),
+            'rights': identity.get('rights'),
+            'groups': identity.get('groups'),
+            'registered': identity.get('registered'),
+            'blocked': identity.get('blocked')
+        }
+
+    def get_user_id(self, details, response):
+        """
+        Get the unique Mediawiki user ID.
+        """
+        return details['userID']
diff --git a/rootfs/api/apps_extra/social_core/backends/meetup.py b/rootfs/api/apps_extra/social_core/backends/meetup.py
new file mode 100644
index 000000000..4c9cd8f6b
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/meetup.py
@@ -0,0 +1,34 @@
+"""
+Meetup OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/meetup.html
+"""
+from .oauth import BaseOAuth2
+
+
+class MeetupOAuth2(BaseOAuth2):
+    """Meetup OAuth2 authentication backend"""
+    name = 'meetup'
+    AUTHORIZATION_URL = 'https://secure.meetup.com/oauth2/authorize'
+    ACCESS_TOKEN_URL = 'https://secure.meetup.com/oauth2/access'
+    ACCESS_TOKEN_METHOD = 'POST'
+    DEFAULT_SCOPE = ['basic']
+    SCOPE_SEPARATOR = ','
+    REDIRECT_STATE = False
+    STATE_PARAMETER = 'state'
+
+    def get_user_details(self, response):
+        """Return user details from Meetup account"""
+        fullname, first_name, last_name = self.get_user_names(
+            response.get('name')
+        )
+
+        return {'username': response.get('username'),
+                'email': response.get('email') or '',
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        return self.get_json('https://api.meetup.com/2/member/self',
+                             params={'access_token': access_token})
diff --git a/rootfs/api/apps_extra/social_core/backends/mendeley.py b/rootfs/api/apps_extra/social_core/backends/mendeley.py
new file mode 100644
index 000000000..ebb73e66f
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/mendeley.py
@@ -0,0 +1,67 @@
+"""
+Mendeley OAuth1 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/mendeley.html
+"""
+from .oauth import BaseOAuth1, BaseOAuth2
+
+
+class MendeleyMixin:
+    SCOPE_SEPARATOR = '+'
+    EXTRA_DATA = [('profile_id', 'profile_id'),
+                  ('name', 'name'),
+                  ('bio', 'bio')]
+
+    def get_user_id(self, details, response):
+        return response['id']
+
+    def get_user_details(self, response):
+        """Return user details from Mendeley account"""
+        profile_id = response['id']
+        name = response['display_name']
+        bio = response['link']
+        return {'profile_id': profile_id,
+                'name': name,
+                'bio': bio}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Return user data provided"""
+        values = self.get_user_data(access_token)
+        values.update(values)
+        return values
+
+    def get_user_data(self, access_token):
+        raise NotImplementedError('Implement in subclass')
+
+
+class MendeleyOAuth(MendeleyMixin, BaseOAuth1):
+    name = 'mendeley'
+    AUTHORIZATION_URL = 'http://api.mendeley.com/oauth/authorize/'
+    REQUEST_TOKEN_URL = 'http://api.mendeley.com/oauth/request_token/'
+    ACCESS_TOKEN_URL = 'http://api.mendeley.com/oauth/access_token/'
+
+    def get_user_data(self, access_token):
+        return self.get_json(
+            'http://api.mendeley.com/oapi/profiles/info/me/',
+            auth=self.oauth_auth(access_token)
+        )
+
+
+class MendeleyOAuth2(MendeleyMixin, BaseOAuth2):
+    name = 'mendeley-oauth2'
+    AUTHORIZATION_URL = 'https://api-oauth2.mendeley.com/oauth/authorize'
+    ACCESS_TOKEN_URL = 'https://api-oauth2.mendeley.com/oauth/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    DEFAULT_SCOPE = ['all']
+    REDIRECT_STATE = False
+    EXTRA_DATA = MendeleyMixin.EXTRA_DATA + [
+        ('refresh_token', 'refresh_token'),
+        ('expires_in', 'expires_in'),
+        ('token_type', 'token_type'),
+    ]
+
+    def get_user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        return self.get_json(
+            'https://api.mendeley.com/profiles/me/',
+            headers={'Authorization': 'Bearer {0}'.format(access_token)}
+        )
diff --git a/rootfs/api/apps_extra/social_core/backends/microsoft.py b/rootfs/api/apps_extra/social_core/backends/microsoft.py
new file mode 100644
index 000000000..eef31d677
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/microsoft.py
@@ -0,0 +1,79 @@
+"""
+OAuth2 Backend to work with microsoft graph.
+"""
+import time
+
+from .oauth import BaseOAuth2
+
+
+class MicrosoftOAuth2(BaseOAuth2):
+    name = 'microsoft-graph'
+    SCOPE_SEPARATOR = ' '
+    AUTHORIZATION_URL = \
+        'https://login.microsoftonline.com/common/oauth2/v2.0/authorize'
+    ACCESS_TOKEN_URL = \
+        'https://login.microsoftonline.com/common/oauth2/v2.0/token'
+
+    ACCESS_TOKEN_METHOD = 'POST'
+    REDIRECT_STATE = False
+    DEFAULT_SCOPE = ['User.Read']
+
+    def auth_complete(self, *args, **kwargs):
+        """Completes login process, must return user instance"""
+        self.process_error(self.data)
+        state = self.validate_state()
+
+        response = self.request_access_token(
+            self.access_token_url(),
+            data=self.auth_complete_params(state),
+            headers=self.auth_headers(),
+            auth=self.auth_complete_credentials(),
+            method=self.ACCESS_TOKEN_METHOD
+        )
+
+        self.process_error(response)
+        return self.do_auth(response['access_token'], response=response,
+                            *args, **kwargs)
+
+    def get_user_id(self, details, response):
+        """Use user account id as unique id"""
+        return response.get('id')
+
+    def get_user_details(self, response):
+        """Return user details from Microsoft online account"""
+        email = response.get('mail')
+        username = response.get('userPrincipalName')
+
+        if '@' in username:
+            if not email:
+                email = username
+            username = username.split('@', 1)[0]
+
+        return {'username': username,
+                'email': email,
+                'fullname': response.get('displayName', ''),
+                'first_name': response.get('givenName', ''),
+                'last_name': response.get('surname', '')}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Return user data by querying Microsoft service"""
+        return self.get_json(
+            'https://graph.microsoft.com/v1.0/me',
+            headers={
+                'Content-Type': 'application/x-www-form-urlencoded',
+                'Accept': 'application/json',
+                'Authorization': 'Bearer ' + access_token
+            },
+            method='GET'
+        )
+
+    def get_auth_token(self, user_id):
+        """Return the access token for the given user, after ensuring that it
+        has not expired, or refreshing it if so."""
+        user = self.get_user(user_id=user_id)
+        access_token = user.social_user.access_token
+        expires_on = user.social_user.extra_data['expires_on']
+        if expires_on <= int(time.time()):
+            new_token_response = self.refresh_token(token=access_token)
+            access_token = new_token_response['access_token']
+        return access_token
diff --git a/rootfs/api/apps_extra/social_core/backends/mineid.py b/rootfs/api/apps_extra/social_core/backends/mineid.py
new file mode 100644
index 000000000..1ac415ed9
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/mineid.py
@@ -0,0 +1,38 @@
+from .oauth import BaseOAuth2
+
+
+class MineIDOAuth2(BaseOAuth2):
+    """MineID OAuth2 authentication backend"""
+    name = 'mineid'
+    _AUTHORIZATION_URL = '%(scheme)s://%(host)s/oauth/authorize'
+    _ACCESS_TOKEN_URL = '%(scheme)s://%(host)s/oauth/access_token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    SCOPE_SEPARATOR = ','
+    EXTRA_DATA = [
+    ]
+
+    def get_user_details(self, response):
+        """Return user details"""
+        return {'email': response.get('email'),
+                'username': response.get('email')}
+
+    def user_data(self, access_token, *args, **kwargs):
+        return self._user_data(access_token)
+
+    def _user_data(self, access_token, path=None):
+        url = '%(scheme)s://%(host)s/api/user' % self.get_mineid_url_params()
+        return self.get_json(url, params={'access_token': access_token})
+
+    @property
+    def AUTHORIZATION_URL(self):
+        return self._AUTHORIZATION_URL % self.get_mineid_url_params()
+
+    @property
+    def ACCESS_TOKEN_URL(self):
+        return self._ACCESS_TOKEN_URL % self.get_mineid_url_params()
+
+    def get_mineid_url_params(self):
+        return {
+            'host': self.setting('HOST', 'www.mineid.org'),
+            'scheme': self.setting('SCHEME', 'https'),
+        }
diff --git a/rootfs/api/apps_extra/social_core/backends/mixcloud.py b/rootfs/api/apps_extra/social_core/backends/mixcloud.py
new file mode 100644
index 000000000..8b1b77e53
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/mixcloud.py
@@ -0,0 +1,26 @@
+"""
+Mixcloud OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/mixcloud.html
+"""
+from .oauth import BaseOAuth2
+
+
+class MixcloudOAuth2(BaseOAuth2):
+    name = 'mixcloud'
+    ID_KEY = 'username'
+    AUTHORIZATION_URL = 'https://www.mixcloud.com/oauth/authorize'
+    ACCESS_TOKEN_URL = 'https://www.mixcloud.com/oauth/access_token'
+    ACCESS_TOKEN_METHOD = 'POST'
+
+    def get_user_details(self, response):
+        fullname, first_name, last_name = self.get_user_names(response['name'])
+        return {'username': response['username'],
+                'email': None,
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
+
+    def user_data(self, access_token, *args, **kwargs):
+        return self.get_json('https://api.mixcloud.com/me/',
+                             params={'access_token': access_token,
+                                     'alt': 'json'})
diff --git a/rootfs/api/apps_extra/social_core/backends/monzo.py b/rootfs/api/apps_extra/social_core/backends/monzo.py
new file mode 100644
index 000000000..46b5ac63a
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/monzo.py
@@ -0,0 +1,32 @@
+from .oauth import BaseOAuth2
+
+
+class MonzoOAuth2(BaseOAuth2):
+    """
+    Monzo OAuth2 authentication backend.
+    """
+
+    name = 'monzo'
+
+    AUTHORIZATION_URL = 'https://auth.getmondo.co.uk/'
+    ACCESS_TOKEN_URL = 'https://api.monzo.com/oauth2/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    REDIRECT_STATE = False
+
+    def get_user_details(self, response):
+        fullname, first_name, last_name = self.get_user_names(
+            response['accounts'][0]['description'],
+        )
+
+        return {
+            'username': str(response.get('user_id')),
+            'fullname': fullname,
+            'first_name': first_name,
+            'last_name': last_name,
+        }
+
+    def user_data(self, access_token, *args, **kwargs):
+        return self.get_json(
+            'https://api.monzo.com/accounts',
+            headers={'Authorization': 'Bearer {0}'.format(access_token)},
+        )
diff --git a/rootfs/api/apps_extra/social_core/backends/moves.py b/rootfs/api/apps_extra/social_core/backends/moves.py
new file mode 100644
index 000000000..0ba1ddde8
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/moves.py
@@ -0,0 +1,30 @@
+"""
+Moves OAuth2 backend, docs at:
+    https://dev.moves-app.com/docs/authentication
+
+Written by Avi Alkalay <avi at unix dot sh>
+Certified to work with Django 1.6
+"""
+from .oauth import BaseOAuth2
+
+
+class MovesOAuth2(BaseOAuth2):
+    """Moves OAuth authentication backend"""
+    name = 'moves'
+    ID_KEY = 'user_id'
+    AUTHORIZATION_URL = 'https://api.moves-app.com/oauth/v1/authorize'
+    ACCESS_TOKEN_URL = 'https://api.moves-app.com/oauth/v1/access_token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    EXTRA_DATA = [
+        ('refresh_token', 'refresh_token', True),
+        ('expires_in', 'expires'),
+    ]
+
+    def get_user_details(self, response):
+        """Return user details Moves account"""
+        return {'username': str(response.get('user_id'))}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        return self.get_json('https://api.moves-app.com/api/1.1/user/profile',
+                             params={'access_token': access_token})
diff --git a/rootfs/api/apps_extra/social_core/backends/nationbuilder.py b/rootfs/api/apps_extra/social_core/backends/nationbuilder.py
new file mode 100644
index 000000000..64dc28f7a
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/nationbuilder.py
@@ -0,0 +1,48 @@
+"""
+NationBuilder OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/nationbuilder.html
+"""
+from .oauth import BaseOAuth2
+
+
+class NationBuilderOAuth2(BaseOAuth2):
+    """NationBuilder OAuth2 authentication backend"""
+    name = 'nationbuilder'
+    AUTHORIZATION_URL = 'https://{slug}.nationbuilder.com/oauth/authorize'
+    ACCESS_TOKEN_URL = 'https://{slug}.nationbuilder.com/oauth/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    REDIRECT_STATE = False
+    SCOPE_SEPARATOR = ','
+    EXTRA_DATA = [
+        ('id', 'id'),
+        ('expires', 'expires')
+    ]
+
+    def authorization_url(self):
+        return self.AUTHORIZATION_URL.format(slug=self.slug)
+
+    def access_token_url(self):
+        return self.ACCESS_TOKEN_URL.format(slug=self.slug)
+
+    @property
+    def slug(self):
+        return self.setting('SLUG')
+
+    def get_user_details(self, response):
+        """Return user details from Github account"""
+        email = response.get('email') or ''
+        username = email.split('@')[0] if email else ''
+        return {'username': username,
+                'email': email,
+                'fullname': response.get('full_name') or '',
+                'first_name': response.get('first_name') or '',
+                'last_name': response.get('last_name') or ''}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        url = 'https://{slug}.nationbuilder.com/api/v1/people/me'.format(
+            slug=self.slug
+        )
+        return self.get_json(url, params={
+            'access_token': access_token
+        })['person']
diff --git a/rootfs/api/apps_extra/social_core/backends/naver.py b/rootfs/api/apps_extra/social_core/backends/naver.py
new file mode 100644
index 000000000..332f81324
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/naver.py
@@ -0,0 +1,61 @@
+from .oauth import BaseOAuth2
+
+
+class NaverOAuth2(BaseOAuth2):
+    """Naver OAuth authentication backend"""
+    name = 'naver'
+    AUTHORIZATION_URL = 'https://nid.naver.com/oauth2.0/authorize'
+    ACCESS_TOKEN_URL = 'https://nid.naver.com/oauth2.0/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    EXTRA_DATA = [
+        ('id', 'id'),
+    ]
+
+    def get_user_id(self, details, response):
+        return response.get('id')
+
+    def get_user_details(self, response):
+        """Return user details from Naver account"""
+        return {
+            'username': response.get('username'),
+            'email': response.get('email'),
+            'fullname': response.get('username'),
+        }
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        response = self.request(
+            'https://openapi.naver.com/v1/nid/me',
+            headers={
+                'Authorization': 'Bearer {0}'.format(access_token),
+                'Content_Type': 'text/json'
+            }
+        )
+
+        data = response.json()
+
+        return {
+            'id': self._fetch(data, 'id'),
+            'email': self._fetch(data, 'email'),
+            'username': self._fetch(data, 'name'),
+            'nickname': self._fetch(data, 'nickname'),
+            'gender': self._fetch(data, 'gender'),
+            'age': self._fetch(data, 'age'),
+            'birthday': self._fetch(data, 'birthday'),
+            'profile_image': self._fetch(data, 'profile_image')
+        }
+
+    def auth_headers(self):
+        client_id, client_secret = self.get_key_and_secret()
+        return {
+            'grant_type': 'authorization_code',
+            'code': self.data.get('code'),
+            'client_id': client_id,
+            'client_secret': client_secret,
+        }
+
+    def _fetch(self, data, key):
+        try:
+            return data['response'][key]
+        except (KeyError, TypeError):
+            return ''
diff --git a/rootfs/api/apps_extra/social_core/backends/ngpvan.py b/rootfs/api/apps_extra/social_core/backends/ngpvan.py
new file mode 100644
index 000000000..8e2557091
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/ngpvan.py
@@ -0,0 +1,66 @@
+"""
+NGP VAN's `ActionID` Provider
+
+http://developers.ngpvan.com/action-id
+"""
+from openid.extensions import ax
+
+from .open_id import OpenIdAuth
+
+
+class ActionIDOpenID(OpenIdAuth):
+    """
+    NGP VAN's ActionID OpenID 1.1 authentication backend
+    """
+    name = 'actionid-openid'
+    URL = 'https://accounts.ngpvan.com/Home/Xrds'
+    USERNAME_KEY = 'email'
+
+    def get_ax_attributes(self):
+        """
+        Return the AX attributes that ActionID responds with, as well as the
+        user data result that it must map to.
+        """
+        return [
+            ('http://openid.net/schema/contact/internet/email', 'email'),
+            ('http://openid.net/schema/contact/phone/business', 'phone'),
+            ('http://openid.net/schema/namePerson/first', 'first_name'),
+            ('http://openid.net/schema/namePerson/last', 'last_name'),
+            ('http://openid.net/schema/namePerson', 'fullname'),
+        ]
+
+    def setup_request(self, params=None):
+        """
+        Setup the OpenID request
+
+        Because ActionID does not advertise the availiability of AX attributes
+        nor use standard attribute aliases, we need to setup the attributes
+        manually instead of rely on the parent OpenIdAuth.setup_request()
+        """
+        request = self.openid_request(params)
+
+        fetch_request = ax.FetchRequest()
+        fetch_request.add(ax.AttrInfo(
+            'http://openid.net/schema/contact/internet/email',
+            alias='ngpvanemail',
+            required=True
+        ))
+
+        fetch_request.add(ax.AttrInfo(
+            'http://openid.net/schema/contact/phone/business',
+            alias='ngpvanphone',
+            required=False
+        ))
+        fetch_request.add(ax.AttrInfo(
+            'http://openid.net/schema/namePerson/first',
+            alias='ngpvanfirstname',
+            required=False
+        ))
+        fetch_request.add(ax.AttrInfo(
+            'http://openid.net/schema/namePerson/last',
+            alias='ngpvanlastname',
+            required=False
+        ))
+        request.addExtension(fetch_request)
+
+        return request
diff --git a/rootfs/api/apps_extra/social_core/backends/nk.py b/rootfs/api/apps_extra/social_core/backends/nk.py
new file mode 100644
index 000000000..979482c97
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/nk.py
@@ -0,0 +1,70 @@
+from urllib import urlencode
+
+from requests_oauthlib import OAuth1
+
+from .oauth import BaseOAuth2
+
+
+class NKOAuth2(BaseOAuth2):
+    """NK OAuth authentication backend"""
+    name = 'nk'
+    AUTHORIZATION_URL = 'https://nk.pl/oauth2/login'
+    ACCESS_TOKEN_URL = 'https://nk.pl/oauth2/token'
+    SCOPE_SEPARATOR = ','
+    ACCESS_TOKEN_METHOD = 'POST'
+    SIGNATURE_TYPE_AUTH_HEADER = 'AUTH_HEADER'
+    EXTRA_DATA = [
+        ('id', 'id'),
+    ]
+
+    def get_user_details(self, response):
+        """Return user details from NK account"""
+        entry = response['entry']
+        return {
+            'username': entry.get('displayName'),
+            'email': entry['emails'][0]['value'],
+            'first_name': entry.get('displayName').split(' ')[0],
+            'id': entry.get('id')
+        }
+
+    def auth_complete_params(self, state=None):
+        client_id, client_secret = self.get_key_and_secret()
+        return {
+            'grant_type': 'authorization_code',  # request auth code
+            'code': self.data.get('code', ''),  # server response code
+            'client_id': client_id,
+            'client_secret': client_secret,
+            'redirect_uri': self.get_redirect_uri(state),
+            'scope': self.get_scope_argument()
+        }
+
+    def get_user_id(self, details, response):
+        """Return a unique ID for the current user, by default from server
+        response."""
+        return details.get(self.ID_KEY)
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        url = 'http://opensocial.nk-net.pl/v09/social/rest/people/@me?' + \
+              urlencode({
+                  'nk_token': access_token,
+                  'fields': 'name,surname,avatar,localization,age,' +
+                            'gender,emails,birthdate'
+              })
+        return self.get_json(
+            url,
+            auth=self.oauth_auth(access_token)
+        )
+
+    def oauth_auth(self, token=None, oauth_verifier=None,
+                   signature_type=SIGNATURE_TYPE_AUTH_HEADER):
+        key, secret = self.get_key_and_secret()
+        oauth_verifier = oauth_verifier or self.data.get('oauth_verifier')
+        token = token or {}
+        state = self.get_or_create_state()
+        return OAuth1(key, secret,
+                      resource_owner_key=None,
+                      resource_owner_secret=None,
+                      callback_uri=self.get_redirect_uri(state),
+                      verifier=oauth_verifier,
+                      signature_type=signature_type)
diff --git a/rootfs/api/apps_extra/social_core/backends/oauth.py b/rootfs/api/apps_extra/social_core/backends/oauth.py
new file mode 100644
index 000000000..a22282443
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/oauth.py
@@ -0,0 +1,429 @@
+from urllib.parse import urlencode, unquote
+
+from requests_oauthlib import OAuth1
+from oauthlib.oauth1 import SIGNATURE_TYPE_AUTH_HEADER
+
+from ..utils import url_add_parameters, parse_qs, handle_http_errors, \
+                    constant_time_compare
+from ..exceptions import AuthFailed, AuthCanceled, AuthUnknownError, \
+                         AuthMissingParameter, AuthStateMissing, \
+                         AuthStateForbidden, AuthTokenError
+from .base import BaseAuth
+
+
+class OAuthAuth(BaseAuth):
+    """OAuth authentication backend base class.
+
+    Settings will be inspected to get more values names that should be
+    stored on extra_data field. The setting name is created following the
+    pattern SOCIAL_AUTH_<uppercase current backend name>_EXTRA_DATA.
+
+    access_token is always stored.
+
+    URLs settings:
+        AUTHORIZATION_URL       Authorization service url
+        ACCESS_TOKEN_URL        Access token URL
+    """
+    AUTHORIZATION_URL = ''
+    ACCESS_TOKEN_URL = ''
+    ACCESS_TOKEN_METHOD = 'GET'
+    REVOKE_TOKEN_URL = None
+    REVOKE_TOKEN_METHOD = 'POST'
+    ID_KEY = 'id'
+    SCOPE_PARAMETER_NAME = 'scope'
+    DEFAULT_SCOPE = None
+    SCOPE_SEPARATOR = ' '
+    REDIRECT_STATE = False
+    STATE_PARAMETER = False
+
+    def extra_data(self, user, uid, response, details=None, *args, **kwargs):
+        """Return access_token and extra defined names to store in
+        extra_data field"""
+        data = super().extra_data(user, uid, response, details, *args, **kwargs)
+        data['access_token'] = response.get('access_token', '') or \
+            kwargs.get('access_token')
+        return data
+
+    def state_token(self):
+        """Generate csrf token to include as state parameter."""
+        return self.strategy.random_string(32)
+
+    def get_or_create_state(self):
+        if self.STATE_PARAMETER or self.REDIRECT_STATE:
+            # Store state in session for further request validation. The state
+            # value is passed as state parameter (as specified in OAuth2 spec),
+            # but also added to redirect, that way we can still verify the
+            # request if the provider doesn't implement the state parameter.
+            # Reuse token if any.
+            name = self.name + '_state'
+            state = self.strategy.session_get(name)
+            if state is None:
+                state = self.state_token()
+                self.strategy.session_set(name, state)
+        else:
+            state = None
+        return state
+
+    def get_session_state(self):
+        return self.strategy.session_get(self.name + '_state')
+
+    def get_request_state(self):
+        request_state = self.data.get('state') or \
+                        self.data.get('redirect_state')
+        if request_state and isinstance(request_state, list):
+            request_state = request_state[0]
+        return request_state
+
+    def validate_state(self):
+        """Validate state value. Raises exception on error, returns state
+        value if valid."""
+        if not self.STATE_PARAMETER and not self.REDIRECT_STATE:
+            return None
+        state = self.get_session_state()
+        request_state = self.get_request_state()
+        if not request_state:
+            raise AuthMissingParameter(self, 'state')
+        elif not state:
+            raise AuthStateMissing(self, 'state')
+        elif not constant_time_compare(request_state, state):
+            raise AuthStateForbidden(self)
+        else:
+            return state
+
+    def get_redirect_uri(self, state=None):
+        """Build redirect with redirect_state parameter."""
+        uri = self.redirect_uri
+        if self.REDIRECT_STATE and state:
+            uri = url_add_parameters(uri, {'redirect_state': state})
+        return uri
+
+    def get_scope(self):
+        """Return list with needed access scope"""
+        scope = self.setting('SCOPE', [])
+        if not self.setting('IGNORE_DEFAULT_SCOPE', False):
+            scope = scope + (self.DEFAULT_SCOPE or [])
+        return scope
+
+    def get_scope_argument(self):
+        param = {}
+        scope = self.get_scope()
+        if scope:
+            param[self.SCOPE_PARAMETER_NAME] = self.SCOPE_SEPARATOR.join(scope)
+        return param
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service. Implement in subclass"""
+        return {}
+
+    def authorization_url(self):
+        return self.AUTHORIZATION_URL
+
+    def access_token_url(self):
+        return self.ACCESS_TOKEN_URL
+
+    def revoke_token_url(self, token, uid):
+        return self.REVOKE_TOKEN_URL
+
+    def revoke_token_params(self, token, uid):
+        return {}
+
+    def revoke_token_headers(self, token, uid):
+        return {}
+
+    def process_revoke_token_response(self, response):
+        return response.status_code == 200
+
+    def revoke_token(self, token, uid):
+        if self.REVOKE_TOKEN_URL:
+            url = self.revoke_token_url(token, uid)
+            params = self.revoke_token_params(token, uid)
+            headers = self.revoke_token_headers(token, uid)
+            data = urlencode(params) if self.REVOKE_TOKEN_METHOD != 'GET' \
+                else None
+            response = self.request(url, params=params, headers=headers,
+                                    data=data, method=self.REVOKE_TOKEN_METHOD)
+            return self.process_revoke_token_response(response)
+
+
+class BaseOAuth1(OAuthAuth):
+    """Consumer based mechanism OAuth authentication, fill the needed
+    parameters to communicate properly with authentication service.
+
+    URLs settings:
+        REQUEST_TOKEN_URL       Request token URL
+
+    """
+    REQUEST_TOKEN_URL = ''
+    REQUEST_TOKEN_METHOD = 'GET'
+    OAUTH_TOKEN_PARAMETER_NAME = 'oauth_token'
+    REDIRECT_URI_PARAMETER_NAME = 'redirect_uri'
+    UNATHORIZED_TOKEN_SUFIX = 'unauthorized_token_name'
+
+    def auth_url(self):
+        """Return redirect url"""
+        token = self.set_unauthorized_token()
+        return self.oauth_authorization_request(token)
+
+    def process_error(self, data):
+        if 'oauth_problem' in data:
+            if data['oauth_problem'] == 'user_refused':
+                raise AuthCanceled(self, 'User refused the access')
+            raise AuthUnknownError(self, 'Error was ' + data['oauth_problem'])
+
+    @handle_http_errors
+    def auth_complete(self, *args, **kwargs):
+        """Return user, might be logged in"""
+        # Multiple unauthorized tokens are supported (see #521)
+        self.process_error(self.data)
+        self.validate_state()
+        token = self.get_unauthorized_token()
+        access_token = self.access_token(token)
+        return self.do_auth(access_token, *args, **kwargs)
+
+    @handle_http_errors
+    def do_auth(self, access_token, *args, **kwargs):
+        """Finish the auth process once the access_token was retrieved"""
+        if not isinstance(access_token, dict):
+            access_token = parse_qs(access_token)
+        data = self.user_data(access_token)
+        if data is not None and 'access_token' not in data:
+            data['access_token'] = access_token
+        kwargs.update({'response': data, 'backend': self})
+        return self.strategy.authenticate(*args, **kwargs)
+
+    def get_unauthorized_token(self):
+        name = self.name + self.UNATHORIZED_TOKEN_SUFIX
+        unauthed_tokens = self.strategy.session_get(name, [])
+        if not unauthed_tokens:
+            raise AuthTokenError(self, 'Missing unauthorized token')
+
+        data_token = self.data.get(self.OAUTH_TOKEN_PARAMETER_NAME)
+
+        if data_token is None:
+            raise AuthTokenError(self, 'Missing unauthorized token')
+
+        token = None
+        for utoken in unauthed_tokens:
+            orig_utoken = utoken
+            if not isinstance(utoken, dict):
+                utoken = parse_qs(utoken)
+            if utoken.get(self.OAUTH_TOKEN_PARAMETER_NAME) == data_token:
+                self.strategy.session_set(name, list(set(unauthed_tokens) -
+                                                     set([orig_utoken])))
+                token = utoken
+                break
+        else:
+            raise AuthTokenError(self, 'Incorrect tokens')
+        return token
+
+    def set_unauthorized_token(self):
+        token = self.unauthorized_token()
+        name = self.name + self.UNATHORIZED_TOKEN_SUFIX
+        tokens = self.strategy.session_get(name, []) + [token]
+        self.strategy.session_set(name, tokens)
+        return token
+
+    def request_token_extra_arguments(self):
+        """Return extra arguments needed on request-token process"""
+        return self.setting('REQUEST_TOKEN_EXTRA_ARGUMENTS', {})
+
+    def unauthorized_token(self):
+        """Return request for unauthorized token (first stage)"""
+        params = self.request_token_extra_arguments()
+        params.update(self.get_scope_argument())
+        key, secret = self.get_key_and_secret()
+        state = self.get_or_create_state()
+        response = self.request(
+            self.REQUEST_TOKEN_URL,
+            params=params,
+            auth=OAuth1(key, secret, callback_uri=self.get_redirect_uri(state)),
+            method=self.REQUEST_TOKEN_METHOD
+        )
+        content = response.content
+        if response.encoding or response.apparent_encoding:
+            content = content.decode(response.encoding or
+                                     response.apparent_encoding)
+        else:
+            content = response.content.decode()
+        return content
+
+    def oauth_authorization_request(self, token):
+        """Generate OAuth request to authorize token."""
+        if not isinstance(token, dict):
+            token = parse_qs(token)
+        params = self.auth_extra_arguments() or {}
+        params.update(self.get_scope_argument())
+        params[self.OAUTH_TOKEN_PARAMETER_NAME] = token.get(
+            self.OAUTH_TOKEN_PARAMETER_NAME
+        )
+        state = self.get_or_create_state()
+        params[self.REDIRECT_URI_PARAMETER_NAME] = self.get_redirect_uri(state)
+        return '{0}?{1}'.format(self.authorization_url(), urlencode(params))
+
+    def oauth_auth(self, token=None, oauth_verifier=None,
+                   signature_type=SIGNATURE_TYPE_AUTH_HEADER):
+        key, secret = self.get_key_and_secret()
+        oauth_verifier = oauth_verifier or self.data.get('oauth_verifier')
+        if token:
+            resource_owner_key = token.get('oauth_token')
+            resource_owner_secret = token.get('oauth_token_secret')
+            if not resource_owner_key:
+                raise AuthTokenError(self, 'Missing oauth_token')
+            if not resource_owner_secret:
+                raise AuthTokenError(self, 'Missing oauth_token_secret')
+        else:
+            resource_owner_key = None
+            resource_owner_secret = None
+        state = self.get_or_create_state()
+        return OAuth1(key, secret,
+                      resource_owner_key=resource_owner_key,
+                      resource_owner_secret=resource_owner_secret,
+                      callback_uri=self.get_redirect_uri(state),
+                      verifier=oauth_verifier,
+                      signature_type=signature_type)
+
+    def oauth_request(self, token, url, params=None, method='GET'):
+        """Generate OAuth request, setups callback url"""
+        return self.request(url, method=method, params=params,
+                            auth=self.oauth_auth(token))
+
+    def access_token(self, token):
+        """Return request for access token value"""
+        return self.get_querystring(self.access_token_url(),
+                                    auth=self.oauth_auth(token),
+                                    method=self.ACCESS_TOKEN_METHOD)
+
+
+class BaseOAuth2(OAuthAuth):
+    """Base class for OAuth2 providers.
+
+    OAuth2 draft details at:
+        http://tools.ietf.org/html/draft-ietf-oauth-v2-10
+    """
+    REFRESH_TOKEN_URL = None
+    REFRESH_TOKEN_METHOD = 'POST'
+    RESPONSE_TYPE = 'code'
+    REDIRECT_STATE = True
+    STATE_PARAMETER = True
+
+    def auth_params(self, state=None):
+        client_id, client_secret = self.get_key_and_secret()
+        params = {
+            'client_id': client_id,
+            'redirect_uri': self.get_redirect_uri(state)
+        }
+        if self.STATE_PARAMETER and state:
+            params['state'] = state
+        if self.RESPONSE_TYPE:
+            params['response_type'] = self.RESPONSE_TYPE
+        return params
+
+    def auth_url(self):
+        """Return redirect url"""
+        state = self.get_or_create_state()
+        params = self.auth_params(state)
+        params.update(self.get_scope_argument())
+        params.update(self.auth_extra_arguments())
+        params = urlencode(params)
+        if not self.REDIRECT_STATE:
+            # redirect_uri matching is strictly enforced, so match the
+            # providers value exactly.
+            params = unquote(params)
+        return '{0}?{1}'.format(self.authorization_url(), params)
+
+    def auth_complete_params(self, state=None):
+        client_id, client_secret = self.get_key_and_secret()
+        return {
+            'grant_type': 'authorization_code',  # request auth code
+            'code': self.data.get('code', ''),  # server response code
+            'client_id': client_id,
+            'client_secret': client_secret,
+            'redirect_uri': self.get_redirect_uri(state)
+        }
+
+    def auth_complete_credentials(self):
+        return None
+
+    def auth_headers(self):
+        return {'Content-Type': 'application/x-www-form-urlencoded',
+                'Accept': 'application/json'}
+
+    def extra_data(self, user, uid, response, details=None, *args, **kwargs):
+        """Return access_token, token_type, and extra defined names to store in
+            extra_data field"""
+        data = super().extra_data(user, uid, response, details=details, *args, **kwargs)
+        data['token_type'] = response.get('token_type') or \
+            kwargs.get('token_type')
+        return data
+
+    def request_access_token(self, *args, **kwargs):
+        return self.get_json(*args, **kwargs)
+
+    def process_error(self, data):
+        if data.get('error'):
+            if 'denied' in data['error'] or 'cancelled' in data['error']:
+                raise AuthCanceled(self, data.get('error_description', ''))
+            raise AuthFailed(self, data.get('error_description') or
+                             data['error'])
+        elif 'denied' in data:
+            raise AuthCanceled(self, data['denied'])
+
+    @handle_http_errors
+    def auth_complete(self, *args, **kwargs):
+        """Completes login process, must return user instance"""
+        self.process_error(self.data)
+        state = self.validate_state()
+        data, params = None, None
+        if self.ACCESS_TOKEN_METHOD == 'GET':
+            params = self.auth_complete_params(state)
+        else:
+            data = self.auth_complete_params(state)
+
+        response = self.request_access_token(
+            self.access_token_url(),
+            data=data,
+            params=params,
+            headers=self.auth_headers(),
+            auth=self.auth_complete_credentials(),
+            method=self.ACCESS_TOKEN_METHOD
+        )
+        self.process_error(response)
+        return self.do_auth(response['access_token'], response=response,
+                            *args, **kwargs)
+
+    @handle_http_errors
+    def do_auth(self, access_token, *args, **kwargs):
+        """Finish the auth process once the access_token was retrieved"""
+        data = self.user_data(access_token, *args, **kwargs)
+        response = kwargs.get('response') or {}
+        response.update(data or {})
+        if 'access_token' not in response:
+            response['access_token'] = access_token
+        kwargs.update({'response': response, 'backend': self})
+        return self.strategy.authenticate(*args, **kwargs)
+
+    def refresh_token_params(self, token, *args, **kwargs):
+        client_id, client_secret = self.get_key_and_secret()
+        return {
+            'refresh_token': token,
+            'grant_type': 'refresh_token',
+            'client_id': client_id,
+            'client_secret': client_secret
+        }
+
+    def process_refresh_token_response(self, response, *args, **kwargs):
+        return response.json()
+
+    def refresh_token(self, token, *args, **kwargs):
+        params = self.refresh_token_params(token, *args, **kwargs)
+        url = self.refresh_token_url()
+        method = self.REFRESH_TOKEN_METHOD
+        key = 'params' if method == 'GET' else 'data'
+        request_args = {'headers': self.auth_headers(),
+                        'method': method,
+                        key: params}
+        request = self.request(url, **request_args)
+        return self.process_refresh_token_response(request, *args, **kwargs)
+
+    def refresh_token_url(self):
+        return self.REFRESH_TOKEN_URL or self.access_token_url()
diff --git a/rootfs/api/apps_extra/social_core/backends/odnoklassniki.py b/rootfs/api/apps_extra/social_core/backends/odnoklassniki.py
new file mode 100644
index 000000000..03979c910
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/odnoklassniki.py
@@ -0,0 +1,171 @@
+"""
+Odnoklassniki OAuth2 and Iframe Application backends, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/odnoklassnikiru.html
+"""
+from hashlib import md5
+from urllib.parse import unquote
+
+from .base import BaseAuth
+from .oauth import BaseOAuth2
+from ..exceptions import AuthFailed
+
+
+class OdnoklassnikiOAuth2(BaseOAuth2):
+    """Odnoklassniki authentication backend"""
+    name = 'odnoklassniki-oauth2'
+    ID_KEY = 'uid'
+    ACCESS_TOKEN_METHOD = 'POST'
+    SCOPE_SEPARATOR = ';'
+    AUTHORIZATION_URL = 'https://connect.ok.ru/oauth/authorize'
+    ACCESS_TOKEN_URL = 'https://api.ok.ru/oauth/token.do'
+    EXTRA_DATA = [('refresh_token', 'refresh_token'),
+                  ('expires_in', 'expires')]
+
+    def get_user_details(self, response):
+        """Return user details from Odnoklassniki request"""
+        fullname, first_name, last_name = self.get_user_names(
+            fullname=unquote(response['name']),
+            first_name=unquote(response['first_name']),
+            last_name=unquote(response['last_name'])
+        )
+        return {
+            'username': response['uid'],
+            'email': response.get('email', ''),
+            'fullname': fullname,
+            'first_name': first_name,
+            'last_name': last_name
+        }
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Return user data from Odnoklassniki REST API"""
+        data = {'access_token': access_token, 'method': 'users.getCurrentUser'}
+        key, secret = self.get_key_and_secret()
+        public_key = self.setting('PUBLIC_NAME')
+        return odnoklassniki_api(self, data, 'https://api.ok.ru/',
+                                 public_key, secret, 'oauth')
+
+
+class OdnoklassnikiApp(BaseAuth):
+    """Odnoklassniki iframe app authentication backend"""
+    name = 'odnoklassniki-app'
+    ID_KEY = 'uid'
+
+    def extra_data(self, user, uid, response, details=None, *args, **kwargs):
+        return dict([(key, value) for key, value in response.items()
+                     if key in response['extra_data_list']])
+
+    def get_user_details(self, response):
+        fullname, first_name, last_name = self.get_user_names(
+            fullname=unquote(response['name']),
+            first_name=unquote(response['first_name']),
+            last_name=unquote(response['last_name'])
+        )
+        return {
+            'username': response['uid'],
+            'email': '',
+            'fullname': fullname,
+            'first_name': first_name,
+            'last_name': last_name
+        }
+
+    def auth_complete(self, *args, **kwargs):
+        self.verify_auth_sig()
+        response = self.get_response()
+        fields = ('uid', 'first_name', 'last_name', 'name') + \
+            self.setting('EXTRA_USER_DATA_LIST', ())
+        data = {
+            'method': 'users.getInfo',
+            'uids': '{0}'.format(response['logged_user_id']),
+            'fields': ','.join(fields),
+        }
+        client_key, client_secret = self.get_key_and_secret()
+        public_key = self.setting('PUBLIC_NAME')
+        details = odnoklassniki_api(self, data, response['api_server'],
+                                    public_key, client_secret,
+                                    'iframe_nosession')
+        if len(details) == 1 and 'uid' in details[0]:
+            details = details[0]
+            auth_data_fields = self.setting('EXTRA_AUTH_DATA_LIST',
+                                            ('api_server', 'apiconnection',
+                                             'session_key', 'authorized',
+                                             'session_secret_key'))
+
+            for field in auth_data_fields:
+                details[field] = response[field]
+            details['extra_data_list'] = fields + auth_data_fields
+            kwargs.update({'backend': self, 'response': details})
+        else:
+            raise AuthFailed(self, 'Cannot get user details: API error')
+        return self.strategy.authenticate(*args, **kwargs)
+
+    def get_auth_sig(self):
+        secret_key = self.setting('SECRET')
+        hash_source = '{0:s}{1:s}{2:s}'.format(self.data['logged_user_id'],
+                                               self.data['session_key'],
+                                               secret_key)
+        return md5(hash_source.encode('utf-8')).hexdigest()
+
+    def get_response(self):
+        fields = ('logged_user_id', 'api_server', 'application_key',
+                  'session_key', 'session_secret_key', 'authorized',
+                  'apiconnection')
+        return dict((name, self.data[name]) for name in fields
+                    if name in self.data)
+
+    def verify_auth_sig(self):
+        correct_key = self.get_auth_sig()
+        key = self.data['auth_sig'].lower()
+        if correct_key != key:
+            raise AuthFailed(self, 'Wrong authorization key')
+
+
+def odnoklassniki_oauth_sig(data, client_secret):
+    """
+    Calculates signature of request data access_token value must be included
+    Algorithm is described at
+        https://apiok.ru/wiki/pages/viewpage.action?pageId=12878032,
+    search for "little bit different way"
+    """
+    suffix = md5(
+        '{0:s}{1:s}'.format(data['access_token'],
+                            client_secret).encode('utf-8')
+    ).hexdigest()
+    check_list = sorted(['{0:s}={1:s}'.format(key, value)
+                         for key, value in data.items()
+                         if key != 'access_token'])
+    return md5((''.join(check_list) + suffix).encode('utf-8')).hexdigest()
+
+
+def odnoklassniki_iframe_sig(data, client_secret_or_session_secret):
+    """
+    Calculates signature as described at:
+        https://apiok.ru/wiki/display/ok/Authentication+and+Authorization
+    If API method requires session context, request is signed with session
+    secret key. Otherwise it is signed with application secret key
+    """
+    param_list = sorted(['{0:s}={1:s}'.format(key, value)
+                         for key, value in data.items()])
+    return md5(
+        (''.join(param_list) + client_secret_or_session_secret).encode('utf-8')
+    ).hexdigest()
+
+
+def odnoklassniki_api(backend, data, api_url, public_key, client_secret,
+                      request_type='oauth'):
+    """Calls Odnoklassniki REST API method
+    https://apiok.ru/wiki/display/ok/Odnoklassniki+Rest+API"""
+    data.update({
+        'application_key': public_key,
+        'format': 'JSON'
+    })
+    if request_type == 'oauth':
+        data['sig'] = odnoklassniki_oauth_sig(data, client_secret)
+    elif request_type == 'iframe_session':
+        data['sig'] = odnoklassniki_iframe_sig(data,
+                                               data['session_secret_key'])
+    elif request_type == 'iframe_nosession':
+        data['sig'] = odnoklassniki_iframe_sig(data, client_secret)
+    else:
+        msg = 'Unknown request type {0}. How should it be signed?'
+        raise AuthFailed(backend, msg.format(request_type))
+    return backend.get_json(api_url + 'fb.do', params=data)
diff --git a/rootfs/api/apps_extra/social_core/backends/okta.py b/rootfs/api/apps_extra/social_core/backends/okta.py
new file mode 100644
index 000000000..b228e21fb
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/okta.py
@@ -0,0 +1,65 @@
+"""
+Okta OAuth2 and OpenIdConnect:
+    https://python-social-auth.readthedocs.io/en/latest/backends/okta.html
+"""
+from urllib.parse import urljoin
+
+from ..utils import append_slash
+from .oauth import BaseOAuth2
+
+
+class OktaMixin:
+    def api_url(self):
+        return append_slash(self.setting('API_URL'))
+
+    def authorization_url(self):
+        return self._url('v1/authorize')
+
+    def access_token_url(self):
+        return self._url('v1/token')
+
+    def _url(self, path):
+        return urljoin(append_slash(self.setting('API_URL')), path)
+
+    def oidc_config(self):
+        return self.get_json(
+            self._url(
+                './.well-known/openid-configuration?client_id={}'.format(
+                    self.setting('KEY')
+                )
+            )
+        )
+
+
+class OktaOAuth2(OktaMixin, BaseOAuth2):
+    """Okta OAuth authentication backend"""
+    name = 'okta-oauth2'
+    REDIRECT_STATE = False
+    ACCESS_TOKEN_METHOD = 'POST'
+    SCOPE_SEPARATOR = ' '
+    ID_KEY = 'preferred_username'
+
+    DEFAULT_SCOPE = [
+        'openid', 'profile', 'email'
+    ]
+    EXTRA_DATA = [
+        ('refresh_token', 'refresh_token', True),
+        ('expires_in', 'expires'),
+        ('token_type', 'token_type', True)
+    ]
+
+    def get_user_details(self, response):
+        """Return user details from Okta account"""
+        return {'username': response.get('preferred_username'),
+                'email': response.get('email') or '',
+                'first_name': response.get('given_name'),
+                'last_name': response.get('family_name')}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from Okta"""
+        return self.get_json(
+            self._url('v1/userinfo'),
+            headers={
+                'Authorization': 'Bearer {}'.format(access_token),
+            }
+        )
diff --git a/rootfs/api/apps_extra/social_core/backends/okta_openidconnect.py b/rootfs/api/apps_extra/social_core/backends/okta_openidconnect.py
new file mode 100644
index 000000000..0f6a41908
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/okta_openidconnect.py
@@ -0,0 +1,14 @@
+"""
+Okta OAuth2 and OpenIdConnect:
+    https://python-social-auth.readthedocs.io/en/latest/backends/okta.html
+"""
+from .okta import OktaOAuth2
+from .open_id_connect import OpenIdConnectAuth
+
+
+class OktaOpenIdConnect(OktaOAuth2, OpenIdConnectAuth):
+    """Okta OpenID-Connect authentication backend"""
+    name = 'okta-openidconnect'
+    REDIRECT_STATE = False
+    ACCESS_TOKEN_METHOD = 'POST'
+    RESPONSE_TYPE = 'code'
diff --git a/rootfs/api/apps_extra/social_core/backends/open_id.py b/rootfs/api/apps_extra/social_core/backends/open_id.py
new file mode 100644
index 000000000..38b217c62
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/open_id.py
@@ -0,0 +1,255 @@
+from openid.consumer.consumer import Consumer, SUCCESS, CANCEL, FAILURE
+from openid.consumer.discover import DiscoveryFailure
+from openid.extensions import sreg, ax, pape
+
+from ..utils import url_add_parameters
+from .base import BaseAuth
+from ..exceptions import AuthException, AuthFailed, AuthCanceled, \
+                         AuthUnknownError, AuthMissingParameter
+
+
+# OpenID configuration
+OLD_AX_ATTRS = [
+    ('http://schema.openid.net/contact/email', 'old_email'),
+    ('http://schema.openid.net/namePerson', 'old_fullname'),
+    ('http://schema.openid.net/namePerson/friendly', 'old_nickname')
+]
+AX_SCHEMA_ATTRS = [
+    # Request both the full name and first/last components since some
+    # providers offer one but not the other.
+    ('http://axschema.org/contact/email', 'email'),
+    ('http://axschema.org/namePerson', 'fullname'),
+    ('http://axschema.org/namePerson/first', 'first_name'),
+    ('http://axschema.org/namePerson/last', 'last_name'),
+    ('http://axschema.org/namePerson/friendly', 'nickname'),
+]
+SREG_ATTR = [
+    ('email', 'email'),
+    ('fullname', 'fullname'),
+    ('nickname', 'nickname')
+]
+OPENID_ID_FIELD = 'openid_identifier'
+SESSION_NAME = 'openid'
+
+
+class OpenIdAuth(BaseAuth):
+    """Generic OpenID authentication backend"""
+    name = 'openid'
+    URL = None
+    USERNAME_KEY = 'username'
+
+    def get_user_id(self, details, response):
+        """Return user unique id provided by service"""
+        return response.identity_url
+
+    def get_ax_attributes(self):
+        attrs = self.setting('AX_SCHEMA_ATTRS', [])
+        if attrs and self.setting('IGNORE_DEFAULT_AX_ATTRS', True):
+            return attrs
+        return attrs + AX_SCHEMA_ATTRS + OLD_AX_ATTRS
+
+    def get_sreg_attributes(self):
+        return self.setting('SREG_ATTR') or SREG_ATTR
+
+    def values_from_response(self, response, sreg_names=None, ax_names=None):
+        """Return values from SimpleRegistration response or
+        AttributeExchange response if present.
+
+        @sreg_names and @ax_names must be a list of name and aliases
+        for such name. The alias will be used as mapping key.
+        """
+        values = {}
+
+        # Use Simple Registration attributes if provided
+        if sreg_names:
+            resp = sreg.SRegResponse.fromSuccessResponse(response)
+            if resp:
+                values.update((alias, resp.get(name) or '')
+                              for name, alias in sreg_names)
+
+        # Use Attribute Exchange attributes if provided
+        if ax_names:
+            resp = ax.FetchResponse.fromSuccessResponse(response)
+            if resp:
+                for src, alias in ax_names:
+                    name = alias.replace('old_', '')
+                    values[name] = resp.getSingle(src, '') or values.get(name)
+        return values
+
+    def get_user_details(self, response):
+        """Return user details from an OpenID request"""
+        values = {'username': '', 'email': '', 'fullname': '',
+                  'first_name': '', 'last_name': ''}
+        # update values using SimpleRegistration or AttributeExchange
+        # values
+        values.update(self.values_from_response(
+            response, self.get_sreg_attributes(), self.get_ax_attributes()
+        ))
+
+        fullname = values.get('fullname') or ''
+        first_name = values.get('first_name') or ''
+        last_name = values.get('last_name') or ''
+        email = values.get('email') or ''
+
+        if not fullname and first_name and last_name:
+            fullname = first_name + ' ' + last_name
+        elif fullname:
+            try:
+                first_name, last_name = fullname.rsplit(' ', 1)
+            except ValueError:
+                last_name = fullname
+
+        username_key = self.setting('USERNAME_KEY') or self.USERNAME_KEY
+        values.update({'fullname': fullname, 'first_name': first_name,
+                       'last_name': last_name,
+                       'username': values.get(username_key) or (
+                           first_name.title() + last_name.title()
+                       ),
+                       'email': email})
+        return values
+
+    def extra_data(self, user, uid, response, details=None, *args, **kwargs):
+        """Return defined extra data names to store in extra_data field.
+        Settings will be inspected to get more values names that should be
+        stored on extra_data field. Setting name is created from current
+        backend name (all uppercase) plus _SREG_EXTRA_DATA and
+        _AX_EXTRA_DATA because values can be returned by SimpleRegistration
+        or AttributeExchange schemas.
+
+        Both list must be a value name and an alias mapping similar to
+        SREG_ATTR, OLD_AX_ATTRS or AX_SCHEMA_ATTRS
+        """
+        sreg_names = self.setting('SREG_EXTRA_DATA')
+        ax_names = self.setting('AX_EXTRA_DATA')
+        values = self.values_from_response(response, sreg_names, ax_names)
+        from_details = super().extra_data(user, uid, {}, details, *args, **kwargs)
+        values.update(from_details)
+        return values
+
+    def auth_url(self):
+        """Return auth URL returned by service"""
+        openid_request = self.setup_request(self.auth_extra_arguments())
+        # Construct completion URL, including page we should redirect to
+        return_to = self.strategy.absolute_uri(self.redirect_uri)
+        return openid_request.redirectURL(self.trust_root(), return_to)
+
+    def auth_html(self):
+        """Return auth HTML returned by service"""
+        openid_request = self.setup_request(self.auth_extra_arguments())
+        return_to = self.strategy.absolute_uri(self.redirect_uri)
+        form_tag = {'id': 'openid_message'}
+        return openid_request.htmlMarkup(self.trust_root(), return_to,
+                                         form_tag_attrs=form_tag)
+
+    def trust_root(self):
+        """Return trust-root option"""
+        return self.setting('OPENID_TRUST_ROOT') or \
+            self.strategy.absolute_uri('/')
+
+    def continue_pipeline(self, partial):
+        """Continue previous halted pipeline"""
+        response = self.consumer().complete(dict(self.data.items()),
+                                            self.strategy.absolute_uri(
+                                                self.redirect_uri
+                                            ))
+        return self.strategy.authenticate(self,
+                                          response=response,
+                                          pipeline_index=partial.next_step,
+                                          *partial.args,
+                                          **partial.kwargs)
+
+    def auth_complete(self, *args, **kwargs):
+        """Complete auth process"""
+        response = self.consumer().complete(dict(self.data.items()),
+                                            self.strategy.absolute_uri(
+                                                self.redirect_uri
+                                            ))
+        self.process_error(response)
+        return self.strategy.authenticate(self, response=response,
+                                          *args, **kwargs)
+
+    def process_error(self, data):
+        if not data:
+            raise AuthException(self, 'OpenID relying party endpoint')
+        elif data.status == FAILURE:
+            raise AuthFailed(self, data.message)
+        elif data.status == CANCEL:
+            raise AuthCanceled(self)
+        elif data.status != SUCCESS:
+            raise AuthUnknownError(self, data.status)
+
+    def setup_request(self, params=None):
+        """Setup request"""
+        request = self.openid_request(params)
+        # Request some user details. Use attribute exchange if provider
+        # advertises support.
+        if request.endpoint.supportsType(ax.AXMessage.ns_uri):
+            fetch_request = ax.FetchRequest()
+            # Mark all attributes as required, Google ignores optional ones
+            for attr, alias in self.get_ax_attributes():
+                fetch_request.add(ax.AttrInfo(attr, alias=alias,
+                                              required=True))
+        else:
+            fetch_request = sreg.SRegRequest(
+                optional=list(dict(self.get_sreg_attributes()).keys())
+            )
+        request.addExtension(fetch_request)
+
+        # Add PAPE Extension for if configured
+        preferred_policies = self.setting(
+            'OPENID_PAPE_PREFERRED_AUTH_POLICIES'
+        )
+        preferred_level_types = self.setting(
+            'OPENID_PAPE_PREFERRED_AUTH_LEVEL_TYPES'
+        )
+        max_age = self.setting('OPENID_PAPE_MAX_AUTH_AGE')
+        if max_age is not None:
+            try:
+                max_age = int(max_age)
+            except (ValueError, TypeError):
+                max_age = None
+
+        if max_age is not None or preferred_policies or preferred_level_types:
+            pape_request = pape.Request(
+                max_auth_age=max_age,
+                preferred_auth_policies=preferred_policies,
+                preferred_auth_level_types=preferred_level_types
+            )
+            request.addExtension(pape_request)
+        return request
+
+    def consumer(self):
+        """Create an OpenID Consumer object for the given Django request."""
+        if not hasattr(self, '_consumer'):
+            self._consumer = self.create_consumer(self.strategy.openid_store())
+        return self._consumer
+
+    def create_consumer(self, store=None):
+        return Consumer(self.strategy.openid_session_dict(SESSION_NAME), store)
+
+    def uses_redirect(self):
+        """Return true if openid request will be handled with redirect or
+        HTML content will be returned.
+        """
+        return self.openid_request().shouldSendRedirect()
+
+    def openid_request(self, params=None):
+        """Return openid request"""
+        try:
+            return self.consumer().begin(url_add_parameters(self.openid_url(),
+                                         params))
+        except DiscoveryFailure as err:
+            raise AuthException(self, 'OpenID discovery error: {0}'.format(
+                err
+            ))
+
+    def openid_url(self):
+        """Return service provider URL.
+        This base class is generic accepting a POST parameter that specifies
+        provider URL."""
+        if self.URL:
+            return self.URL
+        elif OPENID_ID_FIELD in self.data:
+            return self.data[OPENID_ID_FIELD]
+        else:
+            raise AuthMissingParameter(self, OPENID_ID_FIELD)
diff --git a/rootfs/api/apps_extra/social_core/backends/open_id_connect.py b/rootfs/api/apps_extra/social_core/backends/open_id_connect.py
new file mode 100644
index 000000000..c770b934e
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/open_id_connect.py
@@ -0,0 +1,213 @@
+import json
+import datetime
+from calendar import timegm
+
+from jose import jwk, jwt
+from jose.jwt import JWTError, JWTClaimsError, ExpiredSignatureError
+from jose.utils import base64url_decode
+
+from social_core.backends.oauth import BaseOAuth2
+from social_core.utils import cache
+from social_core.exceptions import AuthTokenError
+
+
+class OpenIdConnectAssociation:
+    """ Use Association model to save the nonce by force."""
+
+    def __init__(self, handle, secret='', issued=0, lifetime=0, assoc_type=''):
+        self.handle = handle  # as nonce
+        self.secret = secret.encode()  # not use
+        self.issued = issued  # not use
+        self.lifetime = lifetime  # not use
+        self.assoc_type = assoc_type  # as state
+
+
+class OpenIdConnectAuth(BaseOAuth2):
+    """
+    Base class for Open ID Connect backends.
+    Currently only the code response type is supported.
+    """
+    # Override OIDC_ENDPOINT in your subclass to enable autoconfig of OIDC
+    OIDC_ENDPOINT = None
+    ID_TOKEN_MAX_AGE = 600
+    DEFAULT_SCOPE = ['openid', 'profile', 'email']
+    EXTRA_DATA = ['id_token', 'refresh_token', ('sub', 'id')]
+    REDIRECT_STATE = False
+    ACCESS_TOKEN_METHOD = 'POST'
+    REVOKE_TOKEN_METHOD = 'GET'
+    ID_KEY = 'sub'
+    USERNAME_KEY = 'preferred_username'
+    ID_TOKEN_ISSUER = ''
+    ACCESS_TOKEN_URL = ''
+    AUTHORIZATION_URL = ''
+    REVOKE_TOKEN_URL = ''
+    USERINFO_URL = ''
+    JWKS_URI = ''
+    JWT_ALGORITHMS = ['RS256']
+    JWT_DECODE_OPTIONS = dict()
+
+    def __init__(self, *args, **kwargs):
+        self.id_token = None
+        super().__init__(*args, **kwargs)
+
+    def authorization_url(self):
+        return self.AUTHORIZATION_URL or \
+            self.oidc_config().get('authorization_endpoint')
+
+    def access_token_url(self):
+        return self.ACCESS_TOKEN_URL or \
+            self.oidc_config().get('token_endpoint')
+
+    def revoke_token_url(self, token, uid):
+        return self.REVOKE_TOKEN_URL or \
+            self.oidc_config().get('revocation_endpoint')
+
+    def id_token_issuer(self):
+        return self.ID_TOKEN_ISSUER or \
+            self.oidc_config().get('issuer')
+
+    def userinfo_url(self):
+        return self.USERINFO_URL or \
+            self.oidc_config().get('userinfo_endpoint')
+
+    def jwks_uri(self):
+        return self.JWKS_URI or \
+            self.oidc_config().get('jwks_uri')
+
+    @cache(ttl=86400)
+    def oidc_config(self):
+        return self.get_json(self.OIDC_ENDPOINT +
+                             '/.well-known/openid-configuration')
+
+    @cache(ttl=86400)
+    def get_jwks_keys(self):
+        keys = self.get_remote_jwks_keys()
+
+        # Add client secret as oct key so it can be used for HMAC signatures
+        # client_id, client_secret = self.get_key_and_secret()
+        # keys.append({'key': client_secret, 'kty': 'oct'})
+        return keys
+
+    def get_remote_jwks_keys(self):
+        response = self.request(self.jwks_uri())
+        return json.loads(response.text)['keys']
+
+    def auth_params(self, state=None):
+        """Return extra arguments needed on auth process."""
+        params = super().auth_params(state)
+        params['nonce'] = self.get_and_store_nonce(
+            self.authorization_url(), state
+        )
+        return params
+
+    def get_and_store_nonce(self, url, state):
+        # Create a nonce
+        nonce = self.strategy.random_string(64)
+        # Store the nonce
+        association = OpenIdConnectAssociation(nonce, assoc_type=state)
+        self.strategy.storage.association.store(url, association)
+        return nonce
+
+    def get_nonce(self, nonce):
+        try:
+            return self.strategy.storage.association.get(
+                server_url=self.authorization_url(),
+                handle=nonce
+            )[0]
+        except IndexError:
+            pass
+
+    def remove_nonce(self, nonce_id):
+        self.strategy.storage.association.remove([nonce_id])
+
+    def validate_claims(self, id_token):
+        utc_timestamp = timegm(datetime.datetime.utcnow().utctimetuple())
+
+        if 'nbf' in id_token and utc_timestamp < id_token['nbf']:
+            raise AuthTokenError(self, 'Incorrect id_token: nbf')
+
+        # Verify the token was issued in the last 10 minutes
+        iat_leeway = self.setting('ID_TOKEN_MAX_AGE', self.ID_TOKEN_MAX_AGE)
+        if utc_timestamp > id_token['iat'] + iat_leeway:
+            raise AuthTokenError(self, 'Incorrect id_token: iat')
+
+        # Validate the nonce to ensure the request was not modified
+        nonce = id_token.get('nonce')
+        if not nonce:
+            raise AuthTokenError(self, 'Incorrect id_token: nonce')
+
+        nonce_obj = self.get_nonce(nonce)
+        if nonce_obj:
+            self.remove_nonce(nonce_obj.id)
+        else:
+            raise AuthTokenError(self, 'Incorrect id_token: nonce')
+
+    def find_valid_key(self, id_token):
+        for key in self.get_jwks_keys():
+            rsakey = jwk.construct(key)
+            message, encoded_sig = id_token.rsplit('.', 1)
+            decoded_sig = base64url_decode(encoded_sig.encode('utf-8'))
+            if rsakey.verify(message.encode('utf-8'), decoded_sig):
+                return key
+
+    def validate_and_return_id_token(self, id_token, access_token):
+        """
+        Validates the id_token according to the steps at
+        http://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation.
+        """
+        client_id, client_secret = self.get_key_and_secret()
+
+        key = self.find_valid_key(id_token)
+
+        if not key:
+            raise AuthTokenError(self, 'Signature verification failed')
+
+        rsakey = jwk.construct(key)
+
+        try:
+            claims = jwt.decode(
+                id_token,
+                rsakey.to_pem().decode('utf-8'),
+                algorithms=self.JWT_ALGORITHMS,
+                audience=client_id,
+                issuer=self.id_token_issuer(),
+                access_token=access_token,
+                options=self.JWT_DECODE_OPTIONS,
+            )
+        except ExpiredSignatureError:
+            raise AuthTokenError(self, 'Signature has expired')
+        except JWTClaimsError as error:
+            raise AuthTokenError(self, str(error))
+        except JWTError:
+            raise AuthTokenError(self, 'Invalid signature')
+
+        self.validate_claims(claims)
+
+        return claims
+
+    def request_access_token(self, *args, **kwargs):
+        """
+        Retrieve the access token. Also, validate the id_token and
+        store it (temporarily).
+        """
+        response = self.get_json(*args, **kwargs)
+        self.id_token = self.validate_and_return_id_token(
+            response['id_token'],
+            response['access_token']
+        )
+        return response
+
+    def user_data(self, access_token, *args, **kwargs):
+        return self.get_json(self.userinfo_url(), headers={
+            'Authorization': 'Bearer {0}'.format(access_token)
+        })
+
+    def get_user_details(self, response):
+        username_key = self.setting('USERNAME_KEY', default=self.USERNAME_KEY)
+        return {
+            'username': response.get(username_key),
+            'email': response.get('email'),
+            'fullname': response.get('name'),
+            'first_name': response.get('given_name'),
+            'last_name': response.get('family_name'),
+        }
diff --git a/rootfs/api/apps_extra/social_core/backends/openshift.py b/rootfs/api/apps_extra/social_core/backends/openshift.py
new file mode 100644
index 000000000..8885bb981
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/openshift.py
@@ -0,0 +1,39 @@
+"""
+Openshift OAuth2 backend
+"""
+from urllib.parse import urljoin
+
+import requests
+
+from ..utils import append_slash
+from .oauth import BaseOAuth2
+
+
+class OpenshiftOAuth2(BaseOAuth2):
+    name = 'openshift'
+    ACCESS_TOKEN_METHOD = 'POST'
+
+    def access_token_url(self):
+        return urljoin(append_slash(self.setting('URL')), 'oauth/token')
+
+    def authorization_url(self):
+        return urljoin(append_slash(self.setting('URL')), 'oauth/authorize')
+
+    def get_user_id(self, details, response):
+        return response['metadata']['uid']
+
+    def get_user_details(self, response):
+        """Return user details from openshift account"""
+        username = response['metadata']['name']
+        email = response['metadata']['name']
+        return {'username': username,
+                'email': email}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        headers = {'Authorization': 'Bearer ' + access_token}
+
+        return requests.get(
+            urljoin(append_slash(self.setting('URL')), 'oapi/v1/users/~'),
+            headers=headers
+        ).json()
diff --git a/rootfs/api/apps_extra/social_core/backends/openstack.py b/rootfs/api/apps_extra/social_core/backends/openstack.py
new file mode 100644
index 000000000..b9568513c
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/openstack.py
@@ -0,0 +1,47 @@
+"""
+OpenStack OpenId backend
+"""
+from urllib.parse import urlsplit
+from openid.extensions import ax
+
+from .open_id import OpenIdAuth
+
+
+class OpenStackOpenId(OpenIdAuth):
+    name = 'openstack'
+    URL = 'openstackid.org'
+
+    def get_user_details(self, response):
+        """Generate username from identity url"""
+        values = super().get_user_details(response)
+        values['username'] = values.get('username') or \
+            urlsplit(response.identity_url).path.strip('/')
+        values['nickname'] = values.get('nickname', '')
+        return values
+
+    def setup_request(self, params=None):
+        """Fetch email, firstname, lastname from openid"""
+        request = self.openid_request(params)
+
+        # TODO: use sreg instead ax request to fetch nickname as username
+        fetch_request = ax.FetchRequest()
+        fetch_request.add(ax.AttrInfo(
+            'http://axschema.org/contact/email',
+            alias='email',
+            required=True
+        ))
+
+        fetch_request.add(ax.AttrInfo(
+            'http://axschema.org/namePerson/first',
+            alias='firstname',
+            required=True
+        ))
+
+        fetch_request.add(ax.AttrInfo(
+            'http://axschema.org/namePerson/last',
+            alias='lastname',
+            required=True
+        ))
+
+        request.addExtension(fetch_request)
+        return request
diff --git a/rootfs/api/apps_extra/social_core/backends/openstackdev.py b/rootfs/api/apps_extra/social_core/backends/openstackdev.py
new file mode 100644
index 000000000..b0f46f849
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/openstackdev.py
@@ -0,0 +1,47 @@
+"""
+OpenStack OpenId backend
+"""
+from urllib.parse import urlsplit
+from openid.extensions import ax
+
+from .open_id import OpenIdAuth
+
+
+class OpenStackDevOpenId(OpenIdAuth):
+    name = 'openstackdev'
+    URL = 'openstackid-dev.openstack.org'
+
+    def get_user_details(self, response):
+        """Generate username from identity url"""
+        values = super().get_user_details(response)
+        values['username'] = values.get('username') or \
+            urlsplit(response.identity_url).path.strip('/')
+        values['nickname'] = values.get('nickname', '')
+        return values
+
+    def setup_request(self, params=None):
+        """Fetch email, firstname, lastname from openid"""
+        request = self.openid_request(params)
+
+        # TODO: use sreg instead ax request to fetch nickname as username
+        fetch_request = ax.FetchRequest()
+        fetch_request.add(ax.AttrInfo(
+            'http://axschema.org/contact/email',
+            alias='email',
+            required=True
+        ))
+
+        fetch_request.add(ax.AttrInfo(
+            'http://axschema.org/namePerson/first',
+            alias='firstname',
+            required=True
+        ))
+
+        fetch_request.add(ax.AttrInfo(
+            'http://axschema.org/namePerson/last',
+            alias='lastname',
+            required=True
+        ))
+
+        request.addExtension(fetch_request)
+        return request
diff --git a/rootfs/api/apps_extra/social_core/backends/openstreetmap.py b/rootfs/api/apps_extra/social_core/backends/openstreetmap.py
new file mode 100644
index 000000000..1f4acdbd4
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/openstreetmap.py
@@ -0,0 +1,57 @@
+"""
+OpenStreetMap OAuth support.
+
+This adds support for OpenStreetMap OAuth service. An application must be
+registered first on OpenStreetMap and the settings
+SOCIAL_AUTH_OPENSTREETMAP_KEY and SOCIAL_AUTH_OPENSTREETMAP_SECRET
+must be defined with the corresponding values.
+
+More info: https://wiki.openstreetmap.org/wiki/OAuth
+"""
+from xml.dom import minidom
+
+from .oauth import BaseOAuth1
+
+
+class OpenStreetMapOAuth(BaseOAuth1):
+    """OpenStreetMap OAuth authentication backend"""
+    name = 'openstreetmap'
+    AUTHORIZATION_URL = 'https://www.openstreetmap.org/oauth/authorize'
+    REQUEST_TOKEN_URL = 'https://www.openstreetmap.org/oauth/request_token'
+    ACCESS_TOKEN_URL = 'https://www.openstreetmap.org/oauth/access_token'
+    EXTRA_DATA = [
+        ('id', 'id'),
+        ('avatar', 'avatar'),
+        ('account_created', 'account_created')
+    ]
+
+    def get_user_details(self, response):
+        """Return user details from OpenStreetMap account"""
+        return {
+            'username': response['username'],
+            'email': '',
+            'fullname': '',
+            'first_name': '',
+            'last_name': ''
+        }
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Return user data provided"""
+        response = self.oauth_request(
+            access_token, 'https://api.openstreetmap.org/api/0.6/user/details'
+        )
+        try:
+            dom = minidom.parseString(response.content)
+        except ValueError:
+            return None
+        user = dom.getElementsByTagName('user')[0]
+        try:
+            avatar = dom.getElementsByTagName('img')[0].getAttribute('href')
+        except IndexError:
+            avatar = None
+        return {
+            'id': user.getAttribute('id'),
+            'username': user.getAttribute('display_name'),
+            'account_created': user.getAttribute('account_created'),
+            'avatar': avatar
+        }
diff --git a/rootfs/api/apps_extra/social_core/backends/orbi.py b/rootfs/api/apps_extra/social_core/backends/orbi.py
new file mode 100644
index 000000000..453581a89
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/orbi.py
@@ -0,0 +1,42 @@
+"""
+Orbi OAuth2 backend
+"""
+from .oauth import BaseOAuth2
+
+
+class OrbiOAuth2(BaseOAuth2):
+    """Orbi OAuth2 authentication backend"""
+    name = 'orbi'
+    AUTHORIZATION_URL = 'https://login.orbi.kr/oauth/authorize'
+    ACCESS_TOKEN_URL = 'https://login.orbi.kr/oauth/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    EXTRA_DATA = [
+        ('imin', 'imin'),
+        ('nick', 'nick'),
+        ('photo', 'photo'),
+        ('sex', 'sex'),
+        ('birth', 'birth'),
+    ]
+
+    def get_user_id(self, details, response):
+        return response.get('id')
+
+    def get_user_details(self, response):
+        fullname, first_name, last_name = self.get_user_names(
+            response.get('name', ''),
+            response.get('first_name', ''),
+            response.get('last_name', '')
+        )
+        return {
+            'username': response.get('username', response.get('name')),
+            'email': response.get('email', ''),
+            'fullname': fullname,
+            'first_name': first_name,
+            'last_name': last_name,
+        }
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Load user data from orbi"""
+        return self.get_json('https://login.orbi.kr/oauth/user/get', params={
+            'access_token': access_token
+        })
diff --git a/rootfs/api/apps_extra/social_core/backends/orcid.py b/rootfs/api/apps_extra/social_core/backends/orcid.py
new file mode 100644
index 000000000..fa68fd386
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/orcid.py
@@ -0,0 +1,159 @@
+"""
+    ORCID OAuth2 Application backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/orcid.html
+"""
+from .oauth import BaseOAuth2
+
+class ORCIDOAuth2(BaseOAuth2):
+    """ORCID OAuth2 authentication backend"""
+    name = 'orcid'
+    ID_KEY = 'orcid'
+    AUTHORIZATION_URL = 'https://orcid.org/oauth/authorize'
+    ACCESS_TOKEN_URL = 'https://orcid.org/oauth/token'
+    USER_ID_URL = 'https://orcid.org/oauth/userinfo'
+    USER_DATA_URL = 'https://pub.orcid.org/v2.0/{}'
+    DEFAULT_SCOPE = ['/authenticate']
+    ACCESS_TOKEN_METHOD = 'POST'
+    EXTRA_DATA = [
+        ('orcid', 'id'),
+        ('expires_in', 'expires'),
+        ('refresh_token', 'refresh_token')
+    ]
+
+    def auth_params(self, state=None):
+        params = super().auth_params(state)
+        return params
+
+    def get_user_details(self, response):
+        """Return user details from ORCID account"""
+
+        # response data will be of the following format:
+        # {
+        #     'orcid-identifier': {
+        #         'uri': 'http://orcid.org/0000-0002-2601-8132',
+        #         'path': '0000-0002-2601-8132',
+        #         'host': 'orcid.org'
+        #     },
+        #     'person': {
+        #         'last-modified-date': None,
+        #         'name': {
+        #             'created-date': {
+        #                 'value': 1578249746904
+        #             },
+        #             'last-modified-date': {
+        #                 'value': 1578249746904
+        #             },
+        #             'given-names': {
+        #                 'value': 'Janani Kantharooban'
+        #             },
+        #             'family-name': {
+        #                 'value': 'Umachanger'
+        #             },
+        #             'credit-name': None,
+        #             'source': None,
+        #             'visibility': 'PUBLIC',
+        #             'path': '0000-0002-2601-8132'
+        #         },
+        #     }
+        # }
+        orcid_identifier = response.get('orcid-identifier')
+
+        fullname = first_name = last_name = email = username = ''
+
+        person = response.get('person')
+
+        # Although we're checking here, the response will always have the orcid-identifier key:
+        if orcid_identifier:
+            username = orcid_identifier['path']
+
+        if person:
+            name = person.get('name')
+
+            fullname = name
+
+            if name:
+                first_name = name.get('given-names', {}).get('value', '')
+                last_name = name.get('family-name', {}).get('value', '')
+
+            emails = person.get('emails')
+            if emails:
+                emails_list = emails.get('email')
+                if emails_list and len(emails_list) > 0:
+                    email = emails_list[0].get('email', '')
+
+                    if len(emails_list) > 1:
+                        for email_dict in emails_list:
+                            if email_dict.get('primary'):
+                                email = email_dict['email']
+                                break
+                    else:
+                        email = emails_list[0].get('email', '')
+
+        return {
+            'username': username,
+            'email': email,
+            'fullname': fullname,
+            'first_name': first_name,
+            'last_name': last_name
+        }
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        params = self.setting('PROFILE_EXTRA_PARAMS', {})
+        params['access_token'] = access_token
+
+        # Reference Docs: ORCID Auth Flow: https://github.com/ORCID/ORCID-Source/blob/master/orcid-web/ORCID_AUTH_WITH_OPENID_CONNECT.md#other-endpoints
+        # Sample headers: -H "Accept: application/json" -H "Authorization: Bearer <access_token>"
+        # This will respond with a json document like this:
+        # {
+        #     "sub":"0000-0002-2601-8132",
+        #     "name":"Credit Name",
+        #     "family_name":"Jones",
+        #     "given_name":"Tom"
+        # }
+        try:
+            response = self.get_json(
+                self.USER_ID_URL,
+                headers={
+                    'Content-Type': 'application/json',
+                    'Authorization': 'Bearer {}'.format(str(access_token))
+                },
+            )
+
+            # Update Jan 28 2021: Now we definitely have an ORCID id of format "0000-0000-0000-0000"
+            orcid = response['sub']
+        except Exception as ex:
+            pass
+
+        # We can now attempt to access the ORCID public API with the Orcid:
+        try:
+            return self.get_json(
+                    self.USER_DATA_URL.format(orcid),
+                    headers={
+                        'Content-Type': 'application/json'
+                    },
+                    params=params
+                )
+        except Exception as ex:
+            return None
+
+
+class ORCIDOAuth2Sandbox(ORCIDOAuth2):
+    """ORCID OAuth2 Sandbox authentication backend"""
+    name = 'orcid-sandbox'
+    AUTHORIZATION_URL = 'https://sandbox.orcid.org/oauth/authorize'
+    ACCESS_TOKEN_URL = 'https://sandbox.orcid.org/oauth/token'
+    USER_ID_URL = 'https://sandbox.orcid.org/oauth/userinfo'
+    USER_DATA_URL = 'https://pub.sandbox.orcid.org/v2.0/{}'
+
+
+class ORCIDMemberOAuth2(ORCIDOAuth2):
+    """ORCID OAuth2 authentication backend that uses ORCID Member API"""
+    USER_DATA_URL = 'https://api.orcid.org/v2.0/{}'
+    DEFAULT_SCOPE = ['/authenticate', '/read-limited']
+
+
+class ORCIDMemberOAuth2Sandbox(ORCIDOAuth2Sandbox):
+    """ORCID OAuth2 Sandbox authentication backend that uses ORCID Member Sandbox API"""
+    USER_DATA_URL = 'https://api.sandbox.orcid.org/v2.0/{}'
+    DEFAULT_SCOPE = ['/authenticate', '/read-limited']
diff --git a/rootfs/api/apps_extra/social_core/backends/osso.py b/rootfs/api/apps_extra/social_core/backends/osso.py
new file mode 100644
index 000000000..0fcbe8d79
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/osso.py
@@ -0,0 +1,50 @@
+from .oauth import BaseOAuth2
+from urllib.parse import urlencode
+
+class OssoOAuth2(BaseOAuth2):
+    """Osso OAuth authentication backend"""
+    name = 'osso'
+    REDIRECT_STATE = False
+    STATE_PARAMETER = True
+    AUTHORIZATION_URL = '{osso_base_url}/oauth/authorize'
+    ACCESS_TOKEN_URL = '{osso_base_url}/oauth/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+
+    @property
+    def osso_base_url(self):
+        return self.setting('OSSO_BASE_URL', 'https://demo.ossoapp.com')
+
+    def authorization_url(self):
+        return self.AUTHORIZATION_URL.format(osso_base_url=self.osso_base_url)
+
+    def access_token_url(self):
+        return self.ACCESS_TOKEN_URL.format(osso_base_url=self.osso_base_url)
+
+    def auth_params(self, state=None):
+        client_id, _client_secret = self.get_key_and_secret()
+        params = {
+            'client_id': client_id,
+            'redirect_uri': self.get_redirect_uri(state)
+        }
+        if self.data.get('email'):
+            params['email'] = self.data.get('email')
+        if self.data.get('domain') and not self.data.get('email'):
+            params['domain'] = self.data.get('domain')
+        if self.STATE_PARAMETER and state:
+            params['state'] = state
+        if self.RESPONSE_TYPE:
+            params['response_type'] = self.RESPONSE_TYPE
+        return params
+
+
+    def get_user_details(self, response):
+        """Return user details from Osso"""
+        return {'username': response.get('email'),
+                'email': response.get('email')}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads normalized user profile from Osso"""
+        url = '{osso_base_url}/oauth/me?'.format(osso_base_url=self.osso_base_url) + urlencode({
+            'access_token': access_token
+        })
+        return self.get_json(url)
\ No newline at end of file
diff --git a/rootfs/api/apps_extra/social_core/backends/patreon.py b/rootfs/api/apps_extra/social_core/backends/patreon.py
new file mode 100644
index 000000000..d0e0dc8d5
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/patreon.py
@@ -0,0 +1,41 @@
+"""
+Patreon OAuth2 backend
+https://www.patreon.com/platform/documentation/oauth
+"""
+from .oauth import BaseOAuth2
+
+
+class PatreonOAuth2(BaseOAuth2):
+    """Patreon OAuth2 authentication backend"""
+    name = 'patreon'
+    AUTHORIZATION_URL = 'https://www.patreon.com/oauth2/authorize'
+    ACCESS_TOKEN_URL = 'https://www.patreon.com/api/oauth2/token'
+    REVOKE_TOKEN_URL = 'https://www.patreon.com/oauth2/revoke'
+    ACCESS_TOKEN_METHOD = 'POST'
+    REDIRECT_STATE = False
+    ID_KEY = 'id'
+    EXTRA_DATA = [
+        ('id', 'id'),
+    ]
+
+    def get_user_details(self, response):
+        details = response['attributes']
+        return {
+            'username': details.get('full_name'),
+            'email': details.get('email'),
+            'fullname': details.get('full_name'),
+            'first_name': details.get('first_name'),
+            'last_name': details.get('last_name'),
+        }
+
+    def user_data(self, access_token, *args, **kwargs):
+        return self.get_api(access_token, 'identity')['data']
+
+    def get_api(self, access_token, suffix):
+        return self.get_json(
+            'https://www.patreon.com/api/oauth2/v2/{}'.format(suffix),
+            headers=self.get_auth_header(access_token)
+        )
+
+    def get_auth_header(self, access_token):
+        return {'Authorization': 'Bearer {0}'.format(access_token)}
diff --git a/rootfs/api/apps_extra/social_core/backends/paypal.py b/rootfs/api/apps_extra/social_core/backends/paypal.py
new file mode 100644
index 000000000..fe877b0cf
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/paypal.py
@@ -0,0 +1,75 @@
+import base64
+
+from .oauth import BaseOAuth2
+
+
+class PayPalOAuth2(BaseOAuth2):
+    """
+    PayPal OAuth2 backend, docs at:
+        https://developer.paypal.com/docs/connect-with-paypal/integrate/
+    """
+
+    name = 'paypal-oauth2'
+    ID_KEY = 'user_id'
+    AUTHORIZATION_URL = 'https://www.paypal.com/connect'
+    ACCESS_TOKEN_URL = 'https://api.paypal.com/v1/oauth2/token'
+    USER_DATA_URL = (
+        'https://api.paypal.com/v1/identity/oauth2/userinfo?schema=paypalv1.1'
+    )
+    DEFAULT_SCOPE = ['openid', 'profile']
+    ACCESS_TOKEN_METHOD = 'POST'
+    REFRESH_TOKEN_METHOD = 'POST'
+    REDIRECT_STATE = False
+
+    def user_data(self, access_token, *args, **kwargs):
+        auth_header = {'Authorization': 'Bearer %s' % access_token}
+        response = self.get_json(self.USER_DATA_URL, headers=auth_header)
+        return response
+
+    def get_user_details(self, response):
+        username = response.get(self.ID_KEY).split('/')[-1]
+        fullname, first_name, last_name = self.get_user_names(
+            response.get('name', ''),
+            response.get('given_name', ''),
+            response.get('family_name', ''),
+        )
+        emails = response.get('emails', [])
+        email = self.get_email(emails)
+        return {
+            'username': username,
+            'email': email,
+            'fullname': fullname,
+            'first_name': first_name,
+            'last_name': last_name,
+        }
+
+    def auth_complete_params(self, state=None):
+        return {
+            'grant_type': 'authorization_code',
+            'code': self.data.get('code', ''),
+        }
+
+    def auth_headers(self):
+        auth = ('%s:%s' % self.get_key_and_secret()).encode()
+        return {'Authorization': b'Basic ' + base64.urlsafe_b64encode(auth)}
+
+    def refresh_token_params(self, token, *args, **kwargs):
+        return {'refresh_token': token, 'grant_type': 'refresh_token'}
+
+    @staticmethod
+    def get_email(emails):
+        if not emails:
+            return ''
+        primary_emails = (email for email in emails
+                          if email.get('primary', False))
+        primary_or_first = next(primary_emails, emails[0])
+        return primary_or_first.get('value')
+
+
+class PayPalOAuth2Sandbox(PayPalOAuth2):
+    name = 'paypal-oauth2-sandbox'
+    AUTHORIZATION_URL = 'https://www.sandbox.paypal.com/connect'
+    ACCESS_TOKEN_URL = 'https://api.sandbox.paypal.com/v1/oauth2/token'
+    USER_DATA_URL = (
+        'https://api.sandbox.paypal.com/v1/identity/oauth2/userinfo?schema=paypalv1.1'
+    )
diff --git a/rootfs/api/apps_extra/social_core/backends/persona.py b/rootfs/api/apps_extra/social_core/backends/persona.py
new file mode 100644
index 000000000..8b2c4209a
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/persona.py
@@ -0,0 +1,50 @@
+"""
+Mozilla Persona authentication backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/persona.html
+"""
+from ..utils import handle_http_errors
+from .base import BaseAuth
+from ..exceptions import AuthFailed, AuthMissingParameter
+
+
+class PersonaAuth(BaseAuth):
+    """BrowserID authentication backend"""
+    name = 'persona'
+
+    def get_user_id(self, details, response):
+        """Use BrowserID email as ID"""
+        return details['email']
+
+    def get_user_details(self, response):
+        """Return user details, BrowserID only provides Email."""
+        # {'status': 'okay',
+        #  'audience': 'localhost:8000',
+        #  'expires': 1328983575529,
+        #  'email': 'name@server.com',
+        #  'issuer': 'browserid.org'}
+        email = response['email']
+        return {'username': email.split('@', 1)[0],
+                'email': email,
+                'fullname': '',
+                'first_name': '',
+                'last_name': ''}
+
+    def extra_data(self, user, uid, response, details=None, *args, **kwargs):
+        """Return users extra data"""
+        return {'audience': response['audience'],
+                'issuer': response['issuer']}
+
+    @handle_http_errors
+    def auth_complete(self, *args, **kwargs):
+        """Completes login process, must return user instance"""
+        if 'assertion' not in self.data:
+            raise AuthMissingParameter(self, 'assertion')
+
+        response = self.get_json('https://browserid.org/verify', data={
+            'assertion': self.data['assertion'],
+            'audience': self.strategy.request_host()
+        }, method='POST')
+        if response.get('status') == 'failure':
+            raise AuthFailed(self)
+        kwargs.update({'response': response, 'backend': self})
+        return self.strategy.authenticate(*args, **kwargs)
diff --git a/rootfs/api/apps_extra/social_core/backends/phabricator.py b/rootfs/api/apps_extra/social_core/backends/phabricator.py
new file mode 100644
index 000000000..1bd4ddc39
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/phabricator.py
@@ -0,0 +1,46 @@
+"""
+Phabricator OAuth2 backend, docs at:
+    https://secure.phabricator.com/book/phabcontrib/article/using_oauthserver/
+"""
+from .oauth import BaseOAuth2
+
+
+class PhabricatorOAuth2(BaseOAuth2):
+    """Phabricator OAuth authentication backend"""
+    name = 'phabricator'
+    API_URL = 'https://secure.phabricator.com'
+    AUTHORIZATION_URL = 'https://secure.phabricator.com/oauthserver/auth/'
+    ACCESS_TOKEN_URL = 'https://secure.phabricator.com/oauthserver/token/'
+    ACCESS_TOKEN_METHOD = 'POST'
+    REDIRECT_STATE = False
+
+    def api_url(self, path):
+        api_url = self.setting('API_URL') or self.API_URL
+        return '{0}{1}'.format(api_url.rstrip('/'), path)
+
+    def authorization_url(self):
+        return self.api_url('/oauthserver/auth/')
+
+    def access_token_url(self):
+        return self.api_url('/oauthserver/token/')
+
+    def get_user_details(self, response):
+        """Return user details from Phabricator"""
+        fullname, first_name, last_name = self.get_user_names(
+            response.get('realName')
+        )
+
+        return {
+            'id': response.get('phid'),
+            'username': response.get('userName'),
+            'email': response.get('primaryEmail', ''),
+            'fullname': fullname,
+            'first_name': first_name,
+            'last_name': last_name,
+        }
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from API"""
+        return self.get_json(self.api_url('/api/user.whoami'), params={
+            'access_token': access_token,
+        })
diff --git a/rootfs/api/apps_extra/social_core/backends/pinterest.py b/rootfs/api/apps_extra/social_core/backends/pinterest.py
new file mode 100644
index 000000000..eb5d2ce34
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/pinterest.py
@@ -0,0 +1,46 @@
+# -*- coding: utf-8 -*-
+"""
+Pinterest OAuth2 backend, docs at:
+    https://developers.pinterest.com/docs/api/authentication/
+"""
+
+from __future__ import unicode_literals
+
+import ssl
+
+from .oauth import BaseOAuth2
+
+
+class PinterestOAuth2(BaseOAuth2):
+    name = 'pinterest'
+    ID_KEY = 'user_id'
+    AUTHORIZATION_URL = 'https://api.pinterest.com/oauth/'
+    ACCESS_TOKEN_URL = 'https://api.pinterest.com/v1/oauth/token'
+    REDIRECT_STATE = False
+    ACCESS_TOKEN_METHOD = 'POST'
+    SSL_PROTOCOL = ssl.PROTOCOL_TLSv1
+
+    def user_data(self, access_token, *args, **kwargs):
+        response = self.get_json('https://api.pinterest.com/v1/me/',
+                                 params={'access_token': access_token})
+
+        if 'data' in response:
+            username = response['data']['url'].strip('/').split('/')[-1]
+            response = {
+                'user_id': response['data']['id'],
+                'first_name': response['data']['first_name'],
+                'last_name': response['data']['last_name'],
+                'username': username,
+            }
+        return response
+
+    def get_user_details(self, response):
+        fullname, first_name, last_name = self.get_user_names(
+            first_name=response['first_name'],
+            last_name=response['last_name'])
+
+        return {'username': response.get('username'),
+                'email': None,
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
diff --git a/rootfs/api/apps_extra/social_core/backends/pixelpin.py b/rootfs/api/apps_extra/social_core/backends/pixelpin.py
new file mode 100644
index 000000000..3048134b1
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/pixelpin.py
@@ -0,0 +1,35 @@
+from .open_id_connect import OpenIdConnectAuth
+
+
+class PixelPinOpenIDConnect(OpenIdConnectAuth):
+    """PixelPin OpenID Connect authentication backend"""
+    name = 'pixelpin-openidconnect'
+    ID_KEY = 'sub'
+    AUTHORIZATION_URL = 'https://login.pixelpin.io/connect/authorize'
+    ACCESS_TOKEN_URL = 'https://login.pixelpin.io/connect/token'
+    OIDC_ENDPOINT = 'https://login.pixelpin.io'
+    JWKS_URI = 'https://login.pixelpin.io/.well-known/jwks'
+    ACCESS_TOKEN_METHOD = 'POST'
+    REQUIRES_EMAIL_VALIDATION = False
+
+    def get_user_details(self, response):
+        """Return user details from PixelPin account"""
+        first_name = response.get('given_name')
+        last_name = response.get('family_name')
+        sub = response.get('sub')
+
+        username = first_name + last_name + sub
+
+        return {'username': username,
+                'email': response.get('email'),
+                'fullname': first_name + ' ' + last_name,
+                'first_name': first_name,
+                'last_name': last_name}
+
+    def user_data(self, access_token, *args, **kwargs):
+        return self.get_json(
+            'https://login.pixelpin.io/connect/userinfo',
+            headers={
+                'Authorization': 'Bearer {0}'.format(access_token)
+            }
+        )
diff --git a/rootfs/api/apps_extra/social_core/backends/pocket.py b/rootfs/api/apps_extra/social_core/backends/pocket.py
new file mode 100644
index 000000000..ad2448751
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/pocket.py
@@ -0,0 +1,45 @@
+"""
+Pocket OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/pocket.html
+"""
+from .base import BaseAuth
+from ..utils import handle_http_errors
+
+
+class PocketAuth(BaseAuth):
+    name = 'pocket'
+    AUTHORIZATION_URL = 'https://getpocket.com/auth/authorize'
+    ACCESS_TOKEN_URL = 'https://getpocket.com/v3/oauth/authorize'
+    REQUEST_TOKEN_URL = 'https://getpocket.com/v3/oauth/request'
+    ID_KEY = 'username'
+
+    def get_json(self, url, *args, **kwargs):
+        headers = {'X-Accept': 'application/json'}
+        kwargs.update({'method': 'POST', 'headers': headers})
+        return super().get_json(url, *args, **kwargs)
+
+    def get_user_details(self, response):
+        return {'username': response['username']}
+
+    def extra_data(self, user, uid, response, details=None, *args, **kwargs):
+        return response
+
+    def auth_url(self):
+        data = {
+            'consumer_key': self.setting('KEY'),
+            'redirect_uri': self.redirect_uri,
+        }
+        token = self.get_json(self.REQUEST_TOKEN_URL, data=data)['code']
+        self.strategy.session_set('pocket_request_token', token)
+        bits = (self.AUTHORIZATION_URL, token, self.redirect_uri)
+        return '%s?request_token=%s&redirect_uri=%s' % bits
+
+    @handle_http_errors
+    def auth_complete(self, *args, **kwargs):
+        data = {
+            'consumer_key': self.setting('KEY'),
+            'code': self.strategy.session_get('pocket_request_token'),
+        }
+        response = self.get_json(self.ACCESS_TOKEN_URL, data=data)
+        kwargs.update({'response': response, 'backend': self})
+        return self.strategy.authenticate(*args, **kwargs)
diff --git a/rootfs/api/apps_extra/social_core/backends/podio.py b/rootfs/api/apps_extra/social_core/backends/podio.py
new file mode 100644
index 000000000..0214f61b9
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/podio.py
@@ -0,0 +1,39 @@
+"""
+Podio OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/podio.html
+"""
+from .oauth import BaseOAuth2
+
+
+class PodioOAuth2(BaseOAuth2):
+    """Podio OAuth authentication backend"""
+    name = 'podio'
+    AUTHORIZATION_URL = 'https://podio.com/oauth/authorize'
+    ACCESS_TOKEN_URL = 'https://podio.com/oauth/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    EXTRA_DATA = [
+        ('access_token', 'access_token'),
+        ('token_type', 'token_type'),
+        ('expires_in', 'expires'),
+        ('refresh_token', 'refresh_token'),
+    ]
+
+    def get_user_id(self, details, response):
+        return response['ref']['id']
+
+    def get_user_details(self, response):
+        fullname, first_name, last_name = self.get_user_names(
+            response['profile']['name']
+        )
+        return {
+            'username': 'user_%d' % response['user']['user_id'],
+            'email': response['user']['mail'],
+            'fullname': fullname,
+            'first_name': first_name,
+            'last_name': last_name,
+        }
+
+    def user_data(self, access_token, *args, **kwargs):
+        return self.get_json('https://api.podio.com/user/status', headers={
+            'Authorization': 'OAuth2 ' + access_token
+        })
diff --git a/rootfs/api/apps_extra/social_core/backends/professionali.py b/rootfs/api/apps_extra/social_core/backends/professionali.py
new file mode 100644
index 000000000..15f397760
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/professionali.py
@@ -0,0 +1,55 @@
+# -*- coding: utf-8 -*-
+"""
+Professionaly OAuth 2.0 support.
+
+This contribution adds support for professionaly.ru OAuth 2.0.
+Username is retrieved from the identity returned by server.
+"""
+from time import time
+
+from ..utils import parse_qs
+from .oauth import BaseOAuth2
+
+
+class ProfessionaliOAuth2(BaseOAuth2):
+    name = 'professionali'
+    ID_KEY = 'user_id'
+    AUTHORIZATION_URL = 'https://api.professionali.ru/oauth/authorize.html'
+    ACCESS_TOKEN_URL = 'https://api.professionali.ru/oauth/getToken.json'
+    ACCESS_TOKEN_METHOD = 'POST'
+    EXTRA_DATA = [
+        ('avatar_big', 'avatar_big'),
+        ('link', 'link')
+    ]
+
+    def get_user_details(self, response):
+        first_name, last_name = map(response.get, ('firstname', 'lastname'))
+        email = ''
+        if self.setting('FAKE_EMAIL'):
+            email = '{0}@professionali.ru'.format(time())
+        return {
+            'username': '{0}_{1}'.format(last_name, first_name),
+            'first_name': first_name,
+            'last_name': last_name,
+            'email': email
+        }
+
+    def user_data(self, access_token, response, *args, **kwargs):
+        url = 'https://api.professionali.ru/v6/users/get.json'
+        fields = list(set(['firstname', 'lastname', 'avatar_big', 'link'] +
+                          self.setting('EXTRA_DATA', [])))
+        params = {
+            'fields': ','.join(fields),
+            'access_token': access_token,
+            'ids[]': response['user_id']
+        }
+        try:
+            return self.get_json(url, params)[0]
+        except (TypeError, KeyError, IOError, ValueError, IndexError):
+            return None
+
+    def get_json(self, url, *args, **kwargs):
+        return self.request(url, verify=False, *args, **kwargs).json()
+
+    def get_querystring(self, url, *args, **kwargs):
+        return parse_qs(self.request(url, verify=False, *args, **kwargs).text)
diff --git a/rootfs/api/apps_extra/social_core/backends/pushbullet.py b/rootfs/api/apps_extra/social_core/backends/pushbullet.py
new file mode 100644
index 000000000..d821223f2
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/pushbullet.py
@@ -0,0 +1,23 @@
+import base64
+
+from .oauth import BaseOAuth2
+
+
+class PushbulletOAuth2(BaseOAuth2):
+    """pushbullet OAuth authentication backend"""
+    name = 'pushbullet'
+    EXTRA_DATA = [('id', 'id')]
+    ID_KEY = 'username'
+    AUTHORIZATION_URL = 'https://www.pushbullet.com/authorize'
+    REQUEST_TOKEN_URL = 'https://api.pushbullet.com/oauth2/token'
+    ACCESS_TOKEN_URL = 'https://api.pushbullet.com/oauth2/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    STATE_PARAMETER = False
+
+    def get_user_details(self, response):
+        return {'username': response.get('access_token')}
+
+    def get_user_id(self, details, response):
+        auth = 'Basic {0}'.format(base64.b64encode(details['username']))
+        return self.get_json('https://api.pushbullet.com/v2/users/me',
+                             headers={'Authorization': auth})['iden']
diff --git a/rootfs/api/apps_extra/social_core/backends/qiita.py b/rootfs/api/apps_extra/social_core/backends/qiita.py
new file mode 100644
index 000000000..87fb6a409
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/qiita.py
@@ -0,0 +1,67 @@
+"""
+Qiita OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/qiita.html
+    http://qiita.com/api/v2/docs#get-apiv2oauthauthorize
+"""
+import json
+
+from .oauth import BaseOAuth2
+
+
+class QiitaOAuth2(BaseOAuth2):
+    """Qiita OAuth authentication backend"""
+    name = 'qiita'
+
+    AUTHORIZATION_URL = 'https://qiita.com/api/v2/oauth/authorize'
+    ACCESS_TOKEN_URL = 'https://qiita.com/api/v2/access_tokens'
+    ACCESS_TOKEN_METHOD = 'POST'
+    SCOPE_SEPARATOR = ' '
+    REDIRECT_STATE = True
+    EXTRA_DATA = [
+        ('description', 'description'),
+        ('facebook_id', 'facebook_id'),
+        ('followees_count', 'followees_count'),
+        ('followers_count', 'followers_count'),
+        ('github_login_name', 'github_login_name'),
+        ('id', 'id'),
+        ('items_count', 'items_count'),
+        ('linkedin_id', 'linkedin_id'),
+        ('location', 'location'),
+        ('name', 'name'),
+        ('organization', 'organization'),
+        ('profile_image_url', 'profile_image_url'),
+        ('twitter_screen_name', 'twitter_screen_name'),
+        ('website_url', 'website_url'),
+    ]
+
+    def auth_complete_params(self, state=None):
+        data = super().auth_complete_params(state)
+        if 'grant_type' in data:
+            del data['grant_type']
+        if 'redirect_uri' in data:
+            del data['redirect_uri']
+        return json.dumps(data)
+
+    def auth_headers(self):
+        return {'Content-Type': 'application/json'}
+
+    def request_access_token(self, *args, **kwargs):
+        data = super().request_access_token(*args, **kwargs)
+        data.update({'access_token': data['token']})
+        return data
+
+    def get_user_details(self, response):
+        """Return user details from Qiita account"""
+        return {
+            'username': response['id'],
+            'fullname': response['name'],
+        }
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        return self.get_json(
+            'https://qiita.com/api/v2/authenticated_user',
+            headers={
+                'Authorization': 'Bearer {0}'.format(access_token)
+            }
+        )
diff --git a/rootfs/api/apps_extra/social_core/backends/qq.py b/rootfs/api/apps_extra/social_core/backends/qq.py
new file mode 100644
index 000000000..b740b27ae
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/qq.py
@@ -0,0 +1,71 @@
+"""
+Created on May 13, 2014
+
+@author: Yong Zhang (zyfyfe@gmail.com)
+"""
+
+import json
+
+from ..utils import parse_qs
+from .oauth import BaseOAuth2
+
+
+class QQOAuth2(BaseOAuth2):
+    name = 'qq'
+    ID_KEY = 'openid'
+    AUTHORIZE_URL = 'https://graph.qq.com/oauth2.0/authorize'
+    ACCESS_TOKEN_URL = 'https://graph.qq.com/oauth2.0/token'
+    AUTHORIZATION_URL = 'https://graph.qq.com/oauth2.0/authorize'
+    OPENID_URL = 'https://graph.qq.com/oauth2.0/me'
+    REDIRECT_STATE = False
+    EXTRA_DATA = [
+        ('nickname', 'username'),
+        ('figureurl_qq_1', 'profile_image_url'),
+        ('gender', 'gender')
+    ]
+
+    def get_user_details(self, response):
+        """
+        Return user detail from QQ account sometimes nickname will duplicate
+        with another qq account, to avoid this issue it's possible to use
+        openid as username.
+        """
+        if self.setting('USE_OPENID_AS_USERNAME', False):
+            username = response.get('openid', '')
+        else:
+            username = response.get('nickname', '')
+
+        fullname, first_name, last_name = self.get_user_names(
+            first_name=response.get('nickname', '')
+        )
+
+        return {
+            'username': username,
+            'fullname': fullname,
+            'first_name': first_name,
+            'last_name': last_name
+        }
+
+    def get_openid(self, access_token):
+        response = self.request(self.OPENID_URL, params={
+            'access_token': access_token
+        })
+        content = response.content.decode()
+        data = json.loads(content[10:-3])
+        return data['openid']
+
+    def user_data(self, access_token, *args, **kwargs):
+        openid = self.get_openid(access_token)
+        response = self.get_json(
+            'https://graph.qq.com/user/get_user_info', params={
+                'access_token': access_token,
+                'oauth_consumer_key': self.setting('KEY'),
+                'openid': openid
+            }
+        )
+        response['openid'] = openid
+        return response
+
+    def request_access_token(self, url, data, *args, **kwargs):
+        response = self.request(url, *args, **kwargs)
+        return parse_qs(response.content)
diff --git a/rootfs/api/apps_extra/social_core/backends/quizlet.py b/rootfs/api/apps_extra/social_core/backends/quizlet.py
new file mode 100644
index 000000000..54d6cba78
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/quizlet.py
@@ -0,0 +1,23 @@
+"""
+Quizlet OAuth2 Sign-in backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/quizlet.html
+"""
+from .oauth import BaseOAuth2
+
+
+class QuizletOAuth2(BaseOAuth2):
+    """Quizlet OAuth2"""
+    name = 'quizlet'
+    ID_KEY = 'user_id'
+    API_URL = 'https://api.quizlet.com/2.0/'
+    AUTHORIZATION_URL = 'https://quizlet.com/authorize'
+    ACCESS_TOKEN_URL = 'https://api.quizlet.com/oauth/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    SCOPE_SEPARATOR = ' '
+    DEFAULT_SCOPE = ['read']
+
+    def get_user_details(self, response):
+        """Return user details from Quizlet account"""
+        return {
+            'username': response.get('user_id')
+        }
diff --git a/rootfs/api/apps_extra/social_core/backends/rdio.py b/rootfs/api/apps_extra/social_core/backends/rdio.py
new file mode 100644
index 000000000..60e9f606a
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/rdio.py
@@ -0,0 +1,72 @@
+"""
+Rdio OAuth1 and OAuth2 backends, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/rdio.html
+"""
+from .oauth import BaseOAuth1, BaseOAuth2, OAuthAuth
+
+
+RDIO_API = 'https://www.rdio.com/api/1/'
+
+
+class BaseRdio(OAuthAuth):
+    ID_KEY = 'key'
+
+    def get_user_details(self, response):
+        fullname, first_name, last_name = self.get_user_names(
+            fullname=response['displayName'],
+            first_name=response['firstName'],
+            last_name=response['lastName']
+        )
+        return {
+            'username': response['username'],
+            'fullname': fullname,
+            'first_name': first_name,
+            'last_name': last_name
+        }
+
+
+class RdioOAuth1(BaseRdio, BaseOAuth1):
+    """Rdio OAuth authentication backend"""
+    name = 'rdio-oauth1'
+    REQUEST_TOKEN_URL = 'http://api.rdio.com/oauth/request_token'
+    AUTHORIZATION_URL = 'https://www.rdio.com/oauth/authorize'
+    ACCESS_TOKEN_URL = 'http://api.rdio.com/oauth/access_token'
+    EXTRA_DATA = [
+        ('key', 'rdio_id'),
+        ('icon', 'rdio_icon_url'),
+        ('url', 'rdio_profile_url'),
+        ('username', 'rdio_username'),
+        ('streamRegion', 'rdio_stream_region'),
+    ]
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Return user data provided"""
+        params = {'method': 'currentUser',
+                  'extras': 'username,displayName,streamRegion'}
+        request = self.oauth_request(access_token, RDIO_API,
+                                     params, method='POST')
+        return self.get_json(request.url, method='POST',
+                             data=request.to_postdata())['result']
+
+
+class RdioOAuth2(BaseRdio, BaseOAuth2):
+    name = 'rdio-oauth2'
+    AUTHORIZATION_URL = 'https://www.rdio.com/oauth2/authorize'
+    ACCESS_TOKEN_URL = 'https://www.rdio.com/oauth2/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    EXTRA_DATA = [
+        ('key', 'rdio_id'),
+        ('icon', 'rdio_icon_url'),
+        ('url', 'rdio_profile_url'),
+        ('username', 'rdio_username'),
+        ('streamRegion', 'rdio_stream_region'),
+        ('refresh_token', 'refresh_token', True),
+        ('token_type', 'token_type', True),
+    ]
+
+    def user_data(self, access_token, *args, **kwargs):
+        return self.get_json(RDIO_API, method='POST', data={
+            'method': 'currentUser',
+            'extras': 'username,displayName,streamRegion',
+            'access_token': access_token
+        })['result']
diff --git a/rootfs/api/apps_extra/social_core/backends/readability.py b/rootfs/api/apps_extra/social_core/backends/readability.py
new file mode 100644
index 000000000..593117fce
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/readability.py
@@ -0,0 +1,35 @@
+"""
+Readability OAuth1 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/readability.html
+"""
+from .oauth import BaseOAuth1
+
+
+READABILITY_API = 'https://www.readability.com/api/rest/v1'
+
+
+class ReadabilityOAuth(BaseOAuth1):
+    """Readability OAuth authentication backend"""
+    name = 'readability'
+    ID_KEY = 'username'
+    AUTHORIZATION_URL = '{0}/oauth/authorize/'.format(READABILITY_API)
+    REQUEST_TOKEN_URL = '{0}/oauth/request_token/'.format(READABILITY_API)
+    ACCESS_TOKEN_URL = '{0}/oauth/access_token/'.format(READABILITY_API)
+    EXTRA_DATA = [('date_joined', 'date_joined'),
+                  ('kindle_email_address', 'kindle_email_address'),
+                  ('avatar_url', 'avatar_url'),
+                  ('email_into_address', 'email_into_address')]
+
+    def get_user_details(self, response):
+        fullname, first_name, last_name = self.get_user_names(
+            first_name=response['first_name'],
+            last_name=response['last_name']
+        )
+        return {'username': response['username'],
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
+
+    def user_data(self, access_token):
+        return self.get_json(READABILITY_API + '/users/_current',
+                             auth=self.oauth_auth(access_token))
diff --git a/rootfs/api/apps_extra/social_core/backends/reddit.py b/rootfs/api/apps_extra/social_core/backends/reddit.py
new file mode 100644
index 000000000..97fa76b61
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/reddit.py
@@ -0,0 +1,56 @@
+"""
+Reddit OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/reddit.html
+"""
+import base64
+
+from .oauth import BaseOAuth2
+
+
+class RedditOAuth2(BaseOAuth2):
+    """Reddit OAuth2 authentication backend"""
+    name = 'reddit'
+    AUTHORIZATION_URL = 'https://ssl.reddit.com/api/v1/authorize'
+    ACCESS_TOKEN_URL = 'https://ssl.reddit.com/api/v1/access_token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    REFRESH_TOKEN_METHOD = 'POST'
+    REDIRECT_STATE = False
+    SCOPE_SEPARATOR = ','
+    DEFAULT_SCOPE = ['identity']
+    SEND_USER_AGENT = True
+    EXTRA_DATA = [
+        ('id', 'id'),
+        ('name', 'username'),
+        ('link_karma', 'link_karma'),
+        ('comment_karma', 'comment_karma'),
+        ('refresh_token', 'refresh_token'),
+        ('expires_in', 'expires')
+    ]
+
+    def get_user_details(self, response):
+        """Return user details from Reddit account"""
+        return {'username': response.get('name'),
+                'email': '', 'fullname': '',
+                'first_name': '', 'last_name': ''}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        return self.get_json(
+            'https://oauth.reddit.com/api/v1/me.json',
+            headers={'Authorization': 'bearer ' + access_token}
+        )
+
+    def auth_headers(self):
+        return {
+            'Authorization': b'Basic ' + base64.urlsafe_b64encode(
+                '{0}:{1}'.format(*self.get_key_and_secret()).encode()
+            )
+        }
+
+    def refresh_token_params(self, token, redirect_uri=None, *args, **kwargs):
+        params = super().refresh_token_params(token)
+        params['redirect_uri'] = self.redirect_uri or redirect_uri
+        return params
+
+    def auth_complete_credentials(self):
+        return self.get_key_and_secret()
diff --git a/rootfs/api/apps_extra/social_core/backends/runkeeper.py b/rootfs/api/apps_extra/social_core/backends/runkeeper.py
new file mode 100644
index 000000000..18c2eec88
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/runkeeper.py
@@ -0,0 +1,47 @@
+"""
+RunKeeper OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/runkeeper.html
+"""
+from .oauth import BaseOAuth2
+
+
+class RunKeeperOAuth2(BaseOAuth2):
+    """RunKeeper OAuth authentication backend"""
+    name = 'runkeeper'
+    AUTHORIZATION_URL = 'https://runkeeper.com/apps/authorize'
+    ACCESS_TOKEN_URL = 'https://runkeeper.com/apps/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    EXTRA_DATA = [
+        ('userID', 'id'),
+    ]
+
+    def get_user_id(self, details, response):
+        return response['userID']
+
+    def get_user_details(self, response):
+        """Parse username from profile link"""
+        username = None
+        profile_url = response.get('profile')
+        if len(profile_url):
+            profile_url_parts = profile_url.split('http://runkeeper.com/user/')
+            if len(profile_url_parts) > 1 and len(profile_url_parts[1]):
+                username = profile_url_parts[1]
+        fullname, first_name, last_name = self.get_user_names(
+            fullname=response.get('name')
+        )
+        return {'username': username,
+                'email': response.get('email') or '',
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
+
+    def user_data(self, access_token, *args, **kwargs):
+        # We need to use the /user endpoint to get the user id, the /profile
+        # endpoint contains name, user name, location, gender
+        user_data = self._user_data(access_token, '/user')
+        profile_data = self._user_data(access_token, '/profile')
+        return dict(user_data, **profile_data)
+
+    def _user_data(self, access_token, path):
+        url = 'https://api.runkeeper.com{0}'.format(path)
+        return self.get_json(url, params={'access_token': access_token})
diff --git a/rootfs/api/apps_extra/social_core/backends/salesforce.py b/rootfs/api/apps_extra/social_core/backends/salesforce.py
new file mode 100644
index 000000000..10952c600
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/salesforce.py
@@ -0,0 +1,49 @@
+from urllib.parse import urlencode
+
+from .oauth import BaseOAuth2
+
+
+class SalesforceOAuth2(BaseOAuth2):
+    """Salesforce OAuth2 authentication backend"""
+    name = 'salesforce-oauth2'
+    AUTHORIZATION_URL = \
+        'https://login.salesforce.com/services/oauth2/authorize'
+    ACCESS_TOKEN_URL = 'https://login.salesforce.com/services/oauth2/token'
+    REVOKE_TOKEN_URL = 'https://login.salesforce.com/services/oauth2/revoke'
+    ACCESS_TOKEN_METHOD = 'POST'
+    REFRESH_TOKEN_METHOD = 'POST'
+    SCOPE_SEPARATOR = ' '
+    EXTRA_DATA = [
+        ('id', 'id'),
+        ('instance_url', 'instance_url'),
+        ('issued_at', 'issued_at'),
+        ('signature', 'signature'),
+        ('refresh_token', 'refresh_token'),
+    ]
+
+    def get_user_details(self, response):
+        """Return user details from a Salesforce account"""
+        return {
+            'username': response.get('username'),
+            'email': response.get('email') or '',
+            'first_name': response.get('first_name'),
+            'last_name': response.get('last_name'),
+            'fullname': response.get('display_name')
+        }
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        user_id_url = kwargs.get('response').get('id')
+        url = user_id_url + '?' + urlencode({'access_token': access_token})
+        try:
+            return self.get_json(url)
+        except ValueError:
+            return None
+
+
+class SalesforceOAuth2Sandbox(SalesforceOAuth2):
+    """Salesforce OAuth2 authentication testing backend"""
+    name = 'salesforce-oauth2-sandbox'
+    AUTHORIZATION_URL = 'https://test.salesforce.com/services/oauth2/authorize'
+    ACCESS_TOKEN_URL = 'https://test.salesforce.com/services/oauth2/token'
+    REVOKE_TOKEN_URL = 'https://test.salesforce.com/services/oauth2/revoke'
diff --git a/rootfs/api/apps_extra/social_core/backends/saml.py b/rootfs/api/apps_extra/social_core/backends/saml.py
new file mode 100644
index 000000000..c29024756
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/saml.py
@@ -0,0 +1,370 @@
+"""
+Backend for SAML 2.0 support
+
+Terminology:
+
+"Service Provider" (SP): Your web app
+"Identity Provider" (IdP): The third-party site that is authenticating
+                           users via SAML
+"""
+from onelogin.saml2.auth import OneLogin_Saml2_Auth
+from onelogin.saml2.settings import OneLogin_Saml2_Settings
+
+from .base import BaseAuth
+from ..exceptions import AuthFailed, AuthMissingParameter
+
+# Helpful constants:
+OID_COMMON_NAME = 'urn:oid:2.5.4.3'
+OID_EDU_PERSON_PRINCIPAL_NAME = 'urn:oid:1.3.6.1.4.1.5923.1.1.1.6'
+OID_EDU_PERSON_ENTITLEMENT = 'urn:oid:1.3.6.1.4.1.5923.1.1.1.7'
+OID_GIVEN_NAME = 'urn:oid:2.5.4.42'
+OID_MAIL = 'urn:oid:0.9.2342.19200300.100.1.3'
+OID_SURNAME = 'urn:oid:2.5.4.4'
+OID_USERID = 'urn:oid:0.9.2342.19200300.100.1.1'
+
+
+class SAMLIdentityProvider:
+    """Wrapper around configuration for a SAML Identity provider"""
+    def __init__(self, name, **kwargs):
+        """Load and parse configuration"""
+        self.name = name
+        # name should be a slug and must not contain a colon, which
+        # could conflict with uid prefixing:
+        assert ':' not in self.name and ' ' not in self.name, \
+            'IdP "name" should be a slug (short, no spaces)'
+        self.conf = kwargs
+
+    def get_user_permanent_id(self, attributes):
+        """
+        The most important method: Get a permanent, unique identifier
+        for this user from the attributes supplied by the IdP.
+
+        If you want to use the NameID, it's available via
+        attributes['name_id']
+        """
+        uid = attributes[self.conf.get('attr_user_permanent_id', OID_USERID)]
+        if isinstance(uid, list):
+            uid = uid[0]
+        return uid
+
+    # Attributes processing:
+    def get_user_details(self, attributes):
+        """
+        Given the SAML attributes extracted from the SSO response, get
+        the user data like name.
+        """
+        return {
+            'fullname': self.get_attr(attributes, 'attr_full_name',
+                                      OID_COMMON_NAME),
+            'first_name': self.get_attr(attributes, 'attr_first_name',
+                                        OID_GIVEN_NAME),
+            'last_name': self.get_attr(attributes, 'attr_last_name',
+                                       OID_SURNAME),
+            'username': self.get_attr(attributes, 'attr_username',
+                                      OID_USERID),
+            'email': self.get_attr(attributes, 'attr_email',
+                                   OID_MAIL),
+        }
+
+    def get_attr(self, attributes, conf_key, default_attribute):
+        """
+        Internal helper method.
+        Get the attribute 'default_attribute' out of the attributes,
+        unless self.conf[conf_key] overrides the default by specifying
+        another attribute to use.
+        """
+        key = self.conf.get(conf_key, default_attribute)
+        value = attributes[key] if key in attributes else None
+        if isinstance(value, list):
+            if len(value):
+                value = value[0]
+            else:
+                value = None
+        return value
+
+    @property
+    def entity_id(self):
+        """Get the entity ID for this IdP"""
+        # Required. e.g. "https://idp.testshib.org/idp/shibboleth"
+        return self.conf['entity_id']
+
+    @property
+    def sso_url(self):
+        """Get the SSO URL for this IdP"""
+        # Required. e.g.
+        # "https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO"
+        return self.conf['url']
+
+    @property
+    def slo_url(self):
+        """Get the SLO URL for this IdP"""
+        return self.conf.get('slo_url')
+
+    @property
+    def saml_config_dict(self):
+        """Get the IdP configuration dict in the format required by
+        python-saml"""
+        result = {
+            'entityId': self.entity_id,
+            'singleSignOnService': {
+                'url': self.sso_url,
+                # python-saml only supports Redirect
+                'binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'
+            },
+        }
+
+        if self.slo_url:
+            result['singleLogoutService'] = {
+                'url': self.slo_url,
+                'binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'
+            }
+
+        cert = self.conf.get('x509cert', None)
+        if cert:
+            result['x509cert'] = cert
+            return result
+        cert = self.conf.get('x509certMulti', None)
+        if cert:
+            result['x509certMulti'] = cert
+            return result
+        raise KeyError('IDP must contain x509cert or x509certMulti')
+
+
+class DummySAMLIdentityProvider(SAMLIdentityProvider):
+    """
+    A placeholder IdP used when we must specify something, e.g. when
+    generating SP metadata.
+
+    If OneLogin_Saml2_Auth is modified to not always require IdP
+    config, this can be removed.
+    """
+    def __init__(self):
+        super().__init__(
+            'dummy',
+            entity_id='https://dummy.none/saml2',
+            url='https://dummy.none/SSO',
+            x509cert=''
+        )
+
+
+class SAMLAuth(BaseAuth):
+    """
+    PSA Backend that implements SAML 2.0 Service Provider (SP) functionality.
+
+    Unlike all of the other backends, this one can be configured to work with
+    many identity providers (IdPs). For example, a University that belongs to a
+    Shibboleth federation may support authentication via ~100 partner
+    universities. Also, the IdP configuration can be changed at runtime if you
+    require that functionality - just subclass this and override `get_idp()`.
+
+    Several settings are required. Here's an example:
+
+    SOCIAL_AUTH_SAML_SP_ENTITY_ID = "https://saml.example.com/"
+    SOCIAL_AUTH_SAML_SP_PUBLIC_CERT = "... X.509 certificate string ..."
+    SOCIAL_AUTH_SAML_SP_PRIVATE_KEY = "... private key ..."
+    SOCIAL_AUTH_SAML_ORG_INFO = {
+        "en-US": {
+            "name": "example",
+            "displayname": "Example Inc.",
+            "url": "http://example.com"
+        }
+    }
+    SOCIAL_AUTH_SAML_TECHNICAL_CONTACT = {
+        "givenName": "Tech Gal",
+        "emailAddress": "technical@example.com"
+    }
+    SOCIAL_AUTH_SAML_SUPPORT_CONTACT = {
+        "givenName": "Support Guy",
+        "emailAddress": "support@example.com"
+    }
+    SOCIAL_AUTH_SAML_ENABLED_IDPS = {
+        "testshib": {
+            "entity_id": "https://idp.testshib.org/idp/shibboleth",
+            "url": "https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO",
+            "x509cert": "MIIEDjCCAvagAwIBAgIBADANBgkqhkiG9w0B...
+                        ...8Bbnl+ev0peYzxFyF5sQA==",
+        }
+    }
+
+    Optional settings:
+    SOCIAL_AUTH_SAML_SP_EXTRA = {}
+    SOCIAL_AUTH_SAML_SECURITY_CONFIG = {}
+    """
+    name = 'saml'
+    EXTRA_DATA = []
+
+    def get_idp(self, idp_name):
+        """Given the name of an IdP, get a SAMLIdentityProvider instance"""
+        idp_config = self.setting('ENABLED_IDPS')[idp_name]
+        return SAMLIdentityProvider(idp_name, **idp_config)
+
+    def generate_saml_config(self, idp=None):
+        """
+        Generate the configuration required to instantiate OneLogin_Saml2_Auth
+        """
+        # The shared absolute URL that all IdPs redirect back to -
+        # this is specified in our metadata.xml:
+        abs_completion_url = self.redirect_uri
+        config = {
+            'contactPerson': {
+                'technical': self.setting('TECHNICAL_CONTACT'),
+                'support': self.setting('SUPPORT_CONTACT')
+            },
+            'debug': True,
+            'idp': idp.saml_config_dict if idp else {},
+            'organization': self.setting('ORG_INFO'),
+            'security': {
+                'metadataValidUntil': '',
+                'metadataCacheDuration': 'P10D',  # metadata valid for ten days
+            },
+            'sp': {
+                'assertionConsumerService': {
+                    'url': abs_completion_url,
+                    # python-saml only supports HTTP-POST
+                    'binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'
+                },
+                'entityId': self.setting('SP_ENTITY_ID'),
+                'x509cert': self.setting('SP_PUBLIC_CERT'),
+                'privateKey': self.setting('SP_PRIVATE_KEY'),
+            },
+            'strict': True,  # We must force strict mode - for security
+        }
+        config['security'].update(self.setting('SECURITY_CONFIG', {}))
+        config['sp'].update(self.setting('SP_EXTRA', {}))
+        return config
+
+    def generate_metadata_xml(self):
+        """
+        Helper method that can be used from your web app to generate the XML
+        metadata required to link your web app as a Service Provider.
+
+        Returns (metadata XML string, list of errors)
+
+        Example usage (Django):
+            from ..apps.django_app.utils import load_strategy, \
+                                                     load_backend
+            def saml_metadata_view(request):
+                complete_url = reverse('social:complete', args=("saml", ))
+                saml_backend = load_backend(load_strategy(request), "saml",
+                                            complete_url)
+                metadata, errors = saml_backend.generate_metadata_xml()
+                if not errors:
+                    return HttpResponse(content=metadata,
+                                        content_type='text/xml')
+                return HttpResponseServerError(content=', '.join(errors))
+        """
+        config = self.generate_saml_config()
+        saml_settings = OneLogin_Saml2_Settings(
+            config,
+            sp_validation_only=True
+        )
+        metadata = saml_settings.get_sp_metadata()
+        errors = saml_settings.validate_metadata(metadata)
+        return metadata, errors
+
+    def _create_saml_auth(self, idp):
+        """Get an instance of OneLogin_Saml2_Auth"""
+        config = self.generate_saml_config(idp)
+        request_info = {
+            'https': 'on' if self.strategy.request_is_secure() else 'off',
+            'http_host': self.strategy.request_host(),
+            'script_name': self.strategy.request_path(),
+            'server_port': self.strategy.request_port(),
+            'get_data': self.strategy.request_get(),
+            'post_data': self.strategy.request_post(),
+        }
+        return OneLogin_Saml2_Auth(request_info, config)
+
+    def auth_url(self):
+        """Get the URL to which we must redirect in order to
+        authenticate the user"""
+        try:
+            idp_name = self.strategy.request_data()['idp']
+        except KeyError:
+            raise AuthMissingParameter(self, 'idp')
+        auth = self._create_saml_auth(idp=self.get_idp(idp_name))
+        # Below, return_to sets the RelayState, which can contain
+        # arbitrary data.  We use it to store the specific SAML IdP
+        # name, since we multiple IdPs share the same auth_complete
+        # URL.
+        return auth.login(return_to=idp_name)
+
+    def get_user_details(self, response):
+        """Get user details like full name, email, etc. from the
+        response - see auth_complete"""
+        idp = self.get_idp(response['idp_name'])
+        return idp.get_user_details(response['attributes'])
+
+    def get_user_id(self, details, response):
+        """
+        Get the permanent ID for this user from the response.
+        We prefix each ID with the name of the IdP so that we can
+        connect multiple IdPs to this user.
+        """
+        idp = self.get_idp(response['idp_name'])
+        uid = idp.get_user_permanent_id(response['attributes'])
+        return '{0}:{1}'.format(idp.name, uid)
+
+    def auth_complete(self, *args, **kwargs):
+        """
+        The user has been redirected back from the IdP and we should
+        now log them in, if everything checks out.
+        """
+        idp_name = self.strategy.request_data()['RelayState']
+        idp = self.get_idp(idp_name)
+        auth = self._create_saml_auth(idp)
+        auth.process_response()
+        errors = auth.get_errors()
+        if errors or not auth.is_authenticated():
+            reason = auth.get_last_error_reason()
+            raise AuthFailed(
+                self, 'SAML login failed: {0} ({1})'.format(errors, reason)
+            )
+
+        attributes = auth.get_attributes()
+        attributes['name_id'] = auth.get_nameid()
+        self._check_entitlements(idp, attributes)
+        response = {
+            'idp_name': idp_name,
+            'attributes': attributes,
+            'session_index': auth.get_session_index(),
+        }
+        kwargs.update({'response': response, 'backend': self})
+        return self.strategy.authenticate(*args, **kwargs)
+
+    def extra_data(self, user, uid, response, details=None, *args, **kwargs):
+        extra_data = super().extra_data(
+            user, uid, response['attributes'], details=details, *args, **kwargs
+        )
+        extra_data['session_index'] = response['session_index']
+        extra_data['name_id'] = response['attributes']['name_id']
+        return extra_data
+
+    def request_logout(self, idp_name, social_auth, return_to=None):
+        idp = self.get_idp(idp_name)
+        auth = self._create_saml_auth(idp)
+        name_id = social_auth.extra_data['name_id']
+        session_index = social_auth.extra_data['session_index']
+        return auth.logout(name_id=name_id, session_index=session_index, return_to=return_to)
+
+    def process_logout(self, idp_name, delete_session_cb):
+        idp = self.get_idp(idp_name)
+        auth = self._create_saml_auth(idp)
+        url = auth.process_slo(delete_session_cb=delete_session_cb)
+        errors = auth.get_errors()
+        return url, errors
+
+    def _check_entitlements(self, idp, attributes):
+        """
+        Additional verification of a SAML response before
+        authenticating the user.
+
+        Subclasses can override this method if they need custom
+        validation code, such as requiring the presence of an
+        eduPersonEntitlement.
+
+        raise social_core.exceptions.AuthForbidden if the user should not
+        be authenticated, or do nothing to allow the login pipeline to
+        continue.
+        """
+        pass
diff --git a/rootfs/api/apps_extra/social_core/backends/scistarter.py b/rootfs/api/apps_extra/social_core/backends/scistarter.py
new file mode 100644
index 000000000..c76d311f3
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/scistarter.py
@@ -0,0 +1,58 @@
+""" SciStarter OAuth2 Auth """
+
+from .oauth import BaseOAuth2
+
+
+class SciStarterOAuth2(BaseOAuth2):
+    name = 'scistarter'
+    ID_KEY = 'email'
+    SCOPE_PARAMETER_NAME = 'scope'
+    DEFAULT_SCOPE = ['login', 'extensive']
+    SCOPE_SEPARATOR = ' '
+    AUTHORIZATION_URL = 'https://scistarter.com/authorize'
+    ACCESS_TOKEN_URL = 'https://scistarter.com/token?key={key}'
+    ACCESS_TOKEN_METHOD = 'POST'
+    USER_ACCESS_URL = \
+        'https://scistarter.com/api/user_info?client_id={clientid}&key={key}'
+    REFRESH_TOKEN_URL = None
+    RESPONSE_TYPE = 'code'
+    STATE_PARAMETER = True
+    REDIRECT_STATE = True
+    EXTRA_DATA = [
+        ('profile_id', 'profile_id'),
+        ('expires', 'expires')
+    ]
+
+    def get_redirect_uri(self, state=None):
+        """Build redirect with redirect_state parameter."""
+        return self.redirect_uri.rstrip('/')
+
+    def authorization_url(self):
+        return self.AUTHORIZATION_URL
+
+    def get_user_details(self, response):
+        return {
+            'username': response.get('handle'),
+            'email': response.get('email') or '',
+            'first_name': response.get('first_name'),
+            'last_name': response.get('last_name')
+        }
+
+    def user_data(self, access_token, *args, **kwards):
+        client_id, client_secret = self.get_key_and_secret()
+        return self.get_json(
+            self.USER_ACCESS_URL.format(clientid=client_id, key=client_secret),
+            headers={
+                'Authorization': 'Bearer ' + access_token
+            }
+        )
+
+    def access_token(self, token):
+        """Return request for access token value"""
+        return self.get_querystring(self.access_token_url(),
+                                    auth=self.oauth_auth(token),
+                                    method=self.ACCESS_TOKEN_METHOD)
+
+    def access_token_url(self):
+        client_id, client_secret = self.get_key_and_secret()
+        return self.ACCESS_TOKEN_URL.format(key=client_secret)
diff --git a/rootfs/api/apps_extra/social_core/backends/shimmering.py b/rootfs/api/apps_extra/social_core/backends/shimmering.py
new file mode 100644
index 000000000..bc9a18c05
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/shimmering.py
@@ -0,0 +1,36 @@
+"""
+Shimmering Oauth
+"""
+from .oauth import BaseOAuth2
+
+
+class ShimmeringOAuth2(BaseOAuth2):
+    """Shimmering Verify OAuth2 authentication backend"""
+    name = 'shimmering'
+    ID_KEY = 'id'
+    AUTHORIZATION_URL = 'http://developers.shimmeringverify.com/o/authorize/'
+    ACCESS_TOKEN_URL = 'http://developers.shimmeringverify.com/o/token/'
+    ACCESS_TOKEN_METHOD = 'POST'
+
+    def get_user_details(self, response):
+        """Return user details from Shimmering"""
+        first_name = response.get('first_name')
+        last_name = response.get('last_name')
+        email = response.get('email')
+        username = response.get('username')
+        fullname = '{} {}'.format(first_name, last_name)
+        return {
+            'username': username,
+            'fullname': fullname,
+            'first_name': first_name,
+            'last_name': last_name,
+            'email': email,
+        }
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        headers = {'Authorization': 'Bearer %s' % access_token}
+        return self.get_json(
+            'http://developers.shimmeringverify.com/user_info/',
+            headers=headers
+        )
diff --git a/rootfs/api/apps_extra/social_core/backends/shopify.py b/rootfs/api/apps_extra/social_core/backends/shopify.py
new file mode 100644
index 000000000..59c6ba964
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/shopify.py
@@ -0,0 +1,98 @@
+"""
+Shopify OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/shopify.html
+"""
+import imp
+
+from ..utils import handle_http_errors
+from .oauth import BaseOAuth2
+from ..exceptions import AuthFailed, AuthCanceled
+
+
+class ShopifyOAuth2(BaseOAuth2):
+    """Shopify OAuth2 authentication backend"""
+    name = 'shopify'
+    ID_KEY = 'shop'
+    EXTRA_DATA = [
+        ('shop', 'shop'),
+        ('website', 'website'),
+        ('expires', 'expires')
+    ]
+    REDIRECT_STATE = False
+
+    @property
+    def shopify_api_version(self):
+        return self.setting('API_VERSION', '2019-04')
+
+    @property
+    def shopify_api(self):
+        if not hasattr(self, '_shopify_api'):
+            fp, pathname, description = imp.find_module('shopify')
+            self._shopify_api = imp.load_module('shopify', fp, pathname,
+                                                description)
+        return self._shopify_api
+
+    def get_user_details(self, response):
+        """Use the shopify store name as the username"""
+        return {
+            'username': str(response.get('shop', '')).replace(
+                '.myshopify.com', ''
+            )
+        }
+
+    def extra_data(self, user, uid, response, details=None, *args, **kwargs):
+        """Return access_token and extra defined names to store in
+        extra_data field"""
+        data = super().extra_data(user, uid, response, details, *args, **kwargs)
+        session = self.shopify_api.Session(self.data.get('shop').strip(),
+                                           version=self.shopify_api_version)
+        # Get, and store the permanent token
+        token = session.request_token(data['access_token'])
+        data['access_token'] = token
+        return dict(data)
+
+    def auth_url(self):
+        key, secret = self.get_key_and_secret()
+        self.shopify_api.Session.setup(api_key=key, secret=secret)
+        scope = self.get_scope()
+        state = self.state_token()
+        self.strategy.session_set(self.name + '_state', state)
+        redirect_uri = self.get_redirect_uri(state)
+        session = self.shopify_api.Session(self.data.get('shop').strip(),
+                                           version=self.shopify_api_version)
+        return session.create_permission_url(
+            scope=scope,
+            redirect_uri=redirect_uri
+        )
+
+    @handle_http_errors
+    def auth_complete(self, *args, **kwargs):
+        """Completes login process, must return user instance"""
+        self.process_error(self.data)
+        access_token = None
+        key, secret = self.get_key_and_secret()
+        try:
+            shop_url = self.data.get('shop')
+            self.shopify_api.Session.setup(api_key=key, secret=secret)
+            shopify_session = self.shopify_api.Session(
+                shop_url, version=self.shopify_api_version, token=self.data
+            )
+            access_token = shopify_session.token
+        except self.shopify_api.ValidationException:
+            raise AuthCanceled(self)
+        else:
+            if not access_token:
+                raise AuthFailed(self, 'Authentication Failed')
+        return self.do_auth(access_token, shop_url, shopify_session.url,
+                            *args, **kwargs)
+
+    def do_auth(self, access_token, shop_url, website, *args, **kwargs):
+        kwargs.update({
+            'backend': self,
+            'response': {
+                'shop': shop_url,
+                'website': 'http://{0}'.format(website),
+                'access_token': access_token
+            }
+        })
+        return self.strategy.authenticate(*args, **kwargs)
diff --git a/rootfs/api/apps_extra/social_core/backends/simplelogin.py b/rootfs/api/apps_extra/social_core/backends/simplelogin.py
new file mode 100644
index 000000000..b1fa6aaaa
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/simplelogin.py
@@ -0,0 +1,43 @@
+"""
+SimpleLogin OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/simplelogin.html
+"""
+
+from .oauth import BaseOAuth2
+
+
+class SimpleLoginOAuth2(BaseOAuth2):
+    """SimpleLogin OAuth authentication backend"""
+    name = 'simplelogin'
+    AUTHORIZATION_URL = 'https://app.simplelogin.io/oauth2/authorize'
+    ACCESS_TOKEN_URL = 'https://app.simplelogin.io/oauth2/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    REDIRECT_STATE = False
+    STATE_PARAMETER = True
+    SEND_USER_AGENT = True
+    EXTRA_DATA = [
+        ('name', 'name'),
+        ('email', 'email'),
+        ('avatar_url', 'avatar_url'),
+    ]
+
+    # endpoint to get user info
+    USERINFO_URL = 'https://app.simplelogin.io/oauth2/userinfo'
+
+    def get_user_details(self, response):
+        """Return user details from SimpleLogin account"""
+        fullname, first_name, last_name = self.get_user_names(
+            response.get('name')
+        )
+        return {
+            'username': response.get('email'),
+            'email': response.get('email'),
+            'fullname': fullname,
+            'first_name': first_name,
+            'last_name': last_name}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        return self.get_json(self.USERINFO_URL, params={
+            'access_token': access_token
+        })
diff --git a/rootfs/api/apps_extra/social_core/backends/sketchfab.py b/rootfs/api/apps_extra/social_core/backends/sketchfab.py
new file mode 100644
index 000000000..5a8d0c6b2
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/sketchfab.py
@@ -0,0 +1,39 @@
+"""
+Sketchfab OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/sketchfab.html
+    https://sketchfab.com/developers/oauth
+"""
+from .oauth import BaseOAuth2
+
+
+class SketchfabOAuth2(BaseOAuth2):
+    name = 'sketchfab'
+    ID_KEY = 'uid'
+    AUTHORIZATION_URL = 'https://sketchfab.com/oauth2/authorize/'
+    ACCESS_TOKEN_URL = 'https://sketchfab.com/oauth2/token/'
+    ACCESS_TOKEN_METHOD = 'POST'
+    REDIRECT_STATE = False
+    REQUIRES_EMAIL_VALIDATION = False
+    EXTRA_DATA = [
+        ('username', 'username'),
+        ('apiToken', 'apiToken')
+    ]
+
+    def get_user_details(self, response):
+        """Return user details from Sketchfab account"""
+        user_data = response
+        email = user_data.get('email', '')
+        username = user_data['username']
+        name = user_data.get('displayName', '')
+        fullname, first_name, last_name = self.get_user_names(name)
+        return {'username': username,
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name,
+                'email': email}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        return self.get_json('https://sketchfab.com/v2/users/me', headers={
+            'Authorization': 'Bearer {0}'.format(access_token)
+        })
diff --git a/rootfs/api/apps_extra/social_core/backends/skyrock.py b/rootfs/api/apps_extra/social_core/backends/skyrock.py
new file mode 100644
index 000000000..98906b6b3
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/skyrock.py
@@ -0,0 +1,32 @@
+"""
+Skyrock OAuth1 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/skyrock.html
+"""
+from .oauth import BaseOAuth1
+
+
+class SkyrockOAuth(BaseOAuth1):
+    """Skyrock OAuth authentication backend"""
+    name = 'skyrock'
+    ID_KEY = 'id_user'
+    AUTHORIZATION_URL = 'https://api.skyrock.com/v2/oauth/authenticate'
+    REQUEST_TOKEN_URL = 'https://api.skyrock.com/v2/oauth/initiate'
+    ACCESS_TOKEN_URL = 'https://api.skyrock.com/v2/oauth/token'
+    EXTRA_DATA = [('id', 'id')]
+
+    def get_user_details(self, response):
+        """Return user details from Skyrock account"""
+        fullname, first_name, last_name = self.get_user_names(
+            first_name=response['firstname'],
+            last_name=response['name']
+        )
+        return {'username': response['username'],
+                'email': response['email'],
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
+
+    def user_data(self, access_token):
+        """Return user data provided"""
+        return self.get_json('https://api.skyrock.com/v2/user/get.json',
+                             auth=self.oauth_auth(access_token))
diff --git a/rootfs/api/apps_extra/social_core/backends/slack.py b/rootfs/api/apps_extra/social_core/backends/slack.py
new file mode 100644
index 000000000..5ed69f9c4
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/slack.py
@@ -0,0 +1,61 @@
+"""
+Slack OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/slack.html
+    https://api.slack.com/docs/oauth
+"""
+from __future__ import unicode_literals
+
+from .oauth import BaseOAuth2
+
+
+class SlackOAuth2(BaseOAuth2):
+    """Slack OAuth authentication backend"""
+    name = 'slack'
+    AUTHORIZATION_URL = 'https://slack.com/oauth/authorize'
+    ACCESS_TOKEN_URL = 'https://slack.com/api/oauth.access'
+    ACCESS_TOKEN_METHOD = 'POST'
+    DEFAULT_SCOPE = ['identity.basic', 'identity.email']
+    SCOPE_SEPARATOR = ','
+    REDIRECT_STATE = False
+    EXTRA_DATA = [
+        ('id', 'id'),
+        ('name', 'name'),
+        ('real_name', 'real_name')
+    ]
+
+    def auth_extra_arguments(self):
+        params = super().auth_extra_arguments() or {}
+        if self.setting('TEAM'):
+            params['team'] = self.setting('TEAM')
+        return params
+
+    def get_user_details(self, response):
+        """Return user details from Slack account"""
+        # Build the username with the team $username@$team_url
+        # Necessary to get unique names for all of slack
+        user = response['user']
+        team = response.get('team')
+        name = user['name']
+        email = user.get('email')
+        username = email and email.split('@', 1)[0] or name
+        fullname, first_name, last_name = self.get_user_names(name)
+
+        if self.setting('USERNAME_WITH_TEAM', True) and team and \
+           'name' in team:
+            username = '{0}@{1}'.format(username, response['team']['name'])
+
+        return {
+            'username': username,
+            'email': email,
+            'fullname': fullname,
+            'first_name': first_name,
+            'last_name': last_name
+        }
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        response = self.get_json('https://slack.com/api/users.identity',
+                                 params={'token': access_token})
+        if not response.get('id', None):
+            response['id'] = response['user']['id']
+        return response
diff --git a/rootfs/api/apps_extra/social_core/backends/soundcloud.py b/rootfs/api/apps_extra/social_core/backends/soundcloud.py
new file mode 100644
index 000000000..0e6d46211
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/soundcloud.py
@@ -0,0 +1,56 @@
+"""
+Soundcloud OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/soundcloud.html
+"""
+from urllib.parse import urlencode
+
+from .oauth import BaseOAuth2
+
+
+class SoundcloudOAuth2(BaseOAuth2):
+    """Soundcloud OAuth authentication backend"""
+    name = 'soundcloud'
+    AUTHORIZATION_URL = 'https://soundcloud.com/connect'
+    ACCESS_TOKEN_URL = 'https://api.soundcloud.com/oauth2/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    SCOPE_SEPARATOR = ','
+    REDIRECT_STATE = False
+    EXTRA_DATA = [
+        ('id', 'id'),
+        ('refresh_token', 'refresh_token'),
+        ('expires', 'expires')
+    ]
+
+    def get_user_details(self, response):
+        """Return user details from Soundcloud account"""
+        fullname, first_name, last_name = self.get_user_names(
+            response.get('full_name')
+        )
+        return {'username': response.get('username'),
+                'email': response.get('email') or '',
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        return self.get_json('https://api.soundcloud.com/me.json',
+                             params={'oauth_token': access_token})
+
+    def auth_url(self):
+        """Return redirect url"""
+        state = None
+        if self.STATE_PARAMETER or self.REDIRECT_STATE:
+            # Store state in session for further request validation. The state
+            # value is passed as state parameter (as specified in OAuth2 spec),
+            # but also added to redirect_uri, that way we can still verify the
+            # request if the provider doesn't implement the state parameter.
+            # Reuse token if any.
+            name = self.name + '_state'
+            state = self.strategy.session_get(name) or self.state_token()
+            self.strategy.session_set(name, state)
+
+        params = self.auth_params(state)
+        params.update(self.get_scope_argument())
+        params.update(self.auth_extra_arguments())
+        return self.AUTHORIZATION_URL + '?' + urlencode(params)
diff --git a/rootfs/api/apps_extra/social_core/backends/spotify.py b/rootfs/api/apps_extra/social_core/backends/spotify.py
new file mode 100644
index 000000000..cc0e67e0d
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/spotify.py
@@ -0,0 +1,47 @@
+"""
+Spotify backend, docs at:
+    https://developer.spotify.com/spotify-web-api/
+    https://developer.spotify.com/spotify-web-api/authorization-guide/
+"""
+import base64
+
+from .oauth import BaseOAuth2
+
+
+class SpotifyOAuth2(BaseOAuth2):
+    """Spotify OAuth2 authentication backend"""
+    name = 'spotify'
+    ID_KEY = 'id'
+    AUTHORIZATION_URL = 'https://accounts.spotify.com/authorize'
+    ACCESS_TOKEN_URL = 'https://accounts.spotify.com/api/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    SCOPE_SEPARATOR = ' '
+    REDIRECT_STATE = False
+    EXTRA_DATA = [
+        ('refresh_token', 'refresh_token'),
+    ]
+
+    def auth_headers(self):
+        auth_str = '{0}:{1}'.format(*self.get_key_and_secret())
+        b64_auth_str = base64.urlsafe_b64encode(auth_str.encode()).decode()
+        return {
+            'Authorization': 'Basic {0}'.format(b64_auth_str)
+        }
+
+    def get_user_details(self, response):
+        """Return user details from Spotify account"""
+        fullname, first_name, last_name = self.get_user_names(
+            response.get('display_name')
+        )
+        return {'username': response.get('id'),
+                'email': response.get('email'),
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        return self.get_json(
+            'https://api.spotify.com/v1/me',
+            headers={'Authorization': 'Bearer {0}'.format(access_token)}
+        )
diff --git a/rootfs/api/apps_extra/social_core/backends/stackoverflow.py b/rootfs/api/apps_extra/social_core/backends/stackoverflow.py
new file mode 100644
index 000000000..56e2421fc
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/stackoverflow.py
@@ -0,0 +1,43 @@
+"""
+Stackoverflow OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/stackoverflow.html
+"""
+from .oauth import BaseOAuth2
+
+
+class StackoverflowOAuth2(BaseOAuth2):
+    """Stackoverflow OAuth2 authentication backend"""
+    name = 'stackoverflow'
+    ID_KEY = 'user_id'
+    AUTHORIZATION_URL = 'https://stackexchange.com/oauth'
+    ACCESS_TOKEN_URL = 'https://stackexchange.com/oauth/access_token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    SCOPE_SEPARATOR = ','
+    EXTRA_DATA = [
+        ('id', 'id'),
+        ('expires', 'expires')
+    ]
+
+    def get_user_details(self, response):
+        """Return user details from Stackoverflow account"""
+        fullname, first_name, last_name = self.get_user_names(
+            response.get('display_name')
+        )
+        return {'username': response.get('link').rsplit('/', 1)[-1],
+                'full_name': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        return self.get_json(
+            'https://api.stackexchange.com/2.1/me',
+            params={
+                'site': 'stackoverflow',
+                'access_token': access_token,
+                'key': self.setting('API_KEY')
+            }
+        )['items'][0]
+
+    def request_access_token(self, *args, **kwargs):
+        return self.get_querystring(*args, **kwargs)
diff --git a/rootfs/api/apps_extra/social_core/backends/steam.py b/rootfs/api/apps_extra/social_core/backends/steam.py
new file mode 100644
index 000000000..5627a7dbd
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/steam.py
@@ -0,0 +1,47 @@
+"""
+Steam OpenId backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/steam.html
+"""
+from .open_id import OpenIdAuth
+from ..exceptions import AuthFailed
+
+
+USER_INFO = 'http://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?'
+
+
+class SteamOpenId(OpenIdAuth):
+    name = 'steam'
+    URL = 'https://steamcommunity.com/openid'
+
+    def get_user_id(self, details, response):
+        """Return user unique id provided by service"""
+        return self._user_id(response)
+
+    def get_user_details(self, response):
+        player = self.get_json(USER_INFO, params={
+            'key': self.setting('API_KEY'),
+            'steamids': self._user_id(response)
+        })
+        if len(player['response']['players']) > 0:
+            player = player['response']['players'][0]
+            details = {'username': player.get('personaname'),
+                       'email': '',
+                       'fullname': '',
+                       'first_name': '',
+                       'last_name': '',
+                       'player': player}
+        else:
+            details = {}
+        return details
+
+    def consumer(self):
+        # Steam seems to support stateless mode only, ignore store
+        if not hasattr(self, '_consumer'):
+            self._consumer = self.create_consumer()
+        return self._consumer
+
+    def _user_id(self, response):
+        user_id = response.identity_url.rsplit('/', 1)[-1]
+        if not user_id.isdigit():
+            raise AuthFailed(self, 'Missing Steam Id')
+        return user_id
diff --git a/rootfs/api/apps_extra/social_core/backends/stocktwits.py b/rootfs/api/apps_extra/social_core/backends/stocktwits.py
new file mode 100644
index 000000000..c65757386
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/stocktwits.py
@@ -0,0 +1,37 @@
+"""
+Stocktwits OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/stocktwits.html
+"""
+from .oauth import BaseOAuth2
+
+
+class StocktwitsOAuth2(BaseOAuth2):
+    """Stockwiths OAuth2 backend"""
+    name = 'stocktwits'
+    AUTHORIZATION_URL = 'https://api.stocktwits.com/api/2/oauth/authorize'
+    ACCESS_TOKEN_URL = 'https://api.stocktwits.com/api/2/oauth/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    SCOPE_SEPARATOR = ','
+    DEFAULT_SCOPE = ['read', 'publish_messages', 'publish_watch_lists',
+                     'follow_users', 'follow_stocks']
+
+    def get_user_id(self, details, response):
+        return response['user']['id']
+
+    def get_user_details(self, response):
+        """Return user details from Stocktwits account"""
+        fullname, first_name, last_name = self.get_user_names(
+            response['user']['name']
+        )
+        return {'username': response['user']['username'],
+                'email': '',  # not supplied
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        return self.get_json(
+            'https://api.stocktwits.com/api/2/account/verify.json',
+            params={'access_token': access_token}
+        )
diff --git a/rootfs/api/apps_extra/social_core/backends/strava.py b/rootfs/api/apps_extra/social_core/backends/strava.py
new file mode 100644
index 000000000..a62e03206
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/strava.py
@@ -0,0 +1,48 @@
+"""
+Strava OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/strava.html
+"""
+from .oauth import BaseOAuth2
+
+
+class StravaOAuth(BaseOAuth2):
+    name = 'strava'
+    AUTHORIZATION_URL = 'https://www.strava.com/oauth/authorize'
+    ACCESS_TOKEN_URL = 'https://www.strava.com/oauth/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    # Strava doesn't check for parameters in redirect_uri and directly appends
+    # the auth parameters to it, ending with an URL like:
+    # http://example.com/complete/strava?redirect_state=xxx?code=xxx&state=xxx
+    # Check issue #259 for details.
+    REDIRECT_STATE = False
+    REVOKE_TOKEN_URL = 'https://www.strava.com/oauth/deauthorize'
+    SCOPE_SEPARATOR = ','
+    EXTRA_DATA = [
+        ('refresh_token', 'refresh_token'),
+        ('expires_in', 'expires'),
+    ]
+
+    def get_user_id(self, details, response):
+        return response['athlete']['id']
+
+    def get_user_details(self, response):
+        """Return user details from Strava account"""
+        username = response['athlete'].get('username', '')
+        fullname, first_name, last_name = self.get_user_names(
+            first_name=response['athlete'].get('firstname', ''),
+            last_name=response['athlete'].get('lastname', ''),
+        )
+        return {'username': username,
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        return self.get_json('https://www.strava.com/api/v3/athlete',
+                             params={'access_token': access_token})
+
+    def revoke_token_params(self, token, uid):
+        params = super().revoke_token_params(token, uid)
+        params['access_token'] = token
+        return params
diff --git a/rootfs/api/apps_extra/social_core/backends/stripe.py b/rootfs/api/apps_extra/social_core/backends/stripe.py
new file mode 100644
index 000000000..54d3cbd1b
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/stripe.py
@@ -0,0 +1,46 @@
+"""
+Stripe OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/stripe.html
+"""
+from .oauth import BaseOAuth2
+
+
+class StripeOAuth2(BaseOAuth2):
+    """Stripe OAuth2 authentication backend"""
+    name = 'stripe'
+    ID_KEY = 'stripe_user_id'
+    AUTHORIZATION_URL = 'https://connect.stripe.com/oauth/authorize'
+    ACCESS_TOKEN_URL = 'https://connect.stripe.com/oauth/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    REDIRECT_STATE = False
+    EXTRA_DATA = [
+        ('stripe_publishable_key', 'stripe_publishable_key'),
+        ('access_token', 'access_token'),
+        ('livemode', 'livemode'),
+        ('token_type', 'token_type'),
+        ('refresh_token', 'refresh_token'),
+        ('stripe_user_id', 'stripe_user_id'),
+    ]
+
+    def get_user_details(self, response):
+        """Return user details from Stripe account"""
+        return {'username': response.get('stripe_user_id'),
+                'email': ''}
+
+    def auth_complete_params(self, state=None):
+        client_id, client_secret = self.get_key_and_secret()
+        return {
+            'grant_type': 'authorization_code',
+            'client_id': client_id,
+            'scope': self.SCOPE_SEPARATOR.join(self.get_scope()),
+            'code': self.data['code']
+        }
+
+    def auth_headers(self):
+        client_id, client_secret = self.get_key_and_secret()
+        return {'Accept': 'application/json',
+                'Authorization': 'Bearer {0}'.format(client_secret)}
+
+    def refresh_token_params(self, refresh_token, *args, **kwargs):
+        return {'refresh_token': refresh_token,
+                'grant_type': 'refresh_token'}
diff --git a/rootfs/api/apps_extra/social_core/backends/surveymonkey.py b/rootfs/api/apps_extra/social_core/backends/surveymonkey.py
new file mode 100644
index 000000000..ac54c1816
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/surveymonkey.py
@@ -0,0 +1,31 @@
+"""
+SurveyMonkey OAuth2 backend, docs at:
+    https://developer.surveymonkey.com/api/v3/#authentication
+"""
+from .oauth import BaseOAuth2
+
+
+class SurveyMonkeyOAuth2(BaseOAuth2):
+    """SurveyMonkey OAuth2 authentication backend"""
+    name = 'surveymonkey'
+    AUTHORIZATION_URL = 'https://api.surveymonkey.com/oauth/authorize'
+    ACCESS_TOKEN_URL = 'https://api.surveymonkey.com/oauth/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    USER_DATA_URL = '/v3/users/me'
+    STATE_PARAMETER = False
+    REDIRECT_STATE = False
+    EXTRA_DATA = [
+        ('access_url', 'access_url'),
+    ]
+
+    def get_user_details(self, response):
+        """Return user details from a SurveyMonkey /users/me response"""
+        response['name'] = response['first_name'] + ' ' + response['last_name']
+        return response
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data information from service"""
+        base_url = kwargs['response']['access_url']
+        return self.get_json(base_url + self.USER_DATA_URL, headers={
+          'Authorization': 'bearer ' + access_token
+        })
diff --git a/rootfs/api/apps_extra/social_core/backends/suse.py b/rootfs/api/apps_extra/social_core/backends/suse.py
new file mode 100644
index 000000000..595cfc201
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/suse.py
@@ -0,0 +1,17 @@
+"""
+Open Suse OpenId backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/suse.html
+"""
+from .open_id import OpenIdAuth
+
+
+class OpenSUSEOpenId(OpenIdAuth):
+    name = 'opensuse'
+    URL = 'https://www.opensuse.org/openid/user/'
+
+    def get_user_id(self, details, response):
+        """
+        Return user unique id provided by service. For openSUSE
+        the nickname is original.
+        """
+        return details['nickname']
diff --git a/rootfs/api/apps_extra/social_core/backends/taobao.py b/rootfs/api/apps_extra/social_core/backends/taobao.py
new file mode 100644
index 000000000..0db7f26bd
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/taobao.py
@@ -0,0 +1,26 @@
+from .oauth import BaseOAuth2
+
+
+class TAOBAOAuth(BaseOAuth2):
+    """Taobao OAuth authentication mechanism"""
+    name = 'taobao'
+    ID_KEY = 'taobao_user_id'
+    ACCESS_TOKEN_METHOD = 'POST'
+    AUTHORIZATION_URL = 'https://oauth.taobao.com/authorize'
+    ACCESS_TOKEN_URL = 'https://oauth.taobao.com/token'
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Return user data provided"""
+        try:
+            return self.get_json('https://eco.taobao.com/router/rest', params={
+                'method': 'taobao.user.get',
+                'fomate': 'json',
+                'v': '2.0',
+                'access_token': access_token
+            })
+        except ValueError:
+            return None
+
+    def get_user_details(self, response):
+        """Return user details from Taobao account"""
+        return {'username': response.get('taobao_user_nick')}
diff --git a/rootfs/api/apps_extra/social_core/backends/telegram.py b/rootfs/api/apps_extra/social_core/backends/telegram.py
new file mode 100644
index 000000000..e7f5d5994
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/telegram.py
@@ -0,0 +1,59 @@
+import hmac
+import time
+import hashlib
+
+from .base import BaseAuth
+from ..exceptions import AuthFailed, AuthMissingParameter
+from ..utils import handle_http_errors
+
+
+class TelegramAuth(BaseAuth):
+    name = 'telegram'
+    ID_KEY = 'id'
+
+    def verify_data(self, response):
+        bot_token = self.setting('BOT_TOKEN')
+        if bot_token is None:
+            raise AuthMissingParameter('telegram',
+                                       'SOCIAL_AUTH_TELEGRAM_BOT_TOKEN')
+
+        received_hash_string = response.get('hash')
+        auth_date = response.get('auth_date')
+
+        if received_hash_string is None or auth_date is None:
+            raise AuthMissingParameter('telegram', 'hash or auth_date')
+
+        data_check_string = ['{}={}'.format(k, v)
+                             for k, v in response.items() if k != 'hash']
+        data_check_string = '\n'.join(sorted(data_check_string))
+        secret_key = hashlib.sha256(bot_token.encode()).digest()
+        built_hash = hmac.new(secret_key,
+                              msg=data_check_string.encode(),
+                              digestmod=hashlib.sha256).hexdigest()
+        current_timestamp = int(time.time())
+        auth_timestamp = int(auth_date)
+        if current_timestamp - auth_timestamp > 86400:
+            raise AuthFailed('telegram', 'Auth date is outdated')
+        if built_hash != received_hash_string:
+            raise AuthFailed('telegram', 'Invalid hash supplied')
+
+    def extra_data(self, user, uid, response, details=None, *args, **kwargs):
+        return response
+
+    def get_user_details(self, response):
+        first_name = response.get('first_name', '')
+        last_name = response.get('last_name', '')
+        fullname = '{} {}'.format(first_name, last_name).strip()
+        return {
+            'username': response.get('username') or response[self.ID_KEY],
+            'first_name': first_name,
+            'last_name': last_name,
+            'fullname': fullname
+        }
+
+    @handle_http_errors
+    def auth_complete(self, *args, **kwargs):
+        response = self.data
+        self.verify_data(response)
+        kwargs.update({'response': self.data, 'backend': self})
+        return self.strategy.authenticate(*args, **kwargs)
diff --git a/rootfs/api/apps_extra/social_core/backends/thisismyjam.py b/rootfs/api/apps_extra/social_core/backends/thisismyjam.py
new file mode 100644
index 000000000..f3aa58e20
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/thisismyjam.py
@@ -0,0 +1,33 @@
+"""
+ThisIsMyJam OAuth1 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/thisismyjam.html
+"""
+from .oauth import BaseOAuth1
+
+
+class ThisIsMyJamOAuth1(BaseOAuth1):
+    """ThisIsMyJam OAuth1 authentication backend"""
+    name = 'thisismyjam'
+    REQUEST_TOKEN_URL = 'http://www.thisismyjam.com/oauth/request_token'
+    AUTHORIZATION_URL = 'http://www.thisismyjam.com/oauth/authorize'
+    ACCESS_TOKEN_URL = 'http://www.thisismyjam.com/oauth/access_token'
+    REDIRECT_URI_PARAMETER_NAME = 'oauth_callback'
+
+    def get_user_details(self, response):
+        """Return user details from ThisIsMyJam account"""
+        info = response.get('person')
+        fullname, first_name, last_name = self.get_user_names(
+            info.get('fullname')
+        )
+        return {
+            'username': info.get('name'),
+            'email': '',
+            'fullname': fullname,
+            'first_name': first_name,
+            'last_name': last_name
+        }
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        return self.get_json('http://api.thisismyjam.com/1/verify.json',
+                             auth=self.oauth_auth(access_token))
diff --git a/rootfs/api/apps_extra/social_core/backends/trello.py b/rootfs/api/apps_extra/social_core/backends/trello.py
new file mode 100644
index 000000000..e998df339
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/trello.py
@@ -0,0 +1,47 @@
+"""
+Trello OAuth1 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/trello.html
+"""
+from .oauth import BaseOAuth1
+
+
+class TrelloOAuth(BaseOAuth1):
+
+    """Trello OAuth authentication backend"""
+    name = 'trello'
+    ID_KEY = 'username'
+    AUTHORIZATION_URL = 'https://trello.com/1/OAuthAuthorizeToken'
+    REQUEST_TOKEN_URL = 'https://trello.com/1/OAuthGetRequestToken'
+    ACCESS_TOKEN_URL = 'https://trello.com/1/OAuthGetAccessToken'
+
+    EXTRA_DATA = [
+        ('username', 'username'),
+        ('email', 'email'),
+        ('fullName', 'fullName')
+    ]
+
+    def get_user_details(self, response):
+        """Return user details from Trello account"""
+        fullname, first_name, last_name = self.get_user_names(
+            response.get('fullName')
+        )
+        return {'username': response.get('username'),
+                'email': response.get('email'),
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
+
+    def user_data(self, access_token):
+        """Return user data provided"""
+        url = 'https://trello.com/1/members/me'
+        try:
+            return self.get_json(url, auth=self.oauth_auth(access_token))
+        except ValueError:
+            return None
+
+    def auth_extra_arguments(self):
+        return {
+            'name': self.setting('APP_NAME', ''),
+            # trello default expiration is '30days'
+            'expiration': self.setting('EXPIRATION', 'never')
+        }
diff --git a/rootfs/api/apps_extra/social_core/backends/tripit.py b/rootfs/api/apps_extra/social_core/backends/tripit.py
new file mode 100644
index 000000000..0d4991085
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/tripit.py
@@ -0,0 +1,46 @@
+"""
+Tripit OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/tripit.html
+"""
+from xml.dom import minidom
+
+from .oauth import BaseOAuth1
+
+
+class TripItOAuth(BaseOAuth1):
+    """TripIt OAuth authentication backend"""
+    name = 'tripit'
+    AUTHORIZATION_URL = 'https://www.tripit.com/oauth/authorize'
+    REQUEST_TOKEN_URL = 'https://api.tripit.com/oauth/request_token'
+    ACCESS_TOKEN_URL = 'https://api.tripit.com/oauth/access_token'
+    EXTRA_DATA = [('screen_name', 'screen_name')]
+
+    def get_user_details(self, response):
+        """Return user details from TripIt account"""
+        fullname, first_name, last_name = self.get_user_names(response['name'])
+        return {'username': response['screen_name'],
+                'email': response['email'],
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Return user data provided"""
+        dom = minidom.parseString(self.oauth_request(
+            access_token,
+            'https://api.tripit.com/v1/get/profile'
+        ).content)
+        return {
+            'id': dom.getElementsByTagName('Profile')[0].getAttribute('ref'),
+            'name': dom.getElementsByTagName(
+                'public_display_name'
+            )[0].childNodes[0].data,
+            'screen_name': dom.getElementsByTagName(
+                'screen_name'
+            )[0].childNodes[0].data,
+            'email': dom.getElementsByTagName(
+                'is_primary'
+            )[0].parentNode.getElementsByTagName(
+                'address'
+            )[0].childNodes[0].data
+        }
diff --git a/rootfs/api/apps_extra/social_core/backends/tumblr.py b/rootfs/api/apps_extra/social_core/backends/tumblr.py
new file mode 100644
index 000000000..7dad1ece2
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/tumblr.py
@@ -0,0 +1,31 @@
+"""
+Tumblr OAuth1 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/tumblr.html
+"""
+from ..utils import first
+from .oauth import BaseOAuth1
+
+
+class TumblrOAuth(BaseOAuth1):
+    name = 'tumblr'
+    ID_KEY = 'name'
+    AUTHORIZATION_URL = 'http://www.tumblr.com/oauth/authorize'
+    REQUEST_TOKEN_URL = 'http://www.tumblr.com/oauth/request_token'
+    REQUEST_TOKEN_METHOD = 'POST'
+    ACCESS_TOKEN_URL = 'http://www.tumblr.com/oauth/access_token'
+
+    def get_user_id(self, details, response):
+        return response['response']['user'][self.ID_KEY]
+
+    def get_user_details(self, response):
+        # http://www.tumblr.com/docs/en/api/v2#user-methods
+        user_info = response['response']['user']
+        data = {'username': user_info['name']}
+        blog = first(lambda blog: blog['primary'], user_info['blogs'])
+        if blog:
+            data['fullname'] = blog['title']
+        return data
+
+    def user_data(self, access_token):
+        return self.get_json('http://api.tumblr.com/v2/user/info',
+                             auth=self.oauth_auth(access_token))
diff --git a/rootfs/api/apps_extra/social_core/backends/twilio.py b/rootfs/api/apps_extra/social_core/backends/twilio.py
new file mode 100644
index 000000000..449c7726d
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/twilio.py
@@ -0,0 +1,39 @@
+"""
+Twilio auth backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/twilio.html
+"""
+from re import sub
+from urllib.parse import urlencode
+
+from .base import BaseAuth
+
+
+class TwilioAuth(BaseAuth):
+    name = 'twilio'
+    ID_KEY = 'AccountSid'
+
+    def get_user_details(self, response):
+        """Return twilio details, Twilio only provides AccountSID as
+        parameters."""
+        # /complete/twilio/?AccountSid=ACc65ea16c9ebd4d4684edf814995b27e
+        return {'username': response['AccountSid'],
+                'email': '',
+                'fullname': '',
+                'first_name': '',
+                'last_name': ''}
+
+    def auth_url(self):
+        """Return authorization redirect url."""
+        key, secret = self.get_key_and_secret()
+        callback = self.strategy.absolute_uri(self.redirect_uri)
+        callback = sub(r'^https', 'http', callback)
+        query = urlencode({'cb': callback})
+        return 'https://www.twilio.com/authorize/{0}?{1}'.format(key, query)
+
+    def auth_complete(self, *args, **kwargs):
+        """Completes login process, must return user instance"""
+        account_sid = self.data.get('AccountSid')
+        if not account_sid:
+            raise ValueError('No AccountSid returned')
+        kwargs.update({'response': self.data, 'backend': self})
+        return self.strategy.authenticate(*args, **kwargs)
diff --git a/rootfs/api/apps_extra/social_core/backends/twitch.py b/rootfs/api/apps_extra/social_core/backends/twitch.py
new file mode 100644
index 000000000..aa555aae7
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/twitch.py
@@ -0,0 +1,30 @@
+"""
+Twitch OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/twitch.html
+"""
+from .oauth import BaseOAuth2
+
+
+class TwitchOAuth2(BaseOAuth2):
+    """Twitch OAuth authentication backend"""
+    name = 'twitch'
+    ID_KEY = '_id'
+    AUTHORIZATION_URL = 'https://id.twitch.tv/oauth2/authorize'
+    ACCESS_TOKEN_URL = 'https://id.twitch.tv/oauth2/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    DEFAULT_SCOPE = ['user_read']
+    REDIRECT_STATE = False
+
+    def get_user_details(self, response):
+        return {
+            'username': response.get('name'),
+            'email': response.get('email'),
+            'first_name': '',
+            'last_name': ''
+        }
+
+    def user_data(self, access_token, *args, **kwargs):
+        return self.get_json(
+            'https://api.twitch.tv/kraken/user/',
+            params={'oauth_token': access_token, 'api_version': 5},
+        )
diff --git a/rootfs/api/apps_extra/social_core/backends/twitter.py b/rootfs/api/apps_extra/social_core/backends/twitter.py
new file mode 100644
index 000000000..41970f821
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/twitter.py
@@ -0,0 +1,41 @@
+"""
+Twitter OAuth1 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/twitter.html
+"""
+from .oauth import BaseOAuth1
+from ..exceptions import AuthCanceled
+
+
+class TwitterOAuth(BaseOAuth1):
+    """Twitter OAuth authentication backend"""
+    name = 'twitter'
+    EXTRA_DATA = [('id', 'id')]
+    REQUEST_TOKEN_METHOD = 'POST'
+    ACCESS_TOKEN_METHOD = 'POST'
+    AUTHORIZATION_URL = 'https://api.twitter.com/oauth/authenticate'
+    REQUEST_TOKEN_URL = 'https://api.twitter.com/oauth/request_token'
+    ACCESS_TOKEN_URL = 'https://api.twitter.com/oauth/access_token'
+    REDIRECT_STATE = True
+
+    def process_error(self, data):
+        if 'denied' in data:
+            raise AuthCanceled(self)
+        else:
+            super().process_error(data)
+
+    def get_user_details(self, response):
+        """Return user details from Twitter account"""
+        fullname, first_name, last_name = self.get_user_names(response['name'])
+        return {'username': response['screen_name'],
+                'email': response.get('email', ''),
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Return user data provided"""
+        return self.get_json(
+            'https://api.twitter.com/1.1/account/verify_credentials.json',
+            params={'include_email': 'true'},
+            auth=self.oauth_auth(access_token)
+        )
diff --git a/rootfs/api/apps_extra/social_core/backends/uber.py b/rootfs/api/apps_extra/social_core/backends/uber.py
new file mode 100644
index 000000000..21bd9c51d
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/uber.py
@@ -0,0 +1,39 @@
+"""
+Uber OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/uber.html
+"""
+from .oauth import BaseOAuth2
+
+
+class UberOAuth2(BaseOAuth2):
+    name = 'uber'
+    ID_KEY = 'uuid'
+    SCOPE_SEPARATOR = ' '
+    AUTHORIZATION_URL = 'https://login.uber.com/oauth/authorize'
+    ACCESS_TOKEN_URL = 'https://login.uber.com/oauth/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+
+    def auth_complete_credentials(self):
+        return self.get_key_and_secret()
+
+    def get_user_details(self, response):
+        """Return user details from Uber account"""
+        email = response.get('email', '')
+        fullname, first_name, last_name = self.get_user_names(
+            '',
+            response.get('first_name', ''),
+            response.get('last_name', '')
+        )
+        return {'username': email,
+                'email': email,
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        response = kwargs.pop('response')
+        return self.get_json('https://api.uber.com/v1/me', headers={
+            'Authorization': '{0} {1}'.format(response.get('token_type'),
+                                              access_token)
+        })
diff --git a/rootfs/api/apps_extra/social_core/backends/ubuntu.py b/rootfs/api/apps_extra/social_core/backends/ubuntu.py
new file mode 100644
index 000000000..3db9b876c
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/ubuntu.py
@@ -0,0 +1,16 @@
+"""
+Ubuntu One OpenId backend
+"""
+from .open_id import OpenIdAuth
+
+
+class UbuntuOpenId(OpenIdAuth):
+    name = 'ubuntu'
+    URL = 'https://login.ubuntu.com'
+
+    def get_user_id(self, details, response):
+        """
+        Return user unique id provided by service. For Ubuntu One
+        the nickname should be original.
+        """
+        return details['nickname']
diff --git a/rootfs/api/apps_extra/social_core/backends/udata.py b/rootfs/api/apps_extra/social_core/backends/udata.py
new file mode 100644
index 000000000..c7d0c4140
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/udata.py
@@ -0,0 +1,37 @@
+"""
+Udata related backends.
+
+Docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/udata.html
+"""
+from .oauth import BaseOAuth2
+
+
+class UdataBaseOAuth2(BaseOAuth2):
+    """Udata base OAuth authentication backend."""
+    SCOPE_SEPARATOR = ','
+    REDIRECT_STATE = False
+    DEFAULT_SCOPE = ['default']
+    ACCESS_TOKEN_METHOD = 'POST'
+
+    def get_user_details(self, response):
+        """Return user details from Udata account."""
+        return {
+            'username': response.get('first_name'),
+            'email': response.get('email') or '',
+            'first_name': response.get('first_name')
+        }
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Load user data from service."""
+        return self.get_json(self.USER_DATA_URL, params={
+            'access_token': access_token
+        })
+
+
+class DatagouvfrOAuth2(UdataBaseOAuth2):
+    """Datagouvfr OAuth authentication backend."""
+    name = 'datagouv'
+    ACCESS_TOKEN_URL = 'https://www.data.gouv.fr/oauth/token'
+    AUTHORIZATION_URL = 'https://www.data.gouv.fr/oauth/authorize'
+    USER_DATA_URL = 'https://www.data.gouv.fr/api/1/me/'
diff --git a/rootfs/api/apps_extra/social_core/backends/universe.py b/rootfs/api/apps_extra/social_core/backends/universe.py
new file mode 100644
index 000000000..c67e1c969
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/universe.py
@@ -0,0 +1,35 @@
+from .oauth import BaseOAuth2
+
+
+class UniverseOAuth2(BaseOAuth2):
+    """Universe Ticketing OAuth2 authentication backend"""
+    name = 'universe'
+    AUTHORIZATION_URL = 'https://www.universe.com/oauth/authorize'
+    ACCESS_TOKEN_URL = 'https://www.universe.com/oauth/token'
+    BASE_API_URL = 'https://www.universe.com/api'
+    USER_INFO_URL = BASE_API_URL + '/v2/current_user'
+    ACCESS_TOKEN_METHOD = 'POST'
+    STATE_PARAMETER = True
+    REDIRECT_STATE = True
+    EXTRA_DATA = [
+        ('id', 'id'),
+        ('slug', 'slug'),
+        ('created_at', 'created_at'),
+        ('updated_at', 'updated_at'),
+    ]
+
+    def get_user_id(self, details, response):
+        return response['current_user'][self.ID_KEY]
+
+    def get_user_details(self, response):
+        """Return user details from a Universe account"""
+        # Start with the user data as it was returned
+        user_details = response['current_user']
+        user_details['username'] = user_details['email']
+        return user_details
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        return self.get_json(self.USER_INFO_URL, headers={
+            'Authorization': 'Bearer {}'.format(access_token)
+        })
diff --git a/rootfs/api/apps_extra/social_core/backends/untappd.py b/rootfs/api/apps_extra/social_core/backends/untappd.py
new file mode 100644
index 000000000..f5dc4e069
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/untappd.py
@@ -0,0 +1,112 @@
+import requests
+
+from .oauth import BaseOAuth2
+from ..exceptions import AuthFailed
+from ..utils import handle_http_errors
+
+
+class UntappdOAuth2(BaseOAuth2):
+    """Untappd OAuth2 authentication backend"""
+    name = 'untappd'
+    AUTHORIZATION_URL = 'https://untappd.com/oauth/authenticate/'
+    ACCESS_TOKEN_URL = 'https://untappd.com/oauth/authorize/'
+    BASE_API_URL = 'https://api.untappd.com'
+    USER_INFO_URL = BASE_API_URL + '/v4/user/info/'
+    ACCESS_TOKEN_METHOD = 'GET'
+    STATE_PARAMETER = False
+    REDIRECT_STATE = False
+    SEND_USER_AGENT = True
+    EXTRA_DATA = [
+        ('id', 'id'),
+        ('bio', 'bio'),
+        ('date_joined', 'date_joined'),
+        ('location', 'location'),
+        ('url', 'url'),
+        ('user_avatar', 'user_avatar'),
+        ('user_avatar_hd', 'user_avatar_hd'),
+        ('user_cover_photo', 'user_cover_photo')
+    ]
+
+    def auth_params(self, state=None):
+        client_id, client_secret = self.get_key_and_secret()
+        params = {
+            'client_id': client_id,
+            'redirect_url': self.get_redirect_uri(),
+            'response_type': self.RESPONSE_TYPE
+        }
+        return params
+
+    def process_error(self, data):
+        """
+        All errors from Untappd are contained in the 'meta' key of the
+        response.
+        """
+        response_code = data.get('meta', {}).get('http_code')
+        if response_code is not None and response_code != requests.codes.ok:
+            raise AuthFailed(self, data['meta']['error_detail'])
+
+    @handle_http_errors
+    def auth_complete(self, *args, **kwargs):
+        """Completes login process, must return user instance"""
+        client_id, client_secret = self.get_key_and_secret()
+        code = self.data.get('code')
+
+        self.process_error(self.data)
+
+        # Untapped sends the access token request with URL parameters,
+        # not a body
+        response = self.request_access_token(
+            self.access_token_url(),
+            method=self.ACCESS_TOKEN_METHOD,
+            params={
+                'response_type': 'code',
+                'code': code,
+                'client_id': client_id,
+                'client_secret': client_secret,
+                'redirect_url': self.get_redirect_uri()
+            }
+        )
+
+        self.process_error(response)
+
+        # Both the access_token and the rest of the response are
+        # buried in the 'response' key
+        return self.do_auth(
+            response['response']['access_token'],
+            response=response['response'],
+            *args, **kwargs
+        )
+
+    def get_user_details(self, response):
+        """Return user details from an Untappd account"""
+        # Start with the user data as it was returned
+        user_data = response['user']
+
+        # Make a few updates to match expected key names
+        user_data.update({
+            'username': user_data.get('user_name'),
+            'email': user_data.get('settings', {}).get('email_address', ''),
+            'first_name': user_data.get('first_name'),
+            'last_name': user_data.get('last_name'),
+            'fullname': ' '.join([user_data.get('first_name'),
+                                  user_data.get('last_name')])
+        })
+        return user_data
+
+    def get_user_id(self, details, response):
+        """
+        Return a unique ID for the current user, by default from
+        server response.
+        """
+        return response['user'].get(self.ID_KEY)
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        response = self.get_json(self.USER_INFO_URL, params={
+            'access_token': access_token,
+            'compact': 'true'
+        })
+        self.process_error(response)
+
+        # The response data is buried in the 'response' key
+        return response['response']
diff --git a/rootfs/api/apps_extra/social_core/backends/upwork.py b/rootfs/api/apps_extra/social_core/backends/upwork.py
new file mode 100644
index 000000000..be68d0def
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/upwork.py
@@ -0,0 +1,40 @@
+"""
+Upwork OAuth1 backend
+"""
+from .oauth import BaseOAuth1
+
+
+class UpworkOAuth(BaseOAuth1):
+    """Upwork OAuth authentication backend"""
+    name = 'upwork'
+    ID_KEY = 'id'
+    AUTHORIZATION_URL = 'https://www.upwork.com/services/api/auth'
+    REQUEST_TOKEN_URL = \
+        'https://www.upwork.com/api/auth/v1/oauth/token/request'
+    REQUEST_TOKEN_METHOD = 'POST'
+    ACCESS_TOKEN_URL = 'https://www.upwork.com/api/auth/v1/oauth/token/access'
+    ACCESS_TOKEN_METHOD = 'POST'
+    REDIRECT_URI_PARAMETER_NAME = 'oauth_callback'
+
+    def get_user_details(self, response):
+        """Return user details from Upwork account"""
+        info = response.get('info', {})
+        auth_user = response.get('auth_user', {})
+        first_name = auth_user.get('first_name')
+        last_name = auth_user.get('last_name')
+        fullname = '{} {}'.format(first_name, last_name)
+        profile_url = info.get('profile_url', '')
+        username = profile_url.rsplit('/')[-1].replace('~', '')
+        return {
+            'username': username,
+            'fullname': fullname,
+            'first_name': first_name,
+            'last_name': last_name
+        }
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        return self.get_json(
+            'https://www.upwork.com/api/auth/v1/info.json',
+            auth=self.oauth_auth(access_token)
+        )
diff --git a/rootfs/api/apps_extra/social_core/backends/username.py b/rootfs/api/apps_extra/social_core/backends/username.py
new file mode 100644
index 000000000..ef9375016
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/username.py
@@ -0,0 +1,11 @@
+"""
+Legacy Username backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/username.html
+"""
+from .legacy import LegacyAuth
+
+
+class UsernameAuth(LegacyAuth):
+    name = 'username'
+    ID_KEY = 'username'
+    EXTRA_DATA = ['username']
diff --git a/rootfs/api/apps_extra/social_core/backends/utils.py b/rootfs/api/apps_extra/social_core/backends/utils.py
new file mode 100644
index 000000000..9fbe51e02
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/utils.py
@@ -0,0 +1,82 @@
+from collections import OrderedDict
+
+from .base import BaseAuth
+from ..exceptions import MissingBackend
+from ..utils import module_member, user_is_authenticated
+
+
+# Cache for discovered backends.
+BACKENDSCACHE = OrderedDict()
+
+
+def load_backends(backends, force_load=False):
+    """
+    Load backends defined on SOCIAL_AUTH_AUTHENTICATION_BACKENDS, backends will
+    be imported and cached on BACKENDSCACHE. The key in that dict will be the
+    backend name, and the value is the backend class.
+
+    Only subclasses of BaseAuth (and sub-classes) are considered backends.
+
+    Previously there was a BACKENDS attribute expected on backends modules,
+    this is not needed anymore since it's enough with the
+    AUTHENTICATION_BACKENDS setting. BACKENDS was used because backends used to
+    be split on two classes the authentication backend and another class that
+    dealt with the auth mechanism with the provider, those classes are joined
+    now.
+
+    A force_load boolean argument is also provided so that get_backend
+    below can retry a requested backend that may not yet be discovered.
+    """
+    global BACKENDSCACHE
+    if force_load:
+        BACKENDSCACHE = OrderedDict()
+    if not BACKENDSCACHE:
+        for auth_backend in backends:
+            backend = module_member(auth_backend)
+            if issubclass(backend, BaseAuth):
+                BACKENDSCACHE[backend.name] = backend
+    return BACKENDSCACHE
+
+
+def get_backend(backends, name):
+    """Returns a backend by name. Backends are stored in the BACKENDSCACHE
+    cache dict. If not found, each of the modules referenced in
+    AUTHENTICATION_BACKENDS is imported and checked for a BACKENDS
+    definition. If the named backend is found in the module's BACKENDS
+    definition, it's then stored in the cache for future access.
+    """
+    try:
+        # Cached backend which has previously been discovered
+        return BACKENDSCACHE[name]
+    except KeyError:
+        # Reload BACKENDS to ensure a missing backend hasn't been missed
+        load_backends(backends, force_load=True)
+        try:
+            return BACKENDSCACHE[name]
+        except KeyError:
+            raise MissingBackend(name)
+
+
+def user_backends_data(user, backends, storage):
+    """
+    Will return backends data for given user, the return value will have the
+    following keys:
+        associated: UserSocialAuth model instances for currently associated
+                    accounts
+        not_associated: Not associated (yet) backend names
+        backends: All backend names.
+
+    If user is not authenticated, then 'associated' list is empty, and there's
+    no difference between 'not_associated' and 'backends'.
+    """
+    available = list(load_backends(backends).keys())
+    values = {'associated': [],
+              'not_associated': available,
+              'backends': available}
+    if user_is_authenticated(user):
+        associated = storage.user.get_social_auth_for_user(user)
+        not_associated = list(set(available) -
+                              set(assoc.provider for assoc in associated))
+        values['associated'] = associated
+        values['not_associated'] = not_associated
+    return values
diff --git a/rootfs/api/apps_extra/social_core/backends/vend.py b/rootfs/api/apps_extra/social_core/backends/vend.py
new file mode 100644
index 000000000..99e456336
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/vend.py
@@ -0,0 +1,39 @@
+"""
+Vend  OAuth2 backend:
+"""
+from .oauth import BaseOAuth2
+
+
+class VendOAuth2(BaseOAuth2):
+    name = 'vend'
+    AUTHORIZATION_URL = 'https://secure.vendhq.com/connect'
+    ACCESS_TOKEN_URL = 'https://{0}.vendhq.com/api/1.0/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    REDIRECT_STATE = False
+    EXTRA_DATA = [
+        ('refresh_token', 'refresh_token'),
+        ('domain_prefix', 'domain_prefix')
+    ]
+
+    def access_token_url(self):
+        return self.ACCESS_TOKEN_URL.format(self.data['domain_prefix'])
+
+    def get_user_details(self, response):
+        email = response['email']
+        username = response.get('username') or email.split('@', 1)[0]
+        return {
+            'username': username,
+            'email': email,
+            'fullname': '',
+            'first_name': '',
+            'last_name': ''
+        }
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        prefix = kwargs['response']['domain_prefix']
+        url = 'https://{0}.vendhq.com/api/users'.format(prefix)
+        data = self.get_json(url, headers={
+            'Authorization': 'Bearer {0}'.format(access_token)
+        })
+        return data['users'][0] if data.get('users') else {}
diff --git a/rootfs/api/apps_extra/social_core/backends/vimeo.py b/rootfs/api/apps_extra/social_core/backends/vimeo.py
new file mode 100644
index 000000000..cc340841d
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/vimeo.py
@@ -0,0 +1,79 @@
+from .oauth import BaseOAuth1, BaseOAuth2
+
+
+class VimeoOAuth1(BaseOAuth1):
+    """Vimeo OAuth authentication backend"""
+    name = 'vimeo'
+    AUTHORIZATION_URL = 'https://vimeo.com/oauth/authorize'
+    REQUEST_TOKEN_URL = 'https://vimeo.com/oauth/request_token'
+    ACCESS_TOKEN_URL = 'https://vimeo.com/oauth/access_token'
+
+    def get_user_id(self, details, response):
+        return response.get('person', {}).get('id')
+
+    def get_user_details(self, response):
+        """Return user details from Twitter account"""
+        person = response.get('person', {})
+        fullname, first_name, last_name = self.get_user_names(
+            person.get('display_name', '')
+        )
+        return {'username': person.get('username', ''),
+                'email': '',
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Return user data provided"""
+        return self.get_json(
+            'https://vimeo.com/api/rest/v2',
+            params={'format': 'json', 'method': 'vimeo.people.getInfo'},
+            auth=self.oauth_auth(access_token)
+        )
+
+
+class VimeoOAuth2(BaseOAuth2):
+    """Vimeo OAuth2 authentication backend"""
+    name = 'vimeo-oauth2'
+    AUTHORIZATION_URL = 'https://api.vimeo.com/oauth/authorize'
+    ACCESS_TOKEN_URL = 'https://api.vimeo.com/oauth/access_token'
+    REFRESH_TOKEN_URL = 'https://api.vimeo.com/oauth/request_token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    SCOPE_SEPARATOR = ','
+    API_ACCEPT_HEADER = {'Accept': 'application/vnd.vimeo.*+json;version=3.0'}
+
+    def get_redirect_uri(self, state=None):
+        """
+        Build redirect with redirect_state parameter.
+
+        @Vimeo API 3 requires exact redirect uri without additional
+        additional state parameter included
+        """
+        return self.redirect_uri
+
+    def get_user_id(self, details, response):
+        """Return user id"""
+        try:
+            user_id = response.get('user', {})['uri'].split('/')[-1]
+        except KeyError:
+            user_id = None
+        return user_id
+
+    def get_user_details(self, response):
+        """Return user details from account"""
+        user = response.get('user', {})
+        fullname, first_name, last_name = self.get_user_names(
+            user.get('name', '')
+        )
+        return {'username': fullname,
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Return user data provided"""
+        return self.get_json(
+            'https://api.vimeo.com/me',
+            params={'access_token': access_token},
+            headers=VimeoOAuth2.API_ACCEPT_HEADER,
+        )
diff --git a/rootfs/api/apps_extra/social_core/backends/vk.py b/rootfs/api/apps_extra/social_core/backends/vk.py
new file mode 100644
index 000000000..20296b8cf
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/vk.py
@@ -0,0 +1,201 @@
+# -*- coding: utf-8 -*-
+"""
+VK.com OpenAPI, OAuth2 and Iframe application OAuth2 backends, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/vk.html
+"""
+import json
+from time import time
+from hashlib import md5
+
+from ..utils import parse_qs
+from .base import BaseAuth
+from .oauth import BaseOAuth2
+from ..exceptions import AuthTokenRevoked, AuthException
+
+
+class VKontakteOpenAPI(BaseAuth):
+    """VK.COM OpenAPI authentication backend"""
+    name = 'vk-openapi'
+    ID_KEY = 'id'
+
+    def get_user_details(self, response):
+        """Return user details from VK.com request"""
+        nickname = response.get('nickname') or ''
+        fullname, first_name, last_name = self.get_user_names(
+            first_name=response.get('first_name', [''])[0],
+            last_name=response.get('last_name', [''])[0]
+        )
+        return {
+            'username': response['id'] if len(nickname) == 0 else nickname,
+            'email': '',
+            'fullname': fullname,
+            'first_name': first_name,
+            'last_name': last_name
+        }
+
+    def user_data(self, access_token, *args, **kwargs):
+        return self.data
+
+    def auth_html(self):
+        """Returns local VK authentication page, not necessary for
+        VK to authenticate.
+        """
+        ctx = {'VK_APP_ID': self.setting('APP_ID'),
+               'VK_COMPLETE_URL': self.redirect_uri}
+        local_html = self.setting('LOCAL_HTML', 'vkontakte.html')
+        return self.strategy.render_html(tpl=local_html, context=ctx)
+
+    def auth_complete(self, *args, **kwargs):
+        """Performs check of authentication in VKontakte, returns User if
+        succeeded"""
+        session_value = self.strategy.session_get(
+            'vk_app_' + self.setting('APP_ID')
+        )
+        if 'id' not in self.data or not session_value:
+            raise ValueError('VK.com authentication is not completed')
+
+        mapping = parse_qs(session_value)
+        check_str = ''.join(item + '=' + mapping[item]
+                            for item in ['expire', 'mid', 'secret', 'sid'])
+
+        key, secret = self.get_key_and_secret()
+        hash = md5((check_str + secret).encode('utf-8')).hexdigest()
+        if hash != mapping['sig'] or int(mapping['expire']) < time():
+            raise ValueError('VK.com authentication failed: Invalid Hash')
+
+        kwargs.update({'backend': self,
+                       'response': self.user_data(mapping['mid'])})
+        return self.strategy.authenticate(*args, **kwargs)
+
+    def uses_redirect(self):
+        """VK.com does not require visiting server url in order
+        to do authentication, so auth_xxx methods are not needed to be called.
+        Their current implementation is just an example"""
+        return False
+
+
+class VKOAuth2(BaseOAuth2):
+    """VKOAuth2 authentication backend"""
+    name = 'vk-oauth2'
+    ID_KEY = 'id'
+    AUTHORIZATION_URL = 'http://oauth.vk.com/authorize'
+    ACCESS_TOKEN_URL = 'https://oauth.vk.com/access_token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    EXTRA_DATA = [
+        ('id', 'id'),
+        ('expires_in', 'expires')
+    ]
+
+    def get_user_details(self, response):
+        """Return user details from VK.com account"""
+        fullname, first_name, last_name = self.get_user_names(
+            first_name=response.get('first_name'),
+            last_name=response.get('last_name')
+        )
+        return {'username': response.get('screen_name'),
+                'email': response.get('email', ''),
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        request_data = ['first_name', 'last_name', 'screen_name', 'nickname',
+                        'photo'] + self.setting('EXTRA_DATA', [])
+
+        fields = ','.join(set(request_data))
+        data = vk_api(self, 'users.get', {
+            'access_token': access_token,
+            'fields': fields,
+        })
+
+        if data and data.get('error'):
+            error = data['error']
+            msg = error.get('error_msg', 'Unknown error')
+            if error.get('error_code') == 5:
+                raise AuthTokenRevoked(self, msg)
+            else:
+                raise AuthException(self, msg)
+
+        if data:
+            data = data.get('response')[0]
+            data['user_photo'] = data.get('photo')  # Backward compatibility
+        return data or {}
+
+
+class VKAppOAuth2(VKOAuth2):
+    """VK.com Application Authentication support"""
+    name = 'vk-app'
+
+    def auth_complete(self, *args, **kwargs):
+        required_params = ('is_app_user', 'viewer_id', 'access_token',
+                           'api_id')
+        if not all(param in self.data for param in required_params):
+            return None
+
+        auth_key = self.data.get('auth_key')
+
+        # Verify signature, if present
+        key, secret = self.get_key_and_secret()
+        if auth_key:
+            check_key = md5('_'.join([key,
+                                      self.data.get('viewer_id'),
+                                      secret]).encode('utf-8')).hexdigest()
+            if check_key != auth_key:
+                raise ValueError('VK.com authentication failed: invalid '
+                                 'auth key')
+
+        user_check = self.setting('USERMODE')
+        user_id = self.data.get('viewer_id')
+        if user_check is not None:
+            user_check = int(user_check)
+            if user_check == 1:
+                is_user = self.data.get('is_app_user')
+            elif user_check == 2:
+                is_user = vk_api(
+                    self,
+                    'isAppUser',
+                    {'user_id': user_id}
+                ).get('response', 0)
+            if not int(is_user):
+                return None
+
+        auth_data = {
+            'auth': self,
+            'backend': self,
+            'request': self.strategy.request_data(),
+            'response': {
+                self.ID_KEY: user_id,
+            }
+        }
+        auth_data['response'].update(json.loads(auth_data['request']['api_result'])['response'][0])
+        return self.strategy.authenticate(*args, **auth_data)
+
+
+def vk_api(backend, method, data):
+    """
+    Calls VK.com OpenAPI method, check:
+        https://vk.com/apiclub
+        http://goo.gl/yLcaa
+    """
+    # We need to perform server-side call if no access_token
+    data['v'] = backend.setting('API_VERSION', '5.53')
+    if 'access_token' not in data:
+        key, secret = backend.get_key_and_secret()
+        if 'api_id' not in data:
+            data['api_id'] = key
+
+        data['method'] = method
+        data['format'] = 'json'
+        url = 'http://api.vk.com/api.php'
+        param_list = sorted(list(item + '=' + data[item] for item in data))
+        data['sig'] = md5(
+            (''.join(param_list) + secret).encode('utf-8')
+        ).hexdigest()
+    else:
+        url = 'https://api.vk.com/method/' + method
+
+    try:
+        return backend.get_json(url, params=data)
+    except (TypeError, KeyError, IOError, ValueError, IndexError):
+        return None
diff --git a/rootfs/api/apps_extra/social_core/backends/weibo.py b/rootfs/api/apps_extra/social_core/backends/weibo.py
new file mode 100644
index 000000000..cb5dc908b
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/weibo.py
@@ -0,0 +1,61 @@
+# coding:utf-8
+# author:hepochen@gmail.com  https://github.com/hepochen
+"""
+Weibo OAuth2 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/weibo.html
+"""
+from .oauth import BaseOAuth2
+
+
+class WeiboOAuth2(BaseOAuth2):
+    """Weibo (of sina) OAuth authentication backend"""
+    name = 'weibo'
+    ID_KEY = 'uid'
+    AUTHORIZATION_URL = 'https://api.weibo.com/oauth2/authorize'
+    REQUEST_TOKEN_URL = 'https://api.weibo.com/oauth2/request_token'
+    ACCESS_TOKEN_URL = 'https://api.weibo.com/oauth2/access_token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    REDIRECT_STATE = False
+    EXTRA_DATA = [
+        ('id', 'id'),
+        ('name', 'username'),
+        ('profile_image_url', 'profile_image_url'),
+        ('gender', 'gender')
+    ]
+
+    def get_user_details(self, response):
+        """Return user details from Weibo. API URL is:
+        https://api.weibo.com/2/users/show.json/?uid=<UID>&access_token=<TOKEN>
+        """
+        if self.setting('DOMAIN_AS_USERNAME'):
+            username = response.get('domain', '')
+        else:
+            username = response.get('name', '')
+        fullname, first_name, last_name = self.get_user_names(
+            first_name=response.get('screen_name', '')
+        )
+        return {'username': username,
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
+
+    def get_uid(self, access_token):
+        """Return uid by access_token"""
+        data = self.get_json(
+            'https://api.weibo.com/oauth2/get_token_info',
+            method='POST',
+            params={'access_token': access_token}
+        )
+        return data['uid']
+
+    def user_data(self, access_token, response=None, *args, **kwargs):
+        """Return user data"""
+        # If user id was not retrieved in the response, then get it directly
+        # from weibo get_token_info endpoint
+        uid = response and response.get('uid') or self.get_uid(access_token)
+        user_data = self.get_json(
+            'https://api.weibo.com/2/users/show.json',
+            params={'access_token': access_token, 'uid': uid}
+        )
+        user_data['uid'] = uid
+        return user_data
diff --git a/rootfs/api/apps_extra/social_core/backends/weixin.py b/rootfs/api/apps_extra/social_core/backends/weixin.py
new file mode 100644
index 000000000..f68beb56b
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/weixin.py
@@ -0,0 +1,178 @@
+# -*- coding: utf-8 -*-
+# author:duoduo3369@gmail.com  https://github.com/duoduo369
+"""
+Weixin OAuth2 backend
+"""
+from urllib.parse import urlencode
+from requests import HTTPError
+
+from .oauth import BaseOAuth2
+from ..exceptions import AuthCanceled, AuthUnknownError
+
+
+class WeixinOAuth2(BaseOAuth2):
+    """Weixin OAuth authentication backend"""
+    name = 'weixin'
+    ID_KEY = 'openid'
+    AUTHORIZATION_URL = 'https://open.weixin.qq.com/connect/qrconnect'
+    ACCESS_TOKEN_URL = 'https://api.weixin.qq.com/sns/oauth2/access_token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    DEFAULT_SCOPE = ['snsapi_login']
+    REDIRECT_STATE = False
+    EXTRA_DATA = [
+        ('nickname', 'username'),
+        ('headimgurl', 'profile_image_url'),
+    ]
+
+    def get_user_details(self, response):
+        """Return user details from Weixin. API URL is:
+        https://api.weixin.qq.com/sns/userinfo
+        """
+        if self.setting('DOMAIN_AS_USERNAME'):
+            username = response.get('domain', '')
+        else:
+            username = response.get('nickname', '')
+        return {
+            'username': username,
+            'profile_image_url': response.get('headimgurl', '')
+        }
+
+    def user_data(self, access_token, *args, **kwargs):
+        data = self.get_json('https://api.weixin.qq.com/sns/userinfo', params={
+            'access_token': access_token,
+            'openid': kwargs['response']['openid']
+        })
+        nickname = data.get('nickname')
+        if nickname:
+            # weixin api has some encode bug, here need handle
+            data['nickname'] = nickname.encode(
+                'raw_unicode_escape'
+            ).decode('utf-8')
+        return data
+
+    def auth_params(self, state=None):
+        appid, secret = self.get_key_and_secret()
+        params = {
+            'appid': appid,
+            'redirect_uri': self.get_redirect_uri(state)
+        }
+        if self.STATE_PARAMETER and state:
+            params['state'] = state
+        if self.RESPONSE_TYPE:
+            params['response_type'] = self.RESPONSE_TYPE
+        return params
+
+    def auth_complete_params(self, state=None):
+        appid, secret = self.get_key_and_secret()
+        return {
+            'grant_type': 'authorization_code',  # request auth code
+            'code': self.data.get('code', ''),  # server response code
+            'appid': appid,
+            'secret': secret,
+            'redirect_uri': self.get_redirect_uri(state)
+        }
+
+    def refresh_token_params(self, token, *args, **kwargs):
+        appid, secret = self.get_key_and_secret()
+        return {
+            'refresh_token': token,
+            'grant_type': 'refresh_token',
+            'appid': appid,
+            'secret': secret
+        }
+
+    def auth_complete(self, *args, **kwargs):
+        """Completes login process, must return user instance"""
+        self.process_error(self.data)
+        try:
+            response = self.request_access_token(
+                self.ACCESS_TOKEN_URL,
+                data=self.auth_complete_params(self.validate_state()),
+                headers=self.auth_headers(),
+                method=self.ACCESS_TOKEN_METHOD
+            )
+        except HTTPError as err:
+            if err.response.status_code == 400:
+                raise AuthCanceled(self, response=err.response)
+            else:
+                raise
+        except KeyError:
+            raise AuthUnknownError(self)
+        if 'errcode' in response:
+            raise AuthCanceled(self)
+        self.process_error(response)
+        return self.do_auth(response['access_token'], response=response,
+                            *args, **kwargs)
+
+
+class WeixinOAuth2APP(WeixinOAuth2):
+    """
+    Weixin OAuth authentication backend
+
+    Can't use in web, only in weixin app
+    """
+    name = 'weixinapp'
+    ID_KEY = 'openid'
+    AUTHORIZATION_URL = 'https://open.weixin.qq.com/connect/oauth2/authorize'
+    ACCESS_TOKEN_URL = 'https://api.weixin.qq.com/sns/oauth2/access_token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    REDIRECT_STATE = False
+
+    def auth_url(self):
+        if self.STATE_PARAMETER or self.REDIRECT_STATE:
+            # Store state in session for further request validation. The state
+            # value is passed as state parameter (as specified in OAuth2 spec),
+            # but also added to redirect, that way we can still verify the
+            # request if the provider doesn't implement the state parameter.
+            # Reuse token if any.
+            name = self.name + '_state'
+            state = self.strategy.session_get(name)
+            if state is None:
+                state = self.state_token()
+                self.strategy.session_set(name, state)
+        else:
+            state = None
+
+        params = self.auth_params(state)
+        params.update(self.get_scope_argument())
+        params.update(self.auth_extra_arguments())
+        params = urlencode(sorted(params.items()))
+        return '{}#wechat_redirect'.format(
+            self.AUTHORIZATION_URL + '?' + params
+        )
+
+    def auth_complete_params(self, state=None):
+        appid, secret = self.get_key_and_secret()
+        return {
+            'grant_type': 'authorization_code',  # request auth code
+            'code': self.data.get('code', ''),  # server response code
+            'appid': appid,
+            'secret': secret,
+        }
+
+    def validate_state(self):
+        return None
+
+    def auth_complete(self, *args, **kwargs):
+        """Completes login process, must return user instance"""
+        self.process_error(self.data)
+        try:
+            response = self.request_access_token(
+                self.ACCESS_TOKEN_URL,
+                data=self.auth_complete_params(self.validate_state()),
+                headers=self.auth_headers(),
+                method=self.ACCESS_TOKEN_METHOD
+            )
+        except HTTPError as err:
+            if err.response.status_code == 400:
+                raise AuthCanceled(self)
+            else:
+                raise
+        except KeyError:
+            raise AuthUnknownError(self)
+
+        if 'errcode' in response:
+            raise AuthCanceled(self)
+        self.process_error(response)
+        return self.do_auth(response['access_token'], response=response,
+                            *args, **kwargs)
diff --git a/rootfs/api/apps_extra/social_core/backends/withings.py b/rootfs/api/apps_extra/social_core/backends/withings.py
new file mode 100644
index 000000000..6453d0854
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/withings.py
@@ -0,0 +1,14 @@
+from .oauth import BaseOAuth1
+
+
+class WithingsOAuth(BaseOAuth1):
+    name = 'withings'
+    AUTHORIZATION_URL = 'https://developer.health.nokia.com/account/authorize'
+    REQUEST_TOKEN_URL = 'https://developer.health.nokia.com/account/request_token'
+    ACCESS_TOKEN_URL = 'https://developer.health.nokia.com/account/access_token'
+    ID_KEY = 'userid'
+
+    def get_user_details(self, response):
+        """Return user details from Withings account"""
+        return {'userid': response['access_token']['userid'],
+                'email': ''}
diff --git a/rootfs/api/apps_extra/social_core/backends/wunderlist.py b/rootfs/api/apps_extra/social_core/backends/wunderlist.py
new file mode 100644
index 000000000..e25775ef9
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/wunderlist.py
@@ -0,0 +1,29 @@
+from .oauth import BaseOAuth2
+
+
+class WunderlistOAuth2(BaseOAuth2):
+    """Wunderlist OAuth2 authentication backend"""
+    name = 'wunderlist'
+    AUTHORIZATION_URL = 'https://www.wunderlist.com/oauth/authorize'
+    ACCESS_TOKEN_URL = 'https://www.wunderlist.com/oauth/access_token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    REDIRECT_STATE = False
+
+    def get_user_details(self, response):
+        """Return user details from Wunderlist account"""
+        fullname, first_name, last_name = self.get_user_names(
+            response.get('name')
+        )
+        return {'username': str(response.get('id')),
+                'email': response.get('email'),
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        headers = {
+            'X-Access-Token': access_token,
+            'X-Client-ID': self.setting('KEY')}
+        return self.get_json(
+            'https://a.wunderlist.com/api/v1/user', headers=headers)
diff --git a/rootfs/api/apps_extra/social_core/backends/xing.py b/rootfs/api/apps_extra/social_core/backends/xing.py
new file mode 100644
index 000000000..4a5d24066
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/xing.py
@@ -0,0 +1,64 @@
+"""
+XING OAuth1 backend, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/xing.html
+"""
+from requests_oauthlib import OAuth1
+from oauthlib.oauth1 import SIGNATURE_TYPE_AUTH_HEADER
+
+from .oauth import BaseOAuth1
+from ..exceptions import AuthTokenError
+
+
+class XingOAuth(BaseOAuth1):
+    """Xing OAuth authentication backend"""
+    name = 'xing'
+    AUTHORIZATION_URL = 'https://api.xing.com/v1/authorize'
+    REQUEST_TOKEN_URL = 'https://api.xing.com/v1/request_token'
+    ACCESS_TOKEN_URL = 'https://api.xing.com/v1/access_token'
+    SCOPE_SEPARATOR = '+'
+    EXTRA_DATA = [
+        ('id', 'id'),
+        ('user_id', 'user_id')
+    ]
+
+    def get_user_details(self, response):
+        """Return user details from Xing account"""
+        email = response.get('email', '')
+        fullname, first_name, last_name = self.get_user_names(
+            first_name=response['first_name'],
+            last_name=response['last_name']
+        )
+        return {'username': first_name + last_name,
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name,
+                'email': email}
+
+    def clean_oauth_auth(self, access_token):
+        """Override of oauth_auth since Xing doesn't like callback_uri
+        and oauth_verifier on authenticated API calls"""
+        key, secret = self.get_key_and_secret()
+        resource_owner_key = access_token.get('oauth_token')
+        resource_owner_secret = access_token.get('oauth_token_secret')
+        if not resource_owner_key:
+            raise AuthTokenError(self, 'Missing oauth_token')
+        if not resource_owner_secret:
+            raise AuthTokenError(self, 'Missing oauth_token_secret')
+        return OAuth1(key, secret,
+                      resource_owner_key=resource_owner_key,
+                      resource_owner_secret=resource_owner_secret,
+                      signature_type=SIGNATURE_TYPE_AUTH_HEADER)
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Return user data provided"""
+        profile = self.get_json(
+            'https://api.xing.com/v1/users/me.json',
+            auth=self.clean_oauth_auth(access_token)
+        )['users'][0]
+        return {
+            'user_id': profile['id'],
+            'id': profile['id'],
+            'first_name': profile['first_name'],
+            'last_name': profile['last_name'],
+            'email': profile['active_email']
+        }
diff --git a/rootfs/api/apps_extra/social_core/backends/yahoo.py b/rootfs/api/apps_extra/social_core/backends/yahoo.py
new file mode 100644
index 000000000..d6ff380e5
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/yahoo.py
@@ -0,0 +1,152 @@
+"""
+Yahoo OpenId, OAuth1 and OAuth2 backends, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/yahoo.html
+"""
+from requests.auth import HTTPBasicAuth
+
+from ..utils import handle_http_errors
+from .oauth import BaseOAuth2, BaseOAuth1
+
+
+class YahooOAuth(BaseOAuth1):
+    """Yahoo OAuth authentication backend. DEPRECATED"""
+    name = 'yahoo-oauth'
+    ID_KEY = 'guid'
+    AUTHORIZATION_URL = 'https://api.login.yahoo.com/oauth/v2/request_auth'
+    REQUEST_TOKEN_URL = \
+        'https://api.login.yahoo.com/oauth/v2/get_request_token'
+    ACCESS_TOKEN_URL = 'https://api.login.yahoo.com/oauth/v2/get_token'
+    EXTRA_DATA = [
+        ('guid', 'id'),
+        ('access_token', 'access_token'),
+        ('expires', 'expires')
+    ]
+
+    def get_user_details(self, response):
+        """Return user details from Yahoo Profile"""
+        fullname, first_name, last_name = self.get_user_names(
+            first_name=response.get('givenName'),
+            last_name=response.get('familyName')
+        )
+        emails = [email for email in response.get('emails', [])
+                  if email.get('handle')]
+        emails.sort(key=lambda e: e.get('primary', False), reverse=True)
+        return {'username': response.get('nickname'),
+                'email': emails[0]['handle'] if emails else '',
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        url = 'https://social.yahooapis.com/v1/user/{0}/profile?format=json'
+        return self.get_json(
+            url.format(self._get_guid(access_token)),
+            auth=self.oauth_auth(access_token)
+        )['profile']
+
+    def _get_guid(self, access_token):
+        """
+        Beause you have to provide GUID for every API request it's also
+        returned during one of OAuth calls
+        """
+        return self.get_json(
+            'https://social.yahooapis.com/v1/me/guid?format=json',
+            auth=self.oauth_auth(access_token)
+        )['guid']['value']
+
+
+class YahooOAuth2(BaseOAuth2):
+    """Yahoo OAuth2 authentication backend"""
+    name = 'yahoo-oauth2'
+    ID_KEY = 'sub'
+    AUTHORIZATION_URL = 'https://api.login.yahoo.com/oauth2/request_auth'
+    ACCESS_TOKEN_URL = 'https://api.login.yahoo.com/oauth2/get_token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    EXTRA_DATA = [
+        ('sub', 'id'),
+        ('access_token', 'access_token'),
+        ('expires_in', 'expires'),
+        ('refresh_token', 'refresh_token'),
+        ('token_type', 'token_type'),
+    ]
+
+    def get_user_names(self, first_name, last_name):
+        if first_name or last_name:
+            return ' '.join((first_name, last_name)), first_name, last_name
+        return None, None, None
+
+    def get_user_details(self, response):
+        """
+        Return user details from Yahoo Profile.
+        To Get user email you need the profile private read permission.
+        """
+
+        fullname, first_name, last_name = self.get_user_names(
+            first_name=response.get('given_name'),
+            last_name=response.get('family_name')
+        )
+
+        email = response.get('email')
+        return {
+            'username': response.get('nickname'),
+            'email': email,
+            'fullname': fullname,
+            'first_name': first_name,
+            'last_name': last_name
+        }
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+
+        url = 'https://api.login.yahoo.com/openid/v1/userinfo'
+
+        return self.get_json(url, headers={
+            'Authorization': 'Bearer {0}'.format(access_token)
+        }, method='GET')
+
+    @handle_http_errors
+    def auth_complete(self, *args, **kwargs):
+        """Completes login process, must return user instance"""
+        self.process_error(self.data)
+        response = self.request_access_token(
+            self.ACCESS_TOKEN_URL,
+            auth=HTTPBasicAuth(*self.get_key_and_secret()),
+            data=self.auth_complete_params(self.validate_state()),
+            headers=self.auth_headers(),
+            method=self.ACCESS_TOKEN_METHOD
+        )
+        self.process_error(response)
+        return self.do_auth(response['access_token'], response=response,
+                            *args, **kwargs)
+
+    def refresh_token_params(self, token, *args, **kwargs):
+        return {
+            'refresh_token': token,
+            'grant_type': 'refresh_token',
+            'redirect_uri': 'oob',  # out of bounds
+        }
+
+    def refresh_token(self, token, *args, **kwargs):
+        params = self.refresh_token_params(token, *args, **kwargs)
+        url = self.REFRESH_TOKEN_URL or self.ACCESS_TOKEN_URL
+        method = self.REFRESH_TOKEN_METHOD
+        key = 'params' if method == 'GET' else 'data'
+        request_args = {
+            'headers': self.auth_headers(),
+            'method': method,
+            key: params
+        }
+        request = self.request(
+            url,
+            auth=HTTPBasicAuth(*self.get_key_and_secret()),
+            **request_args
+        )
+        return self.process_refresh_token_response(request, *args, **kwargs)
+
+    def auth_complete_params(self, state=None):
+        return {
+            'grant_type': 'authorization_code',  # request auth code
+            'code': self.data.get('code', ''),  # server response code
+            'redirect_uri': self.get_redirect_uri(state)
+        }
diff --git a/rootfs/api/apps_extra/social_core/backends/yammer.py b/rootfs/api/apps_extra/social_core/backends/yammer.py
new file mode 100644
index 000000000..859d513ef
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/yammer.py
@@ -0,0 +1,44 @@
+"""
+Yammer OAuth2 production and staging backends, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/yammer.html
+"""
+from .oauth import BaseOAuth2
+
+
+class YammerOAuth2(BaseOAuth2):
+    name = 'yammer'
+    AUTHORIZATION_URL = 'https://www.yammer.com/dialog/oauth'
+    ACCESS_TOKEN_URL = 'https://www.yammer.com/oauth2/access_token'
+    EXTRA_DATA = [
+        ('id', 'id'),
+        ('expires', 'expires'),
+        ('mugshot_url', 'mugshot_url')
+    ]
+
+    def get_user_id(self, details, response):
+        return response['user']['id']
+
+    def get_user_details(self, response):
+        username = response['user']['name']
+        fullname, first_name, last_name = self.get_user_names(
+            fullname=response['user']['full_name'],
+            first_name=response['user']['first_name'],
+            last_name=response['user']['last_name']
+        )
+        email = response['user']['contact']['email_addresses'][0]['address']
+        mugshot_url = response['user']['mugshot_url']
+        return {
+            'username': username,
+            'email': email,
+            'fullname': fullname,
+            'first_name': first_name,
+            'last_name': last_name,
+            'picture_url': mugshot_url
+        }
+
+
+class YammerStagingOAuth2(YammerOAuth2):
+    name = 'yammer-staging'
+    AUTHORIZATION_URL = 'https://www.staging.yammer.com/dialog/oauth'
+    ACCESS_TOKEN_URL = 'https://www.staging.yammer.com/oauth2/access_token'
+    REQUEST_TOKEN_URL = 'https://www.staging.yammer.com/oauth2/request_token'
diff --git a/rootfs/api/apps_extra/social_core/backends/yandex.py b/rootfs/api/apps_extra/social_core/backends/yandex.py
new file mode 100644
index 000000000..b3e9e9ea5
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/yandex.py
@@ -0,0 +1,84 @@
+"""
+Yandex OpenID and OAuth2 support.
+
+This contribution adds support for Yandex.ru OpenID service in the form
+openid.yandex.ru/user. Username is retrieved from the identity url.
+
+If username is not specified, OpenID 2.0 url used for authentication.
+"""
+from urllib.parse import urlsplit
+
+from .open_id import OpenIdAuth
+from .oauth import BaseOAuth2
+
+
+class YandexOpenId(OpenIdAuth):
+    """Yandex OpenID authentication backend"""
+    name = 'yandex-openid'
+    URL = 'http://openid.yandex.ru'
+
+    def get_user_id(self, details, response):
+        return details['email'] or response.identity_url
+
+    def get_user_details(self, response):
+        """Generate username from identity url"""
+        values = super().get_user_details(response)
+        values['username'] = values.get('username') or\
+            urlsplit(response.identity_url).path.strip('/')
+        values['email'] = values.get('email', '')
+        return values
+
+
+class YandexOAuth2(BaseOAuth2):
+    """Legacy Yandex OAuth2 authentication backend"""
+    name = 'yandex-oauth2'
+    AUTHORIZATION_URL = 'https://oauth.yandex.com/authorize'
+    ACCESS_TOKEN_URL = 'https://oauth.yandex.com/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    REDIRECT_STATE = False
+
+    def get_user_details(self, response):
+        fullname, first_name, last_name = self.get_user_names(
+            response.get('real_name') or response.get('display_name') or ''
+        )
+        email = response.get('default_email')
+        if not email:
+            emails = response.get('emails')
+            email = emails[0] if emails else ''
+        return {'username': response.get('display_name'),
+                'email': email,
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
+
+    def user_data(self, access_token, *args, **kwargs):
+        return self.get_json('https://login.yandex.ru/info',
+                             params={'oauth_token': access_token,
+                                     'format': 'json'})
+
+
+class YaruOAuth2(BaseOAuth2):
+    name = 'yaru'
+    AUTHORIZATION_URL = 'https://oauth.yandex.com/authorize'
+    ACCESS_TOKEN_URL = 'https://oauth.yandex.com/token'
+    ACCESS_TOKEN_METHOD = 'POST'
+    REDIRECT_STATE = False
+
+    def get_user_details(self, response):
+        fullname, first_name, last_name = self.get_user_names(
+            response.get('real_name') or response.get('display_name') or ''
+        )
+        email = response.get('default_email')
+        if not email:
+            emails = response.get('emails')
+            email = emails[0] if emails else ''
+        return {'username': response.get('display_name'),
+                'email': email,
+                'fullname': fullname,
+                'first_name': first_name,
+                'last_name': last_name}
+
+    def user_data(self, access_token, *args, **kwargs):
+        return self.get_json('https://login.yandex.ru/info',
+                             params={'oauth_token': access_token,
+                                     'format': 'json'})
diff --git a/rootfs/api/apps_extra/social_core/backends/zoom.py b/rootfs/api/apps_extra/social_core/backends/zoom.py
new file mode 100644
index 000000000..3c68a7257
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/zoom.py
@@ -0,0 +1,62 @@
+import base64
+
+from .oauth import BaseOAuth2
+
+
+class ZoomOAuth2(BaseOAuth2):
+    """
+    Zoom OAuth2 authentication backend
+    Doc Reference: https://marketplace.zoom.us/docs/guides/auth/oauth
+    """
+    name = 'zoom-oauth2'
+    AUTHORIZATION_URL = 'https://zoom.us/oauth/authorize'
+    ACCESS_TOKEN_URL = 'https://zoom.us/oauth/token'
+    USER_DETAILS_URL = 'https://api.zoom.us/v2/users/me'
+    DEFAULT_SCOPE = ['user:read']
+    ACCESS_TOKEN_METHOD = 'POST'
+    REFRESH_TOKEN_METHOD = 'POST'
+    REDIRECT_STATE = False
+    EXTRA_DATA = [
+        ('expires_in', 'expires')
+    ]
+
+    def user_data(self, access_token, *args, **kwargs):
+        response = self.get_json(
+            self.USER_DETAILS_URL, headers={
+                'Authorization': 'Bearer {access_token}'.format(
+                    access_token=access_token
+                )
+            }
+        )
+        return response
+
+    def get_user_details(self, response):
+        username = response.get('id', '')
+        first_name = response.get('first_name', '')
+        last_name = response.get('last_name', '')
+        email = response.get('email', '')
+        fullname = ''
+        return {
+            'username': username,
+            'email': email,
+            'fullname': fullname,
+            'first_name': first_name,
+            'last_name': last_name,
+        }
+
+    def auth_complete_params(self, state=None):
+        return {
+            'grant_type': 'authorization_code',  # request auth code
+            'code': self.data.get('code', ''),  # server response code
+            'redirect_uri': self.get_redirect_uri(state),
+        }
+
+    def auth_headers(self):
+        return {
+            'Authorization': b'Basic ' + base64.urlsafe_b64encode(
+                '{0}:{1}'.format(*self.get_key_and_secret()).encode()
+            )
+        }
+
+    def refresh_token_params(self, token, *args, **kwargs):
+        return {'refresh_token': token, 'grant_type': 'refresh_token'}
diff --git a/rootfs/api/apps_extra/social_core/backends/zotero.py b/rootfs/api/apps_extra/social_core/backends/zotero.py
new file mode 100644
index 000000000..1ee2be386
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/backends/zotero.py
@@ -0,0 +1,29 @@
+"""
+Zotero OAuth1 backends, docs at:
+    https://python-social-auth.readthedocs.io/en/latest/backends/zotero.html
+"""
+from .oauth import BaseOAuth1
+
+
+class ZoteroOAuth(BaseOAuth1):
+
+    """Zotero OAuth authorization mechanism"""
+    name = 'zotero'
+    AUTHORIZATION_URL = 'https://www.zotero.org/oauth/authorize'
+    REQUEST_TOKEN_URL = 'https://www.zotero.org/oauth/request'
+    ACCESS_TOKEN_URL = 'https://www.zotero.org/oauth/access'
+
+    def get_user_id(self, details, response):
+        """
+        Return user unique id provided by service. For Ubuntu One
+        the nickname should be original.
+        """
+        return details['userID']
+
+    def get_user_details(self, response):
+        """Return user details from Zotero API account"""
+        access_token = response.get('access_token', {})
+        return {
+            'username': access_token.get('username', ''),
+            'userID': access_token.get('userID', '')
+        }
diff --git a/rootfs/api/apps_extra/social_core/exceptions.py b/rootfs/api/apps_extra/social_core/exceptions.py
new file mode 100644
index 000000000..461fefa2c
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/exceptions.py
@@ -0,0 +1,116 @@
+class SocialAuthBaseException(ValueError):
+    """Base class for pipeline exceptions."""
+    pass
+
+
+class WrongBackend(SocialAuthBaseException):
+    def __init__(self, backend_name):
+        self.backend_name = backend_name
+
+    def __str__(self):
+        return 'Incorrect authentication service "{0}"'.format(
+            self.backend_name
+        )
+
+
+class MissingBackend(WrongBackend):
+    def __str__(self):
+        return 'Missing backend "{0}" entry'.format(self.backend_name)
+
+
+class NotAllowedToDisconnect(SocialAuthBaseException):
+    """User is not allowed to disconnect it's social account."""
+    pass
+
+
+class AuthException(SocialAuthBaseException):
+    """Auth process exception."""
+    def __init__(self, backend, *args, **kwargs):
+        self.backend = backend
+        super().__init__(*args, **kwargs)
+
+
+class AuthFailed(AuthException):
+    """Auth process failed for some reason."""
+    def __str__(self):
+        msg = super().__str__()
+        if msg == 'access_denied':
+            return 'Authentication process was canceled'
+        return 'Authentication failed: {0}'.format(msg)
+
+
+class AuthCanceled(AuthException):
+    """Auth process was canceled by user."""
+    def __init__(self, *args, **kwargs):
+        self.response = kwargs.pop('response', None)
+        super().__init__(*args, **kwargs)
+
+    def __str__(self):
+        msg = super().__str__()
+        if msg:
+            return 'Authentication process canceled: {0}'.format(msg)
+        return 'Authentication process canceled'
+
+
+class AuthUnknownError(AuthException):
+    """Unknown auth process error."""
+    def __str__(self):
+        msg = super().__str__()
+        return 'An unknown error happened while authenticating {0}'.format(msg)
+
+
+class AuthTokenError(AuthException):
+    """Auth token error."""
+    def __str__(self):
+        msg = super().__str__()
+        return 'Token error: {0}'.format(msg)
+
+
+class AuthMissingParameter(AuthException):
+    """Missing parameter needed to start or complete the process."""
+    def __init__(self, backend, parameter, *args, **kwargs):
+        self.parameter = parameter
+        super().__init__(backend, *args, **kwargs)
+
+    def __str__(self):
+        return 'Missing needed parameter {0}'.format(self.parameter)
+
+
+class AuthStateMissing(AuthException):
+    """State parameter is incorrect."""
+    def __str__(self):
+        return 'Session value state missing.'
+
+
+class AuthStateForbidden(AuthException):
+    """State parameter is incorrect."""
+    def __str__(self):
+        return 'Wrong state parameter given.'
+
+
+class AuthAlreadyAssociated(AuthException):
+    """A different user has already associated the target social account"""
+    pass
+
+
+class AuthTokenRevoked(AuthException):
+    """User revoked the access_token in the provider."""
+    def __str__(self):
+        return 'User revoke access to the token'
+
+
+class AuthForbidden(AuthException):
+    """Authentication for this user is forbidden"""
+    def __str__(self):
+        return 'Your credentials aren\'t allowed'
+
+
+class AuthUnreachableProvider(AuthException):
+    """Cannot reach the provider"""
+    def __str__(self):
+        return 'The authentication provider could not be reached'
+
+
+class InvalidEmail(AuthException):
+    def __str__(self):
+        return 'Email couldn\'t be validated'
diff --git a/rootfs/api/apps_extra/social_core/pipeline/__init__.py b/rootfs/api/apps_extra/social_core/pipeline/__init__.py
new file mode 100644
index 000000000..a8edd800e
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/pipeline/__init__.py
@@ -0,0 +1,59 @@
+DEFAULT_AUTH_PIPELINE = (
+    # Get the information we can about the user and return it in a simple
+    # format to create the user instance later. On some cases the details are
+    # already part of the auth response from the provider, but sometimes this
+    # could hit a provider API.
+    'social_core.pipeline.social_auth.social_details',
+
+    # Get the social uid from whichever service we're authing thru. The uid is
+    # the unique identifier of the given user in the provider.
+    'social_core.pipeline.social_auth.social_uid',
+
+    # Verifies that the current auth process is valid within the current
+    # project, this is where emails and domains whitelists are applied (if
+    # defined).
+    'social_core.pipeline.social_auth.auth_allowed',
+
+    # Checks if the current social-account is already associated in the site.
+    'social_core.pipeline.social_auth.social_user',
+
+    # Make up a username for this person, appends a random string at the end if
+    # there's any collision.
+    'social_core.pipeline.user.get_username',
+
+    # Send a validation email to the user to verify its email address.
+    # 'social_core.pipeline.mail.mail_validation',
+
+    # Associates the current social details with another user account with
+    # a similar email address.
+    # 'social_core.pipeline.social_auth.associate_by_email',
+
+    # Create a user account if we haven't found one yet.
+    'social_core.pipeline.user.create_user',
+
+    # Create the record that associated the social account with this user.
+    'social_core.pipeline.social_auth.associate_user',
+
+    # Populate the extra_data field in the social record with the values
+    # specified by settings (and the default ones like access_token, etc).
+    'social_core.pipeline.social_auth.load_extra_data',
+
+    # Update the user record with any changed info from the auth service.
+    'social_core.pipeline.user.user_details'
+)
+
+DEFAULT_DISCONNECT_PIPELINE = (
+    # Verifies that the social association can be disconnected from the current
+    # user (ensure that the user login mechanism is not compromised by this
+    # disconnection).
+    'social_core.pipeline.disconnect.allowed_to_disconnect',
+
+    # Collects the social associations to disconnect.
+    'social_core.pipeline.disconnect.get_entries',
+
+    # Revoke any access_token when possible.
+    'social_core.pipeline.disconnect.revoke_tokens',
+
+    # Removes the social associations.
+    'social_core.pipeline.disconnect.disconnect'
+)
diff --git a/rootfs/api/apps_extra/social_core/pipeline/debug.py b/rootfs/api/apps_extra/social_core/pipeline/debug.py
new file mode 100644
index 000000000..7ef102269
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/pipeline/debug.py
@@ -0,0 +1,13 @@
+from pprint import pprint
+
+
+def debug(response, details, *args, **kwargs):
+    print('=' * 80)
+    pprint(response)
+    print('=' * 80)
+    pprint(details)
+    print('=' * 80)
+    pprint(args)
+    print('=' * 80)
+    pprint(kwargs)
+    print('=' * 80)
diff --git a/rootfs/api/apps_extra/social_core/pipeline/disconnect.py b/rootfs/api/apps_extra/social_core/pipeline/disconnect.py
new file mode 100644
index 000000000..3450ab994
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/pipeline/disconnect.py
@@ -0,0 +1,31 @@
+from ..exceptions import NotAllowedToDisconnect
+
+
+def allowed_to_disconnect(strategy, user, name, user_storage,
+                          association_id=None, *args, **kwargs):
+    if not user_storage.allowed_to_disconnect(user, name, association_id):
+        raise NotAllowedToDisconnect()
+
+
+def get_entries(strategy, user, name, user_storage, association_id=None,
+                *args, **kwargs):
+    return {
+        'entries': user_storage.get_social_auth_for_user(
+            user, name, association_id
+        )
+    }
+
+
+def revoke_tokens(strategy, entries, *args, **kwargs):
+    revoke_tokens = strategy.setting('REVOKE_TOKENS_ON_DISCONNECT', False)
+    if revoke_tokens:
+        for entry in entries:
+            if 'access_token' in entry.extra_data:
+                backend = entry.get_backend_instance(strategy)
+                backend.revoke_token(entry.extra_data['access_token'],
+                                     entry.uid)
+
+
+def disconnect(strategy, entries, user_storage, *args, **kwargs):
+    for entry in entries:
+        user_storage.disconnect(entry)
diff --git a/rootfs/api/apps_extra/social_core/pipeline/mail.py b/rootfs/api/apps_extra/social_core/pipeline/mail.py
new file mode 100644
index 000000000..6101df09e
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/pipeline/mail.py
@@ -0,0 +1,27 @@
+from ..exceptions import InvalidEmail
+from .partial import partial
+
+
+@partial
+def mail_validation(backend, details, is_new=False, *args, **kwargs):
+    requires_validation = backend.REQUIRES_EMAIL_VALIDATION or \
+        backend.setting('FORCE_EMAIL_VALIDATION', False)
+    send_validation = details.get('email') and \
+        (is_new or backend.setting('PASSWORDLESS', False))
+    if requires_validation and send_validation:
+        data = backend.strategy.request_data()
+        if 'verification_code' in data:
+            backend.strategy.session_pop('email_validation_address')
+            if not backend.strategy.validate_email(details['email'],
+                                                   data['verification_code']):
+                raise InvalidEmail(backend)
+        else:
+            current_partial = kwargs.get('current_partial')
+            backend.strategy.send_email_validation(backend,
+                                                   details['email'],
+                                                   current_partial.token)
+            backend.strategy.session_set('email_validation_address',
+                                         details['email'])
+            return backend.strategy.redirect(
+                backend.strategy.setting('EMAIL_VALIDATION_URL')
+            )
diff --git a/rootfs/api/apps_extra/social_core/pipeline/partial.py b/rootfs/api/apps_extra/social_core/pipeline/partial.py
new file mode 100644
index 000000000..8c650dd24
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/pipeline/partial.py
@@ -0,0 +1,47 @@
+from functools import wraps
+
+from .utils import partial_prepare
+from ..utils import PARTIAL_TOKEN_SESSION_NAME
+
+
+def partial_step(save_to_session):
+    """Wraps func to behave like a partial pipeline step, any output
+    that's not None or {} will be considered a response object and
+    will be returned to user.
+
+    The pipeline function will receive a current_partial object, it
+    contains the partial pipeline data and a token that is used to
+    identify it when it's continued, this is useful to build links
+    with the token.
+
+    The default value for this parameter is partial_token, but can be
+    overridden by SOCIAL_AUTH_PARTIAL_PIPELINE_TOKEN_NAME setting.
+
+    The token is also stored in the session under the
+    PARTIAL_TOKEN_SESSION_NAME (partial_pipeline_token) key when the
+    save_to_session parameter is True.
+    """
+    def decorator(func):
+        @wraps(func)
+        def wrapper(strategy, backend, pipeline_index, *args, **kwargs):
+            current_partial = partial_prepare(strategy, backend, pipeline_index,
+                                              *args, **kwargs)
+
+            out = func(strategy=strategy,
+                       backend=backend,
+                       pipeline_index=pipeline_index,
+                       current_partial=current_partial,
+                       *args, **kwargs) or {}
+
+            if not isinstance(out, dict):
+                strategy.storage.partial.store(current_partial)
+                if save_to_session:
+                    strategy.session_set(PARTIAL_TOKEN_SESSION_NAME,
+                                         current_partial.token)
+            return out
+        return wrapper
+    return decorator
+
+
+# Backward compatible partial decorator, that stores the token in the session
+partial = partial_step(save_to_session=True)
diff --git a/rootfs/api/apps_extra/social_core/pipeline/social_auth.py b/rootfs/api/apps_extra/social_core/pipeline/social_auth.py
new file mode 100644
index 000000000..f2c2823c3
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/pipeline/social_auth.py
@@ -0,0 +1,94 @@
+from ..exceptions import AuthAlreadyAssociated, AuthException, AuthForbidden
+
+
+def social_details(backend, details, response, *args, **kwargs):
+    return {'details': dict(backend.get_user_details(response), **details)}
+
+
+def social_uid(backend, details, response, *args, **kwargs):
+    return {'uid': backend.get_user_id(details, response)}
+
+
+def auth_allowed(backend, details, response, *args, **kwargs):
+    if not backend.auth_allowed(response, details):
+        raise AuthForbidden(backend)
+
+
+def social_user(backend, uid, user=None, *args, **kwargs):
+    provider = backend.name
+    social = backend.strategy.storage.user.get_social_auth(provider, uid)
+    if social:
+        if user and social.user != user:
+            msg = 'This account is already in use.'
+            raise AuthAlreadyAssociated(backend, msg)
+        elif not user:
+            user = social.user
+    return {'social': social,
+            'user': user,
+            'is_new': user is None,
+            'new_association': social is None}
+
+
+def associate_user(backend, uid, user=None, social=None, *args, **kwargs):
+    if user and not social:
+        try:
+            social = backend.strategy.storage.user.create_social_auth(
+                user, uid, backend.name
+            )
+        except Exception as err:
+            if not backend.strategy.storage.is_integrity_error(err):
+                raise
+            # Protect for possible race condition, those bastard with FTL
+            # clicking capabilities, check issue #131:
+            #   https://github.com/omab/django-social-auth/issues/131
+            result = social_user(backend, uid, user, *args, **kwargs)
+            # Check if matching social auth really exists. In case it does
+            # not, the integrity error probably had different cause than
+            # existing entry and should not be hidden.
+            if not result['social']:
+                raise
+            return result
+        else:
+            return {'social': social,
+                    'user': social.user,
+                    'new_association': True}
+
+
+def associate_by_email(backend, details, user=None, *args, **kwargs):
+    """
+    Associate current auth with a user with the same email address in the DB.
+
+    This pipeline entry is not 100% secure unless you know that the providers
+    enabled enforce email verification on their side, otherwise a user can
+    attempt to take over another user account by using the same (not validated)
+    email address on some provider.  This pipeline entry is disabled by
+    default.
+    """
+    if user:
+        return None
+
+    email = details.get('email')
+    if email:
+        # Try to associate accounts registered with the same email address,
+        # only if it's a single object. AuthException is raised if multiple
+        # objects are returned.
+        users = list(backend.strategy.storage.user.get_users_by_email(email))
+        if len(users) == 0:
+            return None
+        elif len(users) > 1:
+            raise AuthException(
+                backend,
+                'The given email address is associated with another account'
+            )
+        else:
+            return {'user': users[0],
+                    'is_new': False}
+
+
+def load_extra_data(backend, details, response, uid, user, *args, **kwargs):
+    social = kwargs.get('social') or \
+             backend.strategy.storage.user.get_social_auth(backend.name, uid)
+    if social:
+        extra_data = backend.extra_data(user, uid, response, details,
+                                        *args, **kwargs)
+        social.set_extra_data(extra_data)
diff --git a/rootfs/api/apps_extra/social_core/pipeline/user.py b/rootfs/api/apps_extra/social_core/pipeline/user.py
new file mode 100644
index 000000000..0067afeaf
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/pipeline/user.py
@@ -0,0 +1,120 @@
+from uuid import uuid4
+
+from ..utils import slugify, module_member
+
+
+USER_FIELDS = ['username', 'email']
+
+
+def get_username(strategy, details, backend, user=None, *args, **kwargs):
+    if 'username' not in backend.setting('USER_FIELDS', USER_FIELDS):
+        return
+    storage = strategy.storage
+
+    if not user:
+        email_as_username = strategy.setting('USERNAME_IS_FULL_EMAIL', False)
+        uuid_length = strategy.setting('UUID_LENGTH', 16)
+        max_length = storage.user.username_max_length()
+        do_slugify = strategy.setting('SLUGIFY_USERNAMES', False)
+        do_clean = strategy.setting('CLEAN_USERNAMES', True)
+
+        def identity_func(val):
+            return val
+
+        if do_clean:
+            override_clean = strategy.setting('CLEAN_USERNAME_FUNCTION')
+            if override_clean:
+                clean_func = module_member(override_clean)
+            else:
+                clean_func = storage.user.clean_username
+        else:
+            clean_func = identity_func
+
+        if do_slugify:
+            override_slug = strategy.setting('SLUGIFY_FUNCTION')
+            if override_slug:
+                slug_func = module_member(override_slug)
+            else:
+                slug_func = slugify
+        else:
+            slug_func = identity_func
+
+        if email_as_username and details.get('email'):
+            username = details['email']
+        elif details.get('username'):
+            username = details['username']
+        else:
+            username = uuid4().hex
+
+        short_username = (username[:max_length - uuid_length]
+                          if max_length is not None
+                          else username)
+        final_username = slug_func(clean_func(username[:max_length]))
+
+        # Generate a unique username for current user using username
+        # as base but adding a unique hash at the end. Original
+        # username is cut to avoid any field max_length.
+        # The final_username may be empty and will skip the loop.
+        while not final_username or storage.user.user_exists(
+                username=final_username
+        ):
+            username = short_username + uuid4().hex[:uuid_length]
+            final_username = slug_func(clean_func(username[:max_length]))
+    else:
+        final_username = storage.user.get_username(user)
+    return {'username': final_username}
+
+
+def create_user(strategy, details, backend, user=None, *args, **kwargs):
+    if user:
+        print(user)
+        return {'is_new': False}
+
+    fields = dict((name, kwargs.get(name, details.get(name)))
+                  for name in backend.setting('USER_FIELDS', USER_FIELDS))
+    if not fields:
+        return
+
+    return {
+        'is_new': True,
+        'user': strategy.create_user(**fields)
+    }
+
+
+def user_details(strategy, details, backend, user=None, *args, **kwargs):
+    """Update user details using data from provider."""
+    if not user:
+        return
+
+    changed = False  # flag to track changes
+
+    # Default protected user fields (username, id, pk and email) can be ignored
+    # by setting the SOCIAL_AUTH_NO_DEFAULT_PROTECTED_USER_FIELDS to True
+    if strategy.setting('NO_DEFAULT_PROTECTED_USER_FIELDS') is True:
+        protected = ()
+    else:
+        protected = ('username', 'id', 'pk', 'email', 'password',
+                     'is_active', 'is_staff', 'is_superuser',)
+
+    protected = protected + tuple(strategy.setting('PROTECTED_USER_FIELDS', []))
+
+    # Update user model attributes with the new data sent by the current
+    # provider. Update on some attributes is disabled by default, for
+    # example username and id fields. It's also possible to disable update
+    # on fields defined in SOCIAL_AUTH_PROTECTED_USER_FIELDS.
+    field_mapping = strategy.setting('USER_FIELD_MAPPING', {}, backend)
+    for name, value in details.items():
+        # Convert to existing user field if mapping exists
+        name = field_mapping.get(name, name)
+        if value is None or not hasattr(user, name) or name in protected:
+            continue
+
+        current_value = getattr(user, name, None)
+        if current_value == value:
+            continue
+
+        changed = True
+        setattr(user, name, value)
+
+    if changed:
+        strategy.storage.user.changed(user)
diff --git a/rootfs/api/apps_extra/social_core/pipeline/utils.py b/rootfs/api/apps_extra/social_core/pipeline/utils.py
new file mode 100644
index 000000000..112aed458
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/pipeline/utils.py
@@ -0,0 +1,68 @@
+SERIALIZABLE_TYPES = (dict, list, tuple, set, bool, type(None), int, str, bytes)
+
+
+def is_dict_type(value):
+    """Treat any dict, MergeDict, MultiDict instance as dict type"""
+    # Check by class name to avoid importing Django MergeDict or
+    # Werkzeug MultiDict
+    return isinstance(value, dict) or \
+        value.__class__.__name__ in ('MergeDict', 'MultiDict')
+
+
+def partial_prepare(strategy, backend, next_step, user=None, social=None,
+                    *args, **kwargs):
+    kwargs.update({
+        'response': kwargs.get('response') or {},
+        'details': kwargs.get('details') or {},
+        'username': kwargs.get('username'),
+        'uid': kwargs.get('uid'),
+        'is_new': kwargs.get('is_new') or False,
+        'new_association': kwargs.get('new_association') or False,
+        'user': hasattr(user, 'id') and user.id or None,
+        'social': social and {
+            'provider': social.provider,
+            'uid': social.uid
+        } or None
+    })
+
+    clean_args = [strategy.to_session_value(val) for val in args]
+
+    # Clean any MergeDict data type from the values
+    clean_kwargs = {}
+    for name, value in kwargs.items():
+        value = dict(value) if is_dict_type(value) else value
+        if isinstance(value, SERIALIZABLE_TYPES):
+            clean_kwargs[name] = strategy.to_session_value(value)
+
+    return strategy.storage.partial.prepare(backend.name, next_step, {
+        'args': clean_args,
+        'kwargs': clean_kwargs
+    })
+
+
+def partial_store(strategy, backend, next_step, user=None, social=None,
+                  *args, **kwargs):
+    partial = partial_prepare(strategy, backend, next_step, user=user,
+                              social=social, *args, **kwargs)
+    return strategy.storage.partial.store(partial)
+
+
+def partial_load(strategy, token):
+    partial = strategy.storage.partial.load(token)
+
+    if partial:
+        args = partial.args
+        kwargs = partial.kwargs.copy()
+        user = kwargs.get('user')
+        social = kwargs.get('social')
+
+        if isinstance(social, dict):
+            kwargs['social'] = strategy.storage.user.get_social_auth(**social)
+
+        if user:
+            kwargs['user'] = strategy.storage.user.get_user(user)
+
+        partial.args = [strategy.from_session_value(val) for val in args]
+        partial.kwargs = dict((key, strategy.from_session_value(val))
+                              for key, val in kwargs.items())
+    return partial
diff --git a/rootfs/api/apps_extra/social_core/storage.py b/rootfs/api/apps_extra/social_core/storage.py
new file mode 100644
index 000000000..f48a7cd90
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/storage.py
@@ -0,0 +1,346 @@
+"""Models mixins for Social Auth"""
+import base64
+import re
+import time
+import uuid
+import warnings
+from datetime import datetime, timedelta
+
+from openid.association import Association as OpenIdAssociation
+
+from .exceptions import MissingBackend
+
+
+NO_ASCII_REGEX = re.compile(r'[^\x00-\x7F]+')
+NO_SPECIAL_REGEX = re.compile(r'[^\w.@+_-]+', re.UNICODE)
+
+
+class UserMixin:
+    # Consider tokens that expire in 5 seconds as already expired
+    ACCESS_TOKEN_EXPIRED_THRESHOLD = 5
+
+    user = ''
+    provider = ''
+    uid = None
+    extra_data = None
+
+    def get_backend(self, strategy):
+        return strategy.get_backend_class(self.provider)
+
+    def get_backend_instance(self, strategy):
+        try:
+            return strategy.get_backend(self.provider)
+        except MissingBackend:
+            return None
+
+    @property
+    def access_token(self):
+        """Return access_token stored in extra_data or None"""
+        return self.extra_data.get('access_token')
+
+    @property
+    def tokens(self):
+        warnings.warn('tokens is deprecated, use access_token instead')
+        return self.access_token
+
+    def refresh_token(self, strategy, *args, **kwargs):
+        token = self.extra_data.get('refresh_token') or \
+                self.extra_data.get('access_token')
+        backend = self.get_backend_instance(strategy)
+        if token and backend and hasattr(backend, 'refresh_token'):
+            response = backend.refresh_token(token, *args, **kwargs)
+            extra_data = backend.extra_data(self,
+                                            self.uid,
+                                            response,
+                                            self.extra_data)
+            if self.set_extra_data(extra_data):
+                self.save()
+
+    def expiration_timedelta(self):
+        """Return provider session live seconds. Returns a timedelta ready to
+        use with session.set_expiry().
+
+        If provider returns a timestamp instead of session seconds to live, the
+        timedelta is inferred from current time (using UTC timezone). None is
+        returned if there's no value stored or it's invalid.
+        """
+        if self.extra_data and 'expires' in self.extra_data:
+            try:
+                expires = int(self.extra_data.get('expires'))
+            except (ValueError, TypeError):
+                return None
+
+            now = datetime.utcnow()
+
+            # Detect if expires is a timestamp
+            if expires > time.mktime(now.timetuple()):
+                # expires is a datetime, return the remaining difference
+                return datetime.utcfromtimestamp(expires) - now
+            else:
+                # expires is the time to live seconds since creation,
+                # check against auth_time if present, otherwise return
+                # the value
+                auth_time = self.extra_data.get('auth_time')
+                if auth_time:
+                    reference = datetime.utcfromtimestamp(auth_time)
+                    return (reference + timedelta(seconds=expires)) - now
+                else:
+                    return timedelta(seconds=expires)
+
+    def expiration_datetime(self):
+        # backward compatible alias
+        return self.expiration_timedelta()
+
+    def access_token_expired(self):
+        """Return true / false if access token is already expired"""
+        expiration = self.expiration_timedelta()
+        return expiration and \
+            expiration.total_seconds() <= self.ACCESS_TOKEN_EXPIRED_THRESHOLD
+
+    def get_access_token(self, strategy):
+        """Returns a valid access token."""
+        if self.access_token_expired():
+            self.refresh_token(strategy)
+        return self.access_token
+
+    def set_extra_data(self, extra_data=None):
+        if extra_data and self.extra_data != extra_data:
+            if self.extra_data and not isinstance(self.extra_data, str):
+                self.extra_data.update(extra_data)
+            else:
+                self.extra_data = extra_data
+            return True
+
+    @classmethod
+    def clean_username(cls, value):
+        """Clean username removing any unsupported character"""
+        value = NO_ASCII_REGEX.sub('', value)
+        value = NO_SPECIAL_REGEX.sub('', value)
+        return value
+
+    @classmethod
+    def changed(cls, user):
+        """The given user instance is ready to be saved"""
+        raise NotImplementedError('Implement in subclass')
+
+    @classmethod
+    def get_username(cls, user):
+        """Return the username for given user"""
+        raise NotImplementedError('Implement in subclass')
+
+    @classmethod
+    def user_model(cls):
+        """Return the user model"""
+        raise NotImplementedError('Implement in subclass')
+
+    @classmethod
+    def username_max_length(cls):
+        """Return the max length for username"""
+        raise NotImplementedError('Implement in subclass')
+
+    @classmethod
+    def allowed_to_disconnect(cls, user, backend_name, association_id=None):
+        """Return if it's safe to disconnect the social account for the
+        given user"""
+        raise NotImplementedError('Implement in subclass')
+
+    @classmethod
+    def disconnect(cls, entry):
+        """Disconnect the social account for the given user"""
+        raise NotImplementedError('Implement in subclass')
+
+    @classmethod
+    def user_exists(cls, *args, **kwargs):
+        """
+        Return True/False if a User instance exists with the given arguments.
+        Arguments are directly passed to filter() manager method.
+        """
+        raise NotImplementedError('Implement in subclass')
+
+    @classmethod
+    def create_user(cls, *args, **kwargs):
+        """Create a user instance"""
+        raise NotImplementedError('Implement in subclass')
+
+    @classmethod
+    def get_user(cls, pk):
+        """Return user instance for given id"""
+        raise NotImplementedError('Implement in subclass')
+
+    @classmethod
+    def get_users_by_email(cls, email):
+        """Return users instances for given email address"""
+        raise NotImplementedError('Implement in subclass')
+
+    @classmethod
+    def get_social_auth(cls, provider, uid):
+        """Return UserSocialAuth for given provider and uid"""
+        raise NotImplementedError('Implement in subclass')
+
+    @classmethod
+    def get_social_auth_for_user(cls, user, provider=None, id=None):
+        """Return all the UserSocialAuth instances for given user"""
+        raise NotImplementedError('Implement in subclass')
+
+    @classmethod
+    def create_social_auth(cls, user, uid, provider):
+        """Create a UserSocialAuth instance for given user"""
+        raise NotImplementedError('Implement in subclass')
+
+
+class NonceMixin:
+    """One use numbers"""
+    server_url = ''
+    timestamp = 0
+    salt = ''
+
+    @classmethod
+    def use(cls, server_url, timestamp, salt):
+        """Create a Nonce instance"""
+        raise NotImplementedError('Implement in subclass')
+
+    @classmethod
+    def get(cls, server_url, salt):
+        """Retrieve a Nonce instance"""
+        raise NotImplementedError('Implement in subclass')
+
+    @classmethod
+    def delete(cls, nonce):
+        """Delete a Nonce instance"""
+        raise NotImplementedError('Implement in subclass')
+
+
+class AssociationMixin:
+    """OpenId account association"""
+    server_url = ''
+    handle = ''
+    secret = ''
+    issued = 0
+    lifetime = 0
+    assoc_type = ''
+
+    @classmethod
+    def oids(cls, server_url, handle=None):
+        kwargs = {'server_url': server_url}
+        if handle is not None:
+            kwargs['handle'] = handle
+        return sorted([
+            (assoc.id, cls.openid_association(assoc))
+            for assoc in cls.get(**kwargs)
+        ], key=lambda x: x[1].issued, reverse=True)
+
+    @classmethod
+    def openid_association(cls, assoc):
+        secret = assoc.secret
+        if not isinstance(secret, bytes):
+            secret = secret.encode()
+        return OpenIdAssociation(assoc.handle, base64.decodebytes(secret),
+                                 assoc.issued, assoc.lifetime,
+                                 assoc.assoc_type)
+
+    @classmethod
+    def store(cls, server_url, association):
+        """Create an Association instance"""
+        raise NotImplementedError('Implement in subclass')
+
+    @classmethod
+    def get(cls, *args, **kwargs):
+        """Get an Association instance"""
+        raise NotImplementedError('Implement in subclass')
+
+    @classmethod
+    def remove(cls, ids_to_delete):
+        """Remove an Association instance"""
+        raise NotImplementedError('Implement in subclass')
+
+
+class CodeMixin:
+    email = ''
+    code = ''
+    verified = False
+
+    def verify(self):
+        self.verified = True
+        self.save()
+
+    @classmethod
+    def generate_code(cls):
+        return uuid.uuid4().hex
+
+    @classmethod
+    def make_code(cls, email):
+        code = cls()
+        code.email = email
+        code.code = cls.generate_code()
+        code.verified = False
+        code.save()
+        return code
+
+    @classmethod
+    def get_code(cls, code):
+        raise NotImplementedError('Implement in subclass')
+
+
+class PartialMixin:
+    token = ''
+    data = ''
+    next_step = ''
+    backend = ''
+
+    @property
+    def args(self):
+        return self.data.get('args', [])
+
+    @args.setter
+    def args(self, value):
+        self.data['args'] = value
+
+    @property
+    def kwargs(self):
+        return self.data.get('kwargs', {})
+
+    @kwargs.setter
+    def kwargs(self, value):
+        self.data['kwargs'] = value
+
+    def extend_kwargs(self, values):
+        self.data['kwargs'].update(values)
+
+    @classmethod
+    def generate_token(cls):
+        return uuid.uuid4().hex
+
+    @classmethod
+    def load(cls, token):
+        raise NotImplementedError('Implement in subclass')
+
+    @classmethod
+    def destroy(cls, token):
+        raise NotImplementedError('Implement in subclass')
+
+    @classmethod
+    def prepare(cls, backend, next_step, data):
+        partial = cls()
+        partial.backend = backend
+        partial.next_step = next_step
+        partial.data = data
+        partial.token = cls.generate_token()
+        return partial
+
+    @classmethod
+    def store(cls, partial):
+        partial.save()
+        return partial
+
+
+class BaseStorage:
+    user = UserMixin
+    nonce = NonceMixin
+    association = AssociationMixin
+    code = CodeMixin
+    partial = PartialMixin
+
+    @classmethod
+    def is_integrity_error(cls, exception):
+        """Check if given exception flags an integrity error in the DB"""
+        raise NotImplementedError('Implement in subclass')
diff --git a/rootfs/api/apps_extra/social_core/store.py b/rootfs/api/apps_extra/social_core/store.py
new file mode 100644
index 000000000..aa17223a4
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/store.py
@@ -0,0 +1,84 @@
+import time
+
+try:
+    import cPickle as pickle
+except ImportError:
+    import pickle
+
+from openid.store.interface import OpenIDStore as BaseOpenIDStore
+from openid.store.nonce import SKEW
+
+
+class OpenIdStore(BaseOpenIDStore):
+    """Storage class"""
+    def __init__(self, strategy):
+        """Init method"""
+        super().__init__()
+        self.strategy = strategy
+        self.storage = strategy.storage
+        self.assoc = self.storage.association
+        self.nonce = self.storage.nonce
+        self.max_nonce_age = 6 * 60 * 60  # Six hours
+
+    def storeAssociation(self, server_url, association):
+        """Store new association if it does not exist"""
+        self.assoc.store(server_url, association)
+
+    def removeAssociation(self, server_url, handle):
+        """Remove association"""
+        associations_ids = list(dict(self.assoc.oids(server_url,
+                                                     handle)).keys())
+        if associations_ids:
+            self.assoc.remove(associations_ids)
+
+    def expiresIn(self, assoc):
+        if hasattr(assoc, 'getExpiresIn'):
+            return assoc.getExpiresIn()
+        else:  # python3-openid 3.0.2
+            return assoc.expiresIn
+
+    def getAssociation(self, server_url, handle=None):
+        """Return stored association"""
+        associations, expired = [], []
+        for assoc_id, association in self.assoc.oids(server_url, handle):
+            expires = self.expiresIn(association)
+            if expires > 0:
+                associations.append(association)
+            elif expires == 0:
+                expired.append(assoc_id)
+
+        if expired:  # clear expired associations
+            self.assoc.remove(expired)
+
+        if associations:  # return most recet association
+            return associations[0]
+
+    def useNonce(self, server_url, timestamp, salt):
+        """Generate one use number and return *if* it was created"""
+        if abs(timestamp - time.time()) > SKEW:
+            return False
+        return self.nonce.use(server_url, timestamp, salt)
+
+
+class OpenIdSessionWrapper(dict):
+    pickle_instances = (
+        '_yadis_services__openid_consumer_',
+        '_openid_consumer_last_token'
+    )
+
+    def __getitem__(self, name):
+        value = super().__getitem__(name)
+        if name in self.pickle_instances:
+            value = pickle.loads(value)
+        return value
+
+    def __setitem__(self, name, value):
+        if name in self.pickle_instances:
+            value = pickle.dumps(value, 0)
+        super().__setitem__(name, value)
+
+    def get(self, name, default=None):
+        try:
+            return self[name]
+        except KeyError:
+            return default
diff --git a/rootfs/api/apps_extra/social_core/strategy.py b/rootfs/api/apps_extra/social_core/strategy.py
new file mode 100644
index 000000000..9d7a7e595
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/strategy.py
@@ -0,0 +1,239 @@
+import time
+import random
+import hashlib
+
+from .utils import setting_name, module_member, PARTIAL_TOKEN_SESSION_NAME
+from .store import OpenIdStore, OpenIdSessionWrapper
+from .pipeline import DEFAULT_AUTH_PIPELINE, DEFAULT_DISCONNECT_PIPELINE
+from .pipeline.utils import partial_load, partial_store, partial_prepare
+from .backends.utils import get_backend
+
+
+class BaseTemplateStrategy:
+    def __init__(self, strategy):
+        self.strategy = strategy
+
+    def render(self, tpl=None, html=None, context=None):
+        if not tpl and not html:
+            raise ValueError('Missing template or html parameters')
+        context = context or {}
+        if tpl:
+            return self.render_template(tpl, context)
+        else:
+            return self.render_string(html, context)
+
+    def render_template(self, tpl, context):
+        raise NotImplementedError('Implement in subclass')
+
+    def render_string(self, html, context):
+        raise NotImplementedError('Implement in subclass')
+
+
+class BaseStrategy:
+    ALLOWED_CHARS = 'abcdefghijklmnopqrstuvwxyz' \
+                    'ABCDEFGHIJKLMNOPQRSTUVWXYZ' \
+                    '0123456789'
+    DEFAULT_TEMPLATE_STRATEGY = BaseTemplateStrategy
+
+    def __init__(self, storage=None, tpl=None):
+        self.storage = storage
+        self.tpl = (tpl or self.DEFAULT_TEMPLATE_STRATEGY)(self)
+
+    def setting(self, name, default=None, backend=None):
+        names = [setting_name(name), name]
+        if backend:
+            names.insert(0, setting_name(backend.name, name))
+        for name in names:
+            try:
+                return self.get_setting(name)
+            except (AttributeError, KeyError):
+                pass
+        return default
+
+    def create_user(self, *args, **kwargs):
+        return self.storage.user.create_user(*args, **kwargs)
+
+    def get_user(self, *args, **kwargs):
+        return self.storage.user.get_user(*args, **kwargs)
+
+    def session_setdefault(self, name, value):
+        self.session_set(name, value)
+        return self.session_get(name)
+
+    def openid_session_dict(self, name):
+        # Many frameworks are switching the session serialization from Pickle
+        # to JSON to avoid code execution risks. Flask did this from Flask
+        # 0.10, Django is switching to JSON by default from version 1.6.
+        #
+        # Sadly python-openid stores classes instances in the session which
+        # fails the JSON serialization, the classes are:
+        #
+        #   openid.yadis.manager.YadisServiceManager
+        #   openid.consumer.discover.OpenIDServiceEndpoint
+        #
+        # This method will return a wrapper over the session value used with
+        # openid (a dict) which will automatically keep a pickled value for the
+        # mentioned classes.
+        return OpenIdSessionWrapper(self.session_setdefault(name, {}))
+
+    def to_session_value(self, val):
+        return val
+
+    def from_session_value(self, val):
+        return val
+
+    def partial_save(self, next_step, backend, *args, **kwargs):
+        return partial_store(self, backend, next_step, *args, **kwargs)
+
+    def partial_prepare(self, next_step, backend, *args, **kwargs):
+        return partial_prepare(self, backend, next_step, *args, **kwargs)
+
+    def partial_load(self, token):
+        return partial_load(self, token)
+
+    def clean_partial_pipeline(self, token):
+        self.storage.partial.destroy(token)
+        current_token_in_session = self.session_get(PARTIAL_TOKEN_SESSION_NAME)
+        if current_token_in_session == token:
+            self.session_pop(PARTIAL_TOKEN_SESSION_NAME)
+
+    def openid_store(self):
+        return OpenIdStore(self)
+
+    def get_pipeline(self, backend=None):
+        return self.setting('PIPELINE', DEFAULT_AUTH_PIPELINE, backend)
+
+    def get_disconnect_pipeline(self, backend=None):
+        return self.setting(
+            'DISCONNECT_PIPELINE',
+            DEFAULT_DISCONNECT_PIPELINE,
+            backend
+        )
+
+    def random_string(self, length=12, chars=ALLOWED_CHARS):
+        # Implementation borrowed from django 1.4
+        try:
+            random.SystemRandom()
+        except NotImplementedError:
+            key = self.setting('SECRET_KEY', '')
+            seed = '{0}{1}{2}'.format(random.getstate(), time.time(), key)
+            random.seed(hashlib.sha256(seed.encode()).digest())
+        return ''.join([random.choice(chars) for i in range(length)])
+
+    def absolute_uri(self, path=None):
+        uri = self.build_absolute_uri(path)
+        if uri and self.setting('REDIRECT_IS_HTTPS'):
+            uri = uri.replace('http://', 'https://')
+        return uri
+
+    def get_language(self):
+        """Return current language"""
+        return ''
+
+    def send_email_validation(self, backend, email, partial_token=None):
+        email_validation = self.setting('EMAIL_VALIDATION_FUNCTION')
+        send_email = module_member(email_validation)
+        code = self.storage.code.make_code(email)
+        send_email(self, backend, code, partial_token)
+        return code
+
+    def validate_email(self, email, code):
+        verification_code = self.storage.code.get_code(code)
+        if not verification_code or verification_code.code != code:
+            return False
+        elif verification_code.email != email:
+            return False
+        elif verification_code.verified:
+            return False
+        else:
+            verification_code.verify()
+            return True
+
+    def render_html(self, tpl=None, html=None, context=None):
+        """Render given template or raw html with given context"""
+        return self.tpl.render(tpl, html, context)
+
+    def authenticate(self, backend, *args, **kwargs):
+        """Trigger the authentication mechanism tied to the current
+        framework"""
+        kwargs['strategy'] = self
+        kwargs['storage'] = self.storage
+        kwargs['backend'] = backend
+        args, kwargs = self.clean_authenticate_args(*args, **kwargs)
+        return backend.authenticate(*args, **kwargs)
+
+    def clean_authenticate_args(self, *args, **kwargs):
+        """Take authenticate arguments and return a "cleaned" version
+        of them"""
+        return args, kwargs
+
+    def get_backends(self):
+        """Return configured backends"""
+        return self.setting('AUTHENTICATION_BACKENDS', [])
+
+    def get_backend_class(self, name):
+        """Return a configured backend class"""
+        return get_backend(self.get_backends(), name)
+
+    def get_backend(self, name, redirect_uri=None, *args, **kwargs):
+        """Return a configured backend instance"""
+        Backend = self.get_backend_class(name)
+        return Backend(self, redirect_uri=redirect_uri, *args, **kwargs)
+
+    # Implement the following methods on strategies sub-classes
+
+    def redirect(self, url):
+        """Return a response redirect to the given URL"""
+        raise NotImplementedError('Implement in subclass')
+
+    def get_setting(self, name):
+        """Return value for given setting name"""
+        raise NotImplementedError('Implement in subclass')
+
+    def html(self, content):
+        """Return HTTP response with given content"""
+        raise NotImplementedError('Implement in subclass')
+
+    def request_data(self, merge=True):
+        """Return current request data (POST or GET)"""
+        raise NotImplementedError('Implement in subclass')
+
+    def request_host(self):
+        """Return current host value"""
+        raise NotImplementedError('Implement in subclass')
+
+    def session_get(self, name, default=None):
+        """Return session value for given key"""
+        raise NotImplementedError('Implement in subclass')
+
+    def session_set(self, name, value):
+        """Set session value for given key"""
+        raise NotImplementedError('Implement in subclass')
+
+    def session_pop(self, name):
+        """Pop session value for given key"""
+        raise NotImplementedError('Implement in subclass')
+
+    def build_absolute_uri(self, path=None):
+        """Build absolute URI with given (optional) path"""
+        raise NotImplementedError('Implement in subclass')
+
+    def request_is_secure(self):
+        """Is the request using HTTPS?"""
+        raise NotImplementedError('Implement in subclass')
+
+    def request_path(self):
+        """path of the current request"""
+        raise NotImplementedError('Implement in subclass')
+
+    def request_port(self):
+        """Port in use for this request"""
+        raise NotImplementedError('Implement in subclass')
+
+    def request_get(self):
+        """Request GET data"""
+        raise NotImplementedError('Implement in subclass')
+
+    def request_post(self):
+        """Request POST data"""
+        raise NotImplementedError('Implement in subclass')
diff --git a/rootfs/api/apps_extra/social_core/tests/__init__.py b/rootfs/api/apps_extra/social_core/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/rootfs/api/apps_extra/social_core/tests/actions/__init__.py b/rootfs/api/apps_extra/social_core/tests/actions/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/rootfs/api/apps_extra/social_core/tests/actions/actions.py b/rootfs/api/apps_extra/social_core/tests/actions/actions.py
new file mode 100644
index 000000000..40a71e4bd
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/actions/actions.py
@@ -0,0 +1,213 @@
+import json
+import requests
+import unittest
+from urllib.parse import urlparse
+
+from httpretty import HTTPretty
+
+from ...utils import parse_qs, module_member
+from ...actions import do_auth, do_complete
+from ..models import TestStorage, User, TestUserSocialAuth, TestNonce, \
+    TestAssociation
+from ..strategy import TestStrategy
+
+
+class BaseActionTest(unittest.TestCase):
+    user_data_url = 'https://api.github.com/user'
+    login_redirect_url = '/success'
+    expected_username = 'foobar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    user_data_body = json.dumps({
+        'login': 'foobar',
+        'id': 1,
+        'avatar_url': 'https://github.com/images/error/foobar_happy.gif',
+        'gravatar_id': 'somehexcode',
+        'url': 'https://api.github.com/users/foobar',
+        'name': 'monalisa foobar',
+        'company': 'GitHub',
+        'blog': 'https://github.com/blog',
+        'location': 'San Francisco',
+        'email': 'foo@bar.com',
+        'hireable': False,
+        'bio': 'There once was...',
+        'public_repos': 2,
+        'public_gists': 1,
+        'followers': 20,
+        'following': 0,
+        'html_url': 'https://github.com/foobar',
+        'created_at': '2008-01-14T04:33:35Z',
+        'type': 'User',
+        'total_private_repos': 100,
+        'owned_private_repos': 100,
+        'private_gists': 81,
+        'disk_usage': 10000,
+        'collaborators': 8,
+        'plan': {
+            'name': 'Medium',
+            'space': 400,
+            'collaborators': 10,
+            'private_repos': 20
+        }
+    })
+
+    def __init__(self, *args, **kwargs):
+        self.strategy = None
+        super().__init__(*args, **kwargs)
+
+    def setUp(self):
+        HTTPretty.enable()
+        User.reset_cache()
+        TestUserSocialAuth.reset_cache()
+        TestNonce.reset_cache()
+        TestAssociation.reset_cache()
+        Backend = module_member('social_core.backends.github.GithubOAuth2')
+        self.strategy = self.strategy or TestStrategy(TestStorage)
+        self.backend = Backend(self.strategy, redirect_uri='/complete/github')
+        self.user = None
+
+    def tearDown(self):
+        self.backend = None
+        self.strategy = None
+        self.user = None
+        User.reset_cache()
+        User.set_active(True)
+        TestUserSocialAuth.reset_cache()
+        TestNonce.reset_cache()
+        TestAssociation.reset_cache()
+        HTTPretty.disable()
+
+    def do_login(self, after_complete_checks=True, user_data_body=None,
+                 expected_username=None):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_GITHUB_KEY': 'a-key',
+            'SOCIAL_AUTH_GITHUB_SECRET': 'a-secret-key',
+            'SOCIAL_AUTH_LOGIN_REDIRECT_URL': self.login_redirect_url,
+            'SOCIAL_AUTH_AUTHENTICATION_BACKENDS': (
+                'social_core.backends.github.GithubOAuth2',
+            )
+        })
+        start_url = do_auth(self.backend).url
+        target_url = self.strategy.build_absolute_uri(
+            '/complete/github/?code=foobar'
+        )
+
+        start_query = parse_qs(urlparse(start_url).query)
+        location_url = target_url + ('&' if '?' in target_url else '?') + \
+            'state=' + start_query['state']
+        location_query = parse_qs(urlparse(location_url).query)
+
+        HTTPretty.register_uri(HTTPretty.GET, start_url, status=301,
+                               location=location_url)
+        HTTPretty.register_uri(HTTPretty.GET, location_url, status=200,
+                               body='foobar')
+
+        response = requests.get(start_url)
+        self.assertEqual(response.url, location_url)
+        self.assertEqual(response.text, 'foobar')
+
+        HTTPretty.register_uri(HTTPretty.POST,
+                               uri=self.backend.ACCESS_TOKEN_URL,
+                               status=200,
+                               body=self.access_token_body or '',
+                               content_type='text/json')
+
+        if self.user_data_url:
+            user_data_body = user_data_body or self.user_data_body or ''
+            HTTPretty.register_uri(HTTPretty.GET, self.user_data_url,
+                                   body=user_data_body,
+                                   content_type='text/json')
+        self.strategy.set_request_data(location_query, self.backend)
+
+        def _login(backend, user, social_user):
+            backend.strategy.session_set('username', user.username)
+
+        redirect = do_complete(self.backend, user=self.user, login=_login)
+
+        if after_complete_checks:
+            self.assertEqual(self.strategy.session_get('username'),
+                             expected_username or self.expected_username)
+            self.assertEqual(redirect.url, self.login_redirect_url)
+        return redirect
+
+    def do_login_with_partial_pipeline(self, before_complete=None):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_GITHUB_KEY': 'a-key',
+            'SOCIAL_AUTH_GITHUB_SECRET': 'a-secret-key',
+            'SOCIAL_AUTH_LOGIN_REDIRECT_URL': self.login_redirect_url,
+            'SOCIAL_AUTH_AUTHENTICATION_BACKENDS': (
+                'social_core.backends.github.GithubOAuth2',
+            ),
+            'SOCIAL_AUTH_PIPELINE': (
+                'social_core.pipeline.social_auth.social_details',
+                'social_core.pipeline.social_auth.social_uid',
+                'social_core.pipeline.social_auth.auth_allowed',
+                'social_core.tests.pipeline.ask_for_password',
+                'social_core.pipeline.social_auth.social_user',
+                'social_core.pipeline.user.get_username',
+                'social_core.pipeline.user.create_user',
+                'social_core.pipeline.social_auth.associate_user',
+                'social_core.pipeline.social_auth.load_extra_data',
+                'social_core.tests.pipeline.set_password',
+                'social_core.pipeline.user.user_details'
+            )
+        })
+        start_url = do_auth(self.backend).url
+        target_url = self.strategy.build_absolute_uri(
+            '/complete/github/?code=foobar'
+        )
+
+        start_query = parse_qs(urlparse(start_url).query)
+        location_url = target_url + ('&' if '?' in target_url else '?') + \
+            'state=' + start_query['state']
+        location_query = parse_qs(urlparse(location_url).query)
+
+        HTTPretty.register_uri(HTTPretty.GET, start_url, status=301,
+                               location=location_url)
+        HTTPretty.register_uri(HTTPretty.GET, location_url, status=200,
+                               body='foobar')
+
+        response = requests.get(start_url)
+        self.assertEqual(response.url, location_url)
+        self.assertEqual(response.text, 'foobar')
+
+        HTTPretty.register_uri(HTTPretty.GET,
+                               uri=self.backend.ACCESS_TOKEN_URL,
+                               status=200,
+                               body=self.access_token_body or '',
+                               content_type='text/json')
+
+        if self.user_data_url:
+            HTTPretty.register_uri(HTTPretty.GET, self.user_data_url,
+                                   body=self.user_data_body or '',
+                                   content_type='text/json')
+        self.strategy.set_request_data(location_query, self.backend)
+
+        def _login(backend, user, social_user):
+            backend.strategy.session_set('username', user.username)
+
+        redirect = do_complete(self.backend, user=self.user, login=_login)
+        url = self.strategy.build_absolute_uri('/password')
+        self.assertEqual(redirect.url, url)
+        HTTPretty.register_uri(HTTPretty.GET, redirect.url, status=200,
+                               body='foobar')
+        HTTPretty.register_uri(HTTPretty.POST, redirect.url, status=200)
+
+        password = 'foobar'
+        requests.get(url)
+        requests.post(url, data={'password': password})
+        data = parse_qs(HTTPretty.last_request.body)
+        self.assertEqual(data['password'], password)
+        self.strategy.session_set('password', data['password'])
+
+        if before_complete:
+            before_complete()
+        redirect = do_complete(self.backend, user=self.user, login=_login)
+        self.assertEqual(self.strategy.session_get('username'),
+                         self.expected_username)
+        self.assertEqual(redirect.url, self.login_redirect_url)
+
+    def _logout(self, backend):
+        backend.strategy.session_set('username', None)
diff --git a/rootfs/api/apps_extra/social_core/tests/actions/test_associate.py b/rootfs/api/apps_extra/social_core/tests/actions/test_associate.py
new file mode 100644
index 000000000..3c16a1aa0
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/actions/test_associate.py
@@ -0,0 +1,87 @@
+import json
+
+from ...exceptions import AuthAlreadyAssociated
+
+from ..models import User
+from .actions import BaseActionTest
+
+
+class AssociateActionTest(BaseActionTest):
+    expected_username = 'foobar'
+
+    def setUp(self):
+        super().setUp()
+        self.user = User(username='foobar', email='foo@bar.com')
+        self.backend.strategy.session_set('username', self.user.username)
+
+    def test_associate(self):
+        self.do_login()
+        self.assertTrue(len(self.user.social), 1)
+        self.assertEqual(self.user.social[0].provider, 'github')
+
+    def test_associate_with_partial_pipeline(self):
+        self.do_login_with_partial_pipeline()
+        self.assertEqual(len(self.user.social), 1)
+        self.assertEqual(self.user.social[0].provider, 'github')
+
+
+class MultipleAccountsTest(AssociateActionTest):
+    alternative_user_data_body = json.dumps({
+        'login': 'foobar2',
+        'id': 2,
+        'avatar_url': 'https://github.com/images/error/foobar2_happy.gif',
+        'gravatar_id': 'somehexcode',
+        'url': 'https://api.github.com/users/foobar2',
+        'name': 'monalisa foobar2',
+        'company': 'GitHub',
+        'blog': 'https://github.com/blog',
+        'location': 'San Francisco',
+        'email': 'foo@bar.com',
+        'hireable': False,
+        'bio': 'There once was...',
+        'public_repos': 2,
+        'public_gists': 1,
+        'followers': 20,
+        'following': 0,
+        'html_url': 'https://github.com/foobar2',
+        'created_at': '2008-01-14T04:33:35Z',
+        'type': 'User',
+        'total_private_repos': 100,
+        'owned_private_repos': 100,
+        'private_gists': 81,
+        'disk_usage': 10000,
+        'collaborators': 8,
+        'plan': {
+            'name': 'Medium',
+            'space': 400,
+            'collaborators': 10,
+            'private_repos': 20
+        }
+    })
+
+    def test_multiple_social_accounts(self):
+        self.do_login()
+        self.do_login(user_data_body=self.alternative_user_data_body)
+        self.assertEqual(len(self.user.social), 2)
+        self.assertEqual(self.user.social[0].provider, 'github')
+        self.assertEqual(self.user.social[1].provider, 'github')
+
+
+class AlreadyAssociatedErrorTest(BaseActionTest):
+    def setUp(self):
+        super().setUp()
+        self.user1 = User(username='foobar', email='foo@bar.com')
+        self.user = None
+
+    def tearDown(self):
+        super().tearDown()
+        self.user1 = None
+        self.user = None
+
+    def test_already_associated_error(self):
+        self.user = self.user1
+        self.do_login()
+        self.user = User(username='foobar2', email='foo2@bar2.com')
+        with self.assertRaisesRegex(AuthAlreadyAssociated,
+                                    'This account is already in use.'):
+            self.do_login()
diff --git a/rootfs/api/apps_extra/social_core/tests/actions/test_disconnect.py b/rootfs/api/apps_extra/social_core/tests/actions/test_disconnect.py
new file mode 100644
index 000000000..b5e021729
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/actions/test_disconnect.py
@@ -0,0 +1,67 @@
+import requests
+
+from httpretty import HTTPretty
+
+from ...actions import do_disconnect
+from ...exceptions import NotAllowedToDisconnect
+from ...utils import parse_qs
+
+from ..models import User, TestUserSocialAuth
+from .actions import BaseActionTest
+
+
+class DisconnectActionTest(BaseActionTest):
+    def test_not_allowed_to_disconnect(self):
+        self.do_login()
+        user = User.get(self.expected_username)
+        with self.assertRaises(NotAllowedToDisconnect):
+            do_disconnect(self.backend, user)
+
+    def test_disconnect(self):
+        self.do_login()
+        user = User.get(self.expected_username)
+        user.password = 'password'
+        do_disconnect(self.backend, user)
+        self.assertEqual(len(user.social), 0)
+
+    def test_disconnect_with_association_id(self):
+        self.do_login()
+        user = User.get(self.expected_username)
+        user.password = 'password'
+        association_id = user.social[0].id
+        second_usa = TestUserSocialAuth(user, user.social[0].provider, 'uid2')
+        self.assertEqual(len(user.social), 2)
+        do_disconnect(self.backend, user, association_id)
+        self.assertEqual(len(user.social), 1)
+        self.assertEqual(user.social[0], second_usa)
+
+    def test_disconnect_with_partial_pipeline(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_DISCONNECT_PIPELINE': (
+                'social_core.tests.pipeline.ask_for_password',
+                'social_core.tests.pipeline.set_password',
+                'social_core.pipeline.disconnect.allowed_to_disconnect',
+                'social_core.pipeline.disconnect.get_entries',
+                'social_core.pipeline.disconnect.revoke_tokens',
+                'social_core.pipeline.disconnect.disconnect'
+            )
+        })
+        self.do_login()
+        user = User.get(self.expected_username)
+        redirect = do_disconnect(self.backend, user)
+
+        url = self.strategy.build_absolute_uri('/password')
+        self.assertEqual(redirect.url, url)
+        HTTPretty.register_uri(HTTPretty.GET, redirect.url, status=200,
+                               body='foobar')
+        HTTPretty.register_uri(HTTPretty.POST, redirect.url, status=200)
+
+        password = 'foobar'
+        requests.get(url)
+        requests.post(url, data={'password': password})
+        data = parse_qs(HTTPretty.last_request.body)
+        self.assertEqual(data['password'], password)
+        self.strategy.session_set('password', data['password'])
+
+        redirect = do_disconnect(self.backend, user)
+        self.assertEqual(len(user.social), 0)
diff --git a/rootfs/api/apps_extra/social_core/tests/actions/test_login.py b/rootfs/api/apps_extra/social_core/tests/actions/test_login.py
new file mode 100644
index 000000000..869250191
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/actions/test_login.py
@@ -0,0 +1,81 @@
+from ...utils import PARTIAL_TOKEN_SESSION_NAME
+from ..models import User
+from .actions import BaseActionTest
+
+
+class LoginActionTest(BaseActionTest):
+    def test_login(self):
+        self.do_login()
+
+    def test_login_with_partial_pipeline(self):
+        self.do_login_with_partial_pipeline()
+
+    def test_fields_stored_in_session(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_FIELDS_STORED_IN_SESSION': ['foo', 'bar']
+        })
+        self.strategy.set_request_data({'foo': '1', 'bar': '2'}, self.backend)
+        self.do_login()
+        self.assertEqual(self.strategy.session_get('foo'), '1')
+        self.assertEqual(self.strategy.session_get('bar'), '2')
+
+        self._logout(self.backend)
+        # The _logout helper function doesn't clear the session.
+        self.assertEqual(self.strategy.session_get('foo'), '1')
+        self.assertEqual(self.strategy.session_get('bar'), '2')
+
+        # Login again - without the 'bar' request param and make
+        # sure its value didn't persist in the session.
+        self.strategy.remove_from_request_data('bar')
+        self.strategy.set_request_data({'foo': '3'}, self.backend)
+        self.do_login()
+        self.assertEqual(self.strategy.session_get('foo'), '3')
+        self.assertEqual(self.strategy.session_get('bar'), None)
+
+    def test_redirect_value(self):
+        self.strategy.set_request_data({'next': '/after-login'}, self.backend)
+        redirect = self.do_login(after_complete_checks=False)
+        self.assertEqual(redirect.url, '/after-login')
+
+    def test_login_with_invalid_partial_pipeline(self):
+        def before_complete():
+            partial_token = self.strategy.session_get(
+                PARTIAL_TOKEN_SESSION_NAME
+            )
+            partial = self.strategy.storage.partial.load(partial_token)
+            partial.data['backend'] = 'foobar'
+        self.do_login_with_partial_pipeline(before_complete)
+
+    def test_new_user(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_NEW_USER_REDIRECT_URL': '/new-user'
+        })
+        redirect = self.do_login(after_complete_checks=False)
+        self.assertEqual(redirect.url, '/new-user')
+
+    def test_inactive_user(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_INACTIVE_USER_URL': '/inactive'
+        })
+        User.set_active(False)
+        redirect = self.do_login(after_complete_checks=False)
+        self.assertEqual(redirect.url, '/inactive')
+
+    def test_invalid_user(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_LOGIN_ERROR_URL': '/error',
+            'SOCIAL_AUTH_PIPELINE': (
+                'social_core.pipeline.social_auth.social_details',
+                'social_core.pipeline.social_auth.social_uid',
+                'social_core.pipeline.social_auth.auth_allowed',
+                'social_core.pipeline.social_auth.social_user',
+                'social_core.pipeline.user.get_username',
+                'social_core.pipeline.user.create_user',
+                'social_core.pipeline.social_auth.associate_user',
+                'social_core.pipeline.social_auth.load_extra_data',
+                'social_core.pipeline.user.user_details',
+                'social_core.tests.pipeline.remove_user'
+            )
+        })
+        redirect = self.do_login(after_complete_checks=False)
+        self.assertEqual(redirect.url, '/error')
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/__init__.py b/rootfs/api/apps_extra/social_core/tests/backends/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/base.py b/rootfs/api/apps_extra/social_core/tests/backends/base.py
new file mode 100644
index 000000000..040c5b884
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/base.py
@@ -0,0 +1,152 @@
+import unittest
+import requests
+
+from httpretty import HTTPretty
+
+from ...utils import module_member, parse_qs, PARTIAL_TOKEN_SESSION_NAME
+from ...backends.utils import user_backends_data, load_backends
+from ..strategy import TestStrategy
+from ..models import User, TestUserSocialAuth, TestNonce, \
+    TestAssociation, TestCode, TestStorage
+
+
+class BaseBackendTest(unittest.TestCase):
+    backend = None
+    backend_path = None
+    name = None
+    complete_url = ''
+    raw_complete_url = '/complete/{0}'
+
+    def setUp(self):
+        HTTPretty.enable()
+        Backend = module_member(self.backend_path)
+        self.strategy = TestStrategy(TestStorage)
+        self.backend = Backend(self.strategy, redirect_uri=self.complete_url)
+        self.name = self.backend.name.upper().replace('-', '_')
+        self.complete_url = self.strategy.build_absolute_uri(
+            self.raw_complete_url.format(self.backend.name)
+        )
+        backends = (self.backend_path,
+                    'social_core.tests.backends.test_broken.BrokenBackendAuth')
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_AUTHENTICATION_BACKENDS': backends
+        })
+        self.strategy.set_settings(self.extra_settings())
+        # Force backends loading to trash PSA cache
+        load_backends(backends, force_load=True)
+        User.reset_cache()
+        TestUserSocialAuth.reset_cache()
+        TestNonce.reset_cache()
+        TestAssociation.reset_cache()
+        TestCode.reset_cache()
+
+    def tearDown(self):
+        HTTPretty.disable()
+        self.backend = None
+        self.strategy = None
+        self.name = None
+        self.complete_url = None
+        User.reset_cache()
+        TestUserSocialAuth.reset_cache()
+        TestNonce.reset_cache()
+        TestAssociation.reset_cache()
+        TestCode.reset_cache()
+
+    def extra_settings(self):
+        return {}
+
+    def do_start(self):
+        raise NotImplementedError('Implement in subclass')
+
+    def do_login(self):
+        user = self.do_start()
+        username = self.expected_username
+        self.assertEqual(user.username, username)
+        self.assertEqual(self.strategy.session_get('username'), username)
+        self.assertEqual(self.strategy.get_user(user.id), user)
+        self.assertEqual(self.backend.get_user(user.id), user)
+        user_backends = user_backends_data(
+            user,
+            self.strategy.get_setting('SOCIAL_AUTH_AUTHENTICATION_BACKENDS'),
+            self.strategy.storage
+        )
+        self.assertEqual(len(list(user_backends.keys())), 3)
+        self.assertEqual('associated' in user_backends, True)
+        self.assertEqual('not_associated' in user_backends, True)
+        self.assertEqual('backends' in user_backends, True)
+        self.assertEqual(len(user_backends['associated']), 1)
+        self.assertEqual(len(user_backends['not_associated']), 1)
+        self.assertEqual(len(user_backends['backends']), 2)
+        return user
+
+    def pipeline_settings(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_PIPELINE': (
+                'social_core.pipeline.social_auth.social_details',
+                'social_core.pipeline.social_auth.social_uid',
+                'social_core.pipeline.social_auth.auth_allowed',
+                'social_core.tests.pipeline.ask_for_password',
+                'social_core.tests.pipeline.ask_for_slug',
+                'social_core.pipeline.social_auth.social_user',
+                'social_core.pipeline.user.get_username',
+                'social_core.pipeline.social_auth.associate_by_email',
+                'social_core.pipeline.user.create_user',
+                'social_core.pipeline.social_auth.associate_user',
+                'social_core.pipeline.social_auth.load_extra_data',
+                'social_core.tests.pipeline.set_password',
+                'social_core.tests.pipeline.set_slug',
+                'social_core.pipeline.user.user_details'
+            )
+        })
+
+    def pipeline_handlers(self, url):
+        HTTPretty.register_uri(HTTPretty.GET, url, status=200, body='foobar')
+        HTTPretty.register_uri(HTTPretty.POST, url, status=200)
+
+    def pipeline_password_handling(self, url):
+        password = 'foobar'
+        requests.get(url)
+        requests.post(url, data={'password': password})
+
+        data = parse_qs(HTTPretty.last_request.body)
+        self.assertEqual(data['password'], password)
+        self.strategy.session_set('password', data['password'])
+        return password
+
+    def pipeline_slug_handling(self, url):
+        slug = 'foo-bar'
+        requests.get(url)
+        requests.post(url, data={'slug': slug})
+
+        data = parse_qs(HTTPretty.last_request.body)
+        self.assertEqual(data['slug'], slug)
+        self.strategy.session_set('slug', data['slug'])
+        return slug
+
+    def do_partial_pipeline(self):
+        url = self.strategy.build_absolute_uri('/password')
+        self.pipeline_settings()
+        redirect = self.do_start()
+        self.assertEqual(redirect.url, url)
+        self.pipeline_handlers(url)
+
+        password = self.pipeline_password_handling(url)
+        token = self.strategy.session_pop(PARTIAL_TOKEN_SESSION_NAME)
+        partial = self.strategy.partial_load(token)
+        self.assertEqual(partial.backend, self.backend.name)
+        redirect = self.backend.continue_pipeline(partial)
+
+        url = self.strategy.build_absolute_uri('/slug')
+        self.assertEqual(redirect.url, url)
+        self.pipeline_handlers(url)
+        slug = self.pipeline_slug_handling(url)
+
+        token = self.strategy.session_pop(PARTIAL_TOKEN_SESSION_NAME)
+        partial = self.strategy.partial_load(token)
+        self.assertEqual(partial.backend, self.backend.name)
+        user = self.backend.continue_pipeline(partial)
+
+        self.assertEqual(user.username, self.expected_username)
+        self.assertEqual(user.slug, slug)
+        self.assertEqual(user.password, password)
+        return user
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/data/saml_config.json b/rootfs/api/apps_extra/social_core/tests/backends/data/saml_config.json
new file mode 100644
index 000000000..3f610107c
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/data/saml_config.json
@@ -0,0 +1,23 @@
+{
+    "SOCIAL_AUTH_SAML_SP_ENTITY_ID": "https://github.com/omab/python-social-auth/saml-test",
+    "SOCIAL_AUTH_SAML_SP_PUBLIC_CERT": "MIICsDCCAhmgAwIBAgIJAO7BwdjDZcUWMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNVBAYTAkNBMRkwFwYDVQQIExBCcml0aXNoIENvbHVtYmlhMRswGQYDVQQKExJweXRob24tc29jaWFsLWF1dGgwHhcNMTUwNTA4MDc1ODQ2WhcNMjUwNTA3MDc1ODQ2WjBFMQswCQYDVQQGEwJDQTEZMBcGA1UECBMQQnJpdGlzaCBDb2x1bWJpYTEbMBkGA1UEChMScHl0aG9uLXNvY2lhbC1hdXRoMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCq3g1Cl+3uR5vCnN4HbgjTg+m3nHhteEMyb++ycZYre2bxUfsshER6x33l23tHckRYwm7MdBbrp3LrVoiOCdPblTml1IhEPTCwKMhBKvvWqTvgfcSSnRzAWkLlQYSusayyZK4n9qcYkV5MFni1rbjx+Mr5aOEmb5u33amMKLwSTwIDAQABo4GnMIGkMB0GA1UdDgQWBBRRiBR6zS66fKVokp0yJHbgv3RYmjB1BgNVHSMEbjBsgBRRiBR6zS66fKVokp0yJHbgv3RYmqFJpEcwRTELMAkGA1UEBhMCQ0ExGTAXBgNVBAgTEEJyaXRpc2ggQ29sdW1iaWExGzAZBgNVBAoTEnB5dGhvbi1zb2NpYWwtYXV0aIIJAO7BwdjDZcUWMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADgYEAJwsMU3YSaybVjuJ8US0fUhlPOlM40QFCGL4vB3TEbb24Mq8HrjUwrU0JFPGls9a2OYzN2B3e35NorMuxs+grGtr2yP6LvuX+nV6A93wb4ooGHoGfC7VLlyxSSns937SS5R1pzQ4gWzZma2KGWKICWph5zQ0ARVhL63967mGLmoI=",
+    "SOCIAL_AUTH_SAML_SP_PRIVATE_KEY": "MIICXgIBAAKBgQCq3g1Cl+3uR5vCnN4HbgjTg+m3nHhteEMyb++ycZYre2bxUfsshER6x33l23tHckRYwm7MdBbrp3LrVoiOCdPblTml1IhEPTCwKMhBKvvWqTvgfcSSnRzAWkLlQYSusayyZK4n9qcYkV5MFni1rbjx+Mr5aOEmb5u33amMKLwSTwIDAQABAoGBAIHAg6NJSiYC/NYpVzWfKlasuoNy78R5adXYSNZiCR5V5FNm5OzmODZgXUt6g0A7FomshIT/txQWoV7y5FmwPs8n13JY3Hdt4tJ6MHw2feLo710+OEp9VBQus3JsB2F8ONYrGvs00hPPL7h5av/rzTdE8F67YM1mSgeg7xEF6BghAkEA12OOqSzp2MLTNY7PqOaLDzy4aAMVNN3Ntv2jBN0jq7s1b5ilQ2PGkLwdtkicq/VZcRyUqVbZbMwz05II3nqx3wJBAMsVhRQ5sdFCRBzEbSAm2YEJaFh5u6QT3+zWHMFpPJRnaBAWz3RXKEnleJ+DS2Xz1Jm6ZrmLdZiwMx/8dK5rDZECQQC7GTdWi7ZC3dIcpwaKIGHRhZxmda8ZMkc9Wwwd8H7I8aFUZFPCu0xEc7SXoHHACit8zyfwBYpvMN8gPK3JnOkfAkEAsUSpk0wBMT38one7IZOHzCDgGkq4RbKrhdon45Pus0PIDDM9BrqFimtpbSN4DxhVfZK91DwtfAhhuAvv9cewYQJAPMhpAqv3PBGYmtRDUlWXJQv2JRJJkrvbbqgBed2OX5RRgj5V3SR6PBhLbcTZ+q+1tdPkMFzZo5U6MN5m/6oXvQ==",
+    "SOCIAL_AUTH_SAML_ORG_INFO": {
+        "en-US": {"name": "psa", "displayname": "PSA", "url": "https://github.com/omab/python-social-auth/"}
+    },
+    "SOCIAL_AUTH_SAML_TECHNICAL_CONTACT":
+        {"givenName": "Tech Gal", "emailAddress": "technical@example.com"},
+    "SOCIAL_AUTH_SAML_SUPPORT_CONTACT":
+        {"givenName": "Support Guy", "emailAddress": "support@example.com"},
+    "SOCIAL_AUTH_SAML_ENABLED_IDPS": {
+        "testshib": {
+            "entity_id": "https://idp.testshib.org/idp/shibboleth",
+            "url": "https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO",
+            "x509cert": "MIIEDjCCAvagAwIBAgIBADANBgkqhkiG9w0BAQUFADBnMQswCQYDVQQGEwJVUzEVMBMGA1UECBMMUGVubnN5bHZhbmlhMRMwEQYDVQQHEwpQaXR0c2J1cmdoMREwDwYDVQQKEwhUZXN0U2hpYjEZMBcGA1UEAxMQaWRwLnRlc3RzaGliLm9yZzAeFw0wNjA4MzAyMTEyMjVaFw0xNjA4MjcyMTEyMjVaMGcxCzAJBgNVBAYTAlVTMRUwEwYDVQQIEwxQZW5uc3lsdmFuaWExEzARBgNVBAcTClBpdHRzYnVyZ2gxETAPBgNVBAoTCFRlc3RTaGliMRkwFwYDVQQDExBpZHAudGVzdHNoaWIub3JnMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArYkCGuTmJp9eAOSGHwRJo1SNatB5ZOKqDM9ysg7CyVTDClcpu93gSP10nH4gkCZOlnESNgttg0r+MqL8tfJC6ybddEFB3YBo8PZajKSe3OQ01Ow3yT4I+Wdg1tsTpSge9gEz7SrC07EkYmHuPtd71CHiUaCWDv+xVfUQX0aTNPFmDixzUjoYzbGDrtAyCqA8f9CN2txIfJnpHE6q6CmKcoLADS4UrNPlhHSzd614kR/JYiks0K4kbRqCQF0Dv0P5Di+rEfefC6glV8ysC8dB5/9nb0yh/ojRuJGmgMWHgWk6h0ihjihqiu4jACovUZ7vVOCgSE5Ipn7OIwqd93zp2wIDAQABo4HEMIHBMB0GA1UdDgQWBBSsBQ869nh83KqZr5jArr4/7b+QazCBkQYDVR0jBIGJMIGGgBSsBQ869nh83KqZr5jArr4/7b+Qa6FrpGkwZzELMAkGA1UEBhMCVVMxFTATBgNVBAgTDFBlbm5zeWx2YW5pYTETMBEGA1UEBxMKUGl0dHNidXJnaDERMA8GA1UEChMIVGVzdFNoaWIxGTAXBgNVBAMTEGlkcC50ZXN0c2hpYi5vcmeCAQAwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQUFAAOCAQEAjR29PhrCbk8qLN5MFfSVk98t3CT9jHZoYxd8QMRLI4j7iYQxXiGJTT1FXs1nd4Rha9un+LqTfeMMYqISdDDI6tv8iNpkOAvZZUosVkUo93pv1T0RPz35hcHHYq2yee59HJOco2bFlcsH8JBXRSRrJ3Q7Eut+z9uo80JdGNJ4/SJy5UorZ8KazGj16lfJhOBXldgrhppQBb0Nq6HKHguqmwRfJ+WkxemZXzhediAjGeka8nz8JjwxpUjAiSWYKLtJhGEaTqCYxCCX2Dw+dOTqUzHOZ7WKv4JXPK5G/Uhr8K/qhmFT2nIQi538n6rVYLeWj8Bbnl+ev0peYzxFyF5sQA=="
+        },
+        "other": {
+            "entity_id": "https://unused.saml.example.com",
+            "url": "https://unused.saml.example.com/SAML2/Redirect/SSO"
+        }
+    }
+}
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/data/saml_response.txt b/rootfs/api/apps_extra/social_core/tests/backends/data/saml_response.txt
new file mode 100644
index 000000000..557bb59e8
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/data/saml_response.txt
@@ -0,0 +1 @@
+http://myapp.com/?RelayState=testshib&SAMLResponse=PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c2FtbDJwOlJlc3BvbnNlIHhtbG5zOnNhbWwycD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOnByb3RvY29sIiBEZXN0aW5hdGlvbj0iaHR0cDovL215YXBwLmNvbSIgSUQ9Il8yNTk2NTFlOTY3ZGIwOGZjYTQ4MjdkODI3YWY1M2RkMCIgSW5SZXNwb25zZVRvPSJURVNUX0lEIiBJc3N1ZUluc3RhbnQ9IjIwMTUtMDUtMDlUMDM6NTc6NDMuNzkyWiIgVmVyc2lvbj0iMi4wIj48c2FtbDI6SXNzdWVyIHhtbG5zOnNhbWwyPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXNzZXJ0aW9uIiBGb3JtYXQ9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDpuYW1laWQtZm9ybWF0OmVudGl0eSI%2BaHR0cHM6Ly9pZHAudGVzdHNoaWIub3JnL2lkcC9zaGliYm9sZXRoPC9zYW1sMjpJc3N1ZXI%2BPHNhbWwycDpTdGF0dXM%2BPHNhbWwycDpTdGF0dXNDb2RlIFZhbHVlPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6c3RhdHVzOlN1Y2Nlc3MiLz48L3NhbWwycDpTdGF0dXM%2BPHNhbWwyOkVuY3J5cHRlZEFzc2VydGlvbiB4bWxuczpzYW1sMj0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmFzc2VydGlvbiI%2BPHhlbmM6RW5jcnlwdGVkRGF0YSB4bWxuczp4ZW5jPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGVuYyMiIElkPSJfMGM0NzYzNzIyOWFkNmEzMTY1OGU0MDc2ZDNlYzBmNmQiIFR5cGU9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMDQveG1sZW5jI0VsZW1lbnQiPjx4ZW5jOkVuY3J5cHRpb25NZXRob2QgQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGVuYyNhZXMxMjgtY2JjIiB4bWxuczp4ZW5jPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGVuYyMiLz48ZHM6S2V5SW5mbyB4bWxuczpkcz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC8wOS94bWxkc2lnIyI%2BPHhlbmM6RW5jcnlwdGVkS2V5IElkPSJfYjZmNmU2YWZjMzYyNGI3NmM1N2JmOWZhODA5YzAzNmMiIHhtbG5zOnhlbmM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMDQveG1sZW5jIyI%2BPHhlbmM6RW5jcnlwdGlvbk1ldGhvZCBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMDQveG1sZW5jI3JzYS1vYWVwLW1nZjFwIiB4bWxuczp4ZW5jPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGVuYyMiPjxkczpEaWdlc3RNZXRob2QgQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwLzA5L3htbGRzaWcjc2hhMSIgeG1sbnM6ZHM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvMDkveG1sZHNpZyMiLz48L3hlbmM6RW5jcnlwdGlvbk1ldGhvZD48ZHM6S2V5SW5mbz48ZHM6WDUwOURhdGE%2BPGRzOlg1MDlDZXJ0aWZpY2F0ZT5NSUlDc0RDQ0FobWdBd0lCQWdJSkFPN0J3ZGpEWmNVV01BMEdDU3FHU0liM0RRRUJCUVVBTUVVeEN6QUpCZ05WQkFZVEFrTkJNUmt3CkZ3WURWUVFJRXhCQ2NtbDBhWE5vSUVOdmJIVnRZbWxoTVJzd0dRWURWUVFLRXhKd2VYUm9iMjR0YzI5amFXRnNMV0YxZEdnd0hoY04KTVRVd05UQTRNRGMxT0RRMldoY05NalV3TlRBM01EYzFPRFEyV2pCRk1Rc3dDUVlEVlFRR0V3SkRRVEVaTUJjR0ExVUVDQk1RUW5KcApkR2x6YUNCRGIyeDFiV0pwWVRFYk1Ca0dBMVVFQ2hNU2NIbDBhRzl1TFhOdlkybGhiQzFoZFhSb01JR2ZNQTBHQ1NxR1NJYjNEUUVCCkFRVUFBNEdOQURDQmlRS0JnUUNxM2cxQ2wrM3VSNXZDbk40SGJnalRnK20zbkhodGVFTXliKyt5Y1pZcmUyYnhVZnNzaEVSNngzM2wKMjN0SGNrUll3bTdNZEJicnAzTHJWb2lPQ2RQYmxUbWwxSWhFUFRDd0tNaEJLdnZXcVR2Z2ZjU1NuUnpBV2tMbFFZU3VzYXl5Wks0bgo5cWNZa1Y1TUZuaTFyYmp4K01yNWFPRW1iNXUzM2FtTUtMd1NUd0lEQVFBQm80R25NSUdrTUIwR0ExVWREZ1FXQkJSUmlCUjZ6UzY2CmZLVm9rcDB5SkhiZ3YzUlltakIxQmdOVkhTTUViakJzZ0JSUmlCUjZ6UzY2ZktWb2twMHlKSGJndjNSWW1xRkpwRWN3UlRFTE1Ba0cKQTFVRUJoTUNRMEV4R1RBWEJnTlZCQWdURUVKeWFYUnBjMmdnUTI5c2RXMWlhV0V4R3pBWkJnTlZCQW9URW5CNWRHaHZiaTF6YjJOcApZV3d0WVhWMGFJSUpBTzdCd2RqRFpjVVdNQXdHQTFVZEV3UUZNQU1CQWY4d0RRWUpLb1pJaHZjTkFRRUZCUUFEZ1lFQUp3c01VM1lTCmF5YlZqdUo4VVMwZlVobFBPbE00MFFGQ0dMNHZCM1RFYmIyNE1xOEhyalV3clUwSkZQR2xzOWEyT1l6TjJCM2UzNU5vck11eHMrZ3IKR3RyMnlQNkx2dVgrblY2QTkzd2I0b29HSG9HZkM3VkxseXhTU25zOTM3U1M1UjFwelE0Z1d6Wm1hMktHV0tJQ1dwaDV6UTBBUlZoTAo2Mzk2N21HTG1vST08L2RzOlg1MDlDZXJ0aWZpY2F0ZT48L2RzOlg1MDlEYXRhPjwvZHM6S2V5SW5mbz48eGVuYzpDaXBoZXJEYXRhIHhtbG5zOnhlbmM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMDQveG1sZW5jIyI%2BPHhlbmM6Q2lwaGVyVmFsdWU%2BTElQdkVNVUVGeXhrVHowQ2N4QVA5TjV4Y3NYT2V4aVV4cXBvR2VIeVFMV0R5RVBBUDVnZ1daL3NLZ1ViL2xWSk92bCtuQXhSdVhXUlc5dGxSWWx3R2orRVhIOWhIbmdEY1BWMDNqSUJMQnFJbElBL1RmMGw4cVliOHFKRy9ZM0RTS2RQNkwvUURtYXBtTXpFM29YOEJxMW5Ea3YrUWh4cmQwMGVGK2ZMYVQ0PTwveGVuYzpDaXBoZXJWYWx1ZT48L3hlbmM6Q2lwaGVyRGF0YT48L3hlbmM6RW5jcnlwdGVkS2V5PjwvZHM6S2V5SW5mbz48eGVuYzpDaXBoZXJEYXRhIHhtbG5zOnhlbmM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMDQveG1sZW5jIyI%2BPHhlbmM6Q2lwaGVyVmFsdWU%2BRVpUWDhHTkM0My9yWStTUVlBMXRudHlUTTVVNkN2dUNCaktsVEVlekZPRjBZZHhCWUdFQVVjYU8xNVNKOXBMemJ1L1h0WGxzTkVMZTdKdEx4RUpwYUxubWFENnIranNWczdLaTBLNHRTMGNBUERDWHV2R1FoMmFOVjVQOGJ3N1JWUGhLOGQwYlJ1RklGR09FOHMwTTZYOUpxWDN4S0MvL1lSbVVoeDlybnU3ZWlwMGh5ZitPaUZiVGR2SDY2NTB2LzQ3aVdKcDNZeFlUV0QyMHBNbVRJMUpwWUEwYjByWVFQRkR0RU93d0JxYktxanRJc3ZYVFJzeXJhQkxvbnFOeHN5dHpEWHEra0JsMXp3WGUvSE5QcUVQblczdnNxaFhZcDVGM3dkWThkKzNCOTRMZlpOdUd4a0p3VDNzdVR0OGY5VHRBSlI4VytBUmtzT2M4eDBVaVNsVG5BNHFHOTBLMTR5dkVoVHcvd2drZjFXV01RT3dpZDNpakFYbUV4MU5MbVZvYUxYb3p4VExkTjN6YnJ6VEJIRXc3R2J3ZEdrdU5pMlhZOW16YUgwaWtGRm51VUxjMHUwc0pycEdGdzlaK0VlUk44RzNVUVZ5MjhtS2g3ZFBwWU5KbzhyajIxZFFaK2JaeUtTUHZablU3REkyakdJRE5US1g2ZkVyVWFINGlOTzN4cUU2Vk90L2d4T3BMNE5VNUhLV0Q0bG93VzcwdUJjVEVQRmhwaThpYUovdTB6YzUvTEhvdVBjMzByc1RLZFc5cmJLL2NWaHNQUHErZzA5WHZpZ0QweTJvN2tOc1pVL25tRXFiSzBKOTBrazhCR3I5cXRSczY4bUJnSURtUHVwUkhwWjM4eXNnU2VZN3V0VlVaSG5tQ0dzTzZ2NDJ6OTVOK05Pb3RCTEVZbFd1ZEdzYnowQWc4VkRDSlY5ak95QW95MDZyL1AyUHBsOFhjdmJza2d2T1BMMWdDNnVYbVJJS1lmOEw4UDJCNXVjN0haK0dtUHNOWXRLS2VKRDFFUHovdCt2NlBIbXNVb3dsSDhSd3FMRHdtMUF4dlNLQTR3UXBlQ0dQd3A5YXRYS0lWMS84NUZzRWMzajVzNjd6VlRybThrVEpydXV2MDZEdFVRZDNMOFdwTkV4cWhQait6RUp6U3RxSG04ckhNMVhNQUVxdVozc0xycTVqLzFSNlpqS0dOdFJCbjhwOE5ERGtrWm0vWTV5TXlJNXJJS3U5bnA3bXdaaEVpeWVHeHdxblV3VVMvUzVDRjNnMHVidnd4eVVnalVvd1ZvTkNqYktBbkdtT2VCSW5abkh0eGdIVUhVOUVlTFdyd2pRc3JtUmpJV0R2RkZQa3l6SzJDL20yaitubmNxc2E1OGRLVXZxcGR1VTRJYnNPQng3UGpXdXRBNmY5bXd6YWxyRU1NK0lGR3VPdk9HMC93eUdzQjZLREV6bldjUC83NkQ4angzaHZFSlAzN3REbFgreGM4Qno5TXdKdkd6VG4xbTdCb2xoR0lzSXlCTys1ZXpXa3RDWVVIUURGVE9wbXA0MDlOWHp6ZUNTUGY1U2NDWG5YYjRPd01ULy9VM1JFUnRRbGMrNmU2WG1JRjhoRkJVc0taUUJsS2ppSDkwZHlzYWlsNmN2V3UyQW55Q3QxbWxXcHFLc0MzU2RTRVZDTG1qRjlUQUFUMEtFSGdZQjg3RjZtZUpTTysvOXkyZkRuYVVvUUlUVzdubnVuSCtkT3dWSGZMU0wyL2N5YTltNlQzR29TSVNMbGJPMVRzalhKclVkZW55OTcvM2tkNmhFQlphdGY1U3NETFQ3SjNsQUVJNDROeXJ0NkIxQWdod2JNdkpqd1JNTXRNdUJLc3ltUytKVzc4UFNEWXQ4MG9waDJQTTc1N0tBNCtUMTAvYnZaQkE5Vk1OdVpqNVV3NXRWMnFIS3dwS0t6ZVVETUFiQlBRaGpYcXlQZzFKa09rd2RQMUpnOHRITjJTelBZQTlmT1htV0pBZGJDS2tMb0F4ZTV6cDZBUzYzS3FXMmFmSUt6SHJ3RTJmS1VtamppeURvMnNuMkJHbWtBaTRzbnpiVzc2SUQvSVgwd044aDBaQ2VRc29vKzdtb1RCMEJxSnBkS1MycXlsUktoc3BSTC9henVQdmxaK1pwckJxdXpJdEZkNFVLMkpzQkp6VXcwZkpxcTV1bk9PZENzVWM3SUU3QTNmZ1NmZ3NBd1R3WFZJMEVoME5ySWZpMkFKV1Z2VFpEMys2eFZ3dS96WWhuVjc0VXkvMFE4Mi8yQWtpSGpFRjNJVGNLWHdTNTB6bWtLakxjZDJqa2h5TUFYMWRoQ0wwZElFMUJoN0RNamVvNC9YbjBqSlpPL3Rrbi9xZmYzc3RNb1BYVG9KTnBIU1RjR2ZheGtaMzJYNCt3Q0xPc0VBRWxlMVZSY0kwUkZyOFhHTSsxWU9BTjBodFdGcFMxaG9kSi9OczJqL1FnUVNEemNpQ1FZeUFDd3lFRWZDZjZybnR0VmJyTlJQZWlmSHhBM3B2UnZ5ZGRhNDE5cXl0ZXI0akJ3cmw3ZUpuVnJ2VEprR2VhU2FRbDdXWk5SQXBscXRnNnZPYmpiMHZDRWlFaFhKbmNzQUhxcXp5QTRGeWFUVGQ2R0FySU9adUNxRWVoWk51T01lOVlrMVpya0VkR3pIalJESWk3Q1BKQk12NEZ4ZHI3bnJvN0I1WEhKb0ZMNE1DSUtOWWU2aWZiTUtYOU5uN1FWdnphUmY2UXlaSW1BWENQZndvU1BkN2x6NXl3UDJLSUIyaGhFMWt5eVZ5YVc5T0praWpUY3dvUnZrSXhIU0RqMXFqeGxueXh0QzhVZ1pNWmlwcGgzQXJpcjRiekIzUDhIbGIzejZ0OW51KzZMemNiN2ZObVo0UHluaU50Vk9OQ0lHbEh4dTBSY3hQK3cwUXNsM1BtTzJLaHBpc2RIanhvSUJ1YVY1NXdoTlFFNmdNNFBrT0xINDc4Rzg4bUxkd2s2RFpkWVl4L2d6RWE3b3ZIL0pReFp2TzRLdFVTNmZjZHJxV2thTFg1cEhkNkdneFBGZ2NFc2Nad1ZqM2hCS0xFQmE5L0dodERINEhzRnNRbmpPZnNDQkNzN0tjRitmTi9oSUdUeHFqTVlKVHJRYmNtdWF5dk9xR3RQMDFPcXltR24rVm5FSVkzKytQcm95SFN3K0Q0b0JIVG1maFNXRmJLZCtuTlVFS3BhRVIxNkdCU256WktQRVRVSmdRWEw5QWJRQ3RXVjFHb0UzRWNnMDZYaVd2aHFHakpGNldtdEU4dHY4Q25rZmxMNm91TDRvNldpbmx2WnNEdkZrS0R6TDkwUTNsWC9NanBtRTFpWU9uYzdISXdEVGwraFRRcHdsYXJiTDVUNGNkZTg1akNwYU0xU3p1TStiQU5zMHlXVDA0ZXJUVFc2cnhlbXFDTHAra202TVVMTlZOcE1CazBiQjJpRU82UlRtc3VpRlhDUU1xdU5xZjdkWXUwTFFCZzQ0MkJzU1pBV1ZrWEVZblduOURLdTRSby8veEFsb2h5VHozWlZmSkhuWVBSdDloSUErRHVUL3c4T2ZzTURIWnlCelUvL0JEa1NiNkxjMHdraVA3QlhIdjBoNVdud2dNWUxlZDBPalR5UWI2aGxpVnQ5b0FjaDRFVy9EZUlBdkpaQ1BYVm1pUFFYTGVsOVJIRko2bXFiYVo0TCtaZG1ONmQwcFZNZ1FveXhmQTR3dEwwYVpiNnFZYkhibjJMd2VBQVZwL3M2TzVlMVExdnZpZDRTWHo0a2l3RW1LSStIeXZEQ1pnekpQQVN5Z1gvWDJFWEZ0NGV3SjVmUFQyVXZmWnhQWlpqMFZGSFpyUFQwWVd2VE16bjUva3hoT09oM2drVGdDSmNwNWVsZnp4cEFPNFl1a0NoNHJXdVNndDRqVUJyaWNYbFdWdWo5U3JSZVhUalNHTktLK202NWovUDllNHRHT0RkMk9BbjNKTVQ3Q3FuaDhreTZpZjVjbmpVMmU3UDhTZnBONGwxWEFiZEZEcGk5bVJYamEyTzR1RWFHNGNvNW4xcWNDT3ZNMWYyblFBY1ZGNUFoSXhueS96TWhmU2l2RXdOQ0Zyd2tBWDRyQVE0WldUNldFakFyUG5jb1Y4Z1VRclhxQVA4NDJmK1lNWWI5RHFncmFicEg1a3ZuMnQzcWRldGJHODJ0QWlTamhPcUxNYW9iU2F4cXdWa1lUOHRTMW9rUUt2MWZoZ2t6elpEOE5IQnVQQzdNVHdXS0VCS2tDRUUzRWRFMXhNQURLd1B1M3NSaGpSaExXZyszZ2srejJtdlU4cTBhTlc0Y3hObUdoekx4eEY0Q3NFNStMQ1cwOWFpUVJOM1VvWmg1aktBZzBiMlh3WHBLS3pycUVTY1BYdnI0L1dWUTMyMm5qRWRvQVdXR0t2WnBKMlRlREo0eDdiT21LVElFc2RHWU1UZzFVaEU2eFFQcnhqS3dWeGFJNVJyaVE4a0xpaGgwa0t0WHQvYTVsSDhzUjVwR0ZISGZ3dlNVb3liQTB1eUVDNnNRVitPbTVReUZmRmpqZHFCOGNpOGxQS1hLTHFCTHJ6bjNmUkh3TmQwbzFiRTg0aGllTkx5UlhZVmhrRCtFNEpGaVd3ZWt3U3VWM3BjQk9ybnRVU3RoWmx6M3hIUURUVGNJNWliOFJyQ2swZEZ6YTgvQmw3VUdtWlUwSXZ2UmdvVXF2TXNHT2dMY3pGWmRpZnJ5aGNiUTY4a2ZzZ3lCMHppdC9MN1BSV3V4RkdYdDFoTVZSVUZ3WXBJS04zVkI3cXVKZlgwamZsU1JaRndMaXdlK3VhYndmTVZ6c2doajUvOXZNNzcwK0JaMGtJcE45NzBTMG5BbHl6R0h0aW1nTUl1RXFhbUt5QTNTQlI1aHZIYmRyNENnTHFUbXIzbFFnWmpnSkNvN1FXYUJWTXdCR0RpdzVOVVhUUnBycWc4U3h2eDlnNWZwbXMrL0o2QjFEelNTM3ZRZzgxdHFRU1ZDWVJpc0Y3M2VqZlFuZk4zcUszd3RJRDkxQnRISmFvMEFaUUdKVFpKOXVsZ0kzV3hzdWR4ejB0VHVpNlJlSWpmSWsxekZRdFpwRExGMnB3NGpTQVdQTlJqNDBYdVIrRzFUVlI3OVFiME9FYkw4RDFoTU5zWmo3MTZNbUhSOTlKaUxNdm1FWHV5a1V4VGhGYjRMTzZVbW1kU3UwTlBpMXQ2NmNkYURpQWhMaVBFTGdUNkZsenA2T2FGSGNSNjRncEtyemtTNDJONEhJeFpNa2R6M0FsYkRhK2pOWHZPR1l3UWl5K0xNNENZWGtrTWtHR3ZTWis5R2xWQ0l5RXBJaXIzbEQ3bmdzZGk4emxGWDYvekNaczlQSUtwZFZlSGJGZi9GS20wV3AreHI0Ykd0R0RrVHR2Nk1Manh2YU8zanFHaUFWeERKVWFkTVBlS2VHSm5uempTdnpKbGdOVHV3c3grRnF5L2dPMkwxMGowWmhDWi92dE9NelVjNjl3cGhKZm9FNzU3V3lOeFJOcThJc0Y1Tkg5Y0x0b3UvbUNxOTc3YnZPSkRrSURCN3lKWEJ6YUhVQkJuSXJra1Qyemg3bGJmUm5SREJUSFZraVZMazVESUxqeC9XL1BSZEZpUUM2SzRmZGx4Y29JbzlMcnM4ZFVWZkt2TTNNYnJ6c1hGT3ZtVVh0K3NsZldvd3UyTC9ndG9mRFhvTUJZZnlEcWIvWlRaRWZ0MC83blliRm1relBEUlZacU5SR0F3YWZVNTU1UjB2SWtNbGR2VjdKUzhNT1BNYWlXQVBpelNLRG4yRzNvcys1MzRFQytaOGZnWmFPVWpZL0xLME9vME9RMmhvNUV6MGNMYWpwUjFINk9FNEhvUm1ydjQzZkFjdGpYc0hYdi81RXg3emdrWk1NZXZhTFNEdjZtcjFGcDk4QXR4L296VTFGVDBoMDUxcVcwR0g2VWpRRXk5aExSZDBBMnFkUTRMZXpReDNvbDFTblhsamt2MG4zTXFlaFozOC94bzZhdHFDdkJtQkc3amlUdXd6YnlVUngzRm1TM0NCNllOYnFON3hPYVRZRnlkOEZDL01nY0xGQmMwS3F4MXllQ2VUd1hucldQb0dvdllVQlYxYjA1cWtIa1d5V0RUaCsveXJFNzF0RjNxbUQvd3F6cUJyNE04NERtWWVuQkdFOWxtb3FIZEMyWnRpK09KVFZKcmlHZWxQQ3RjZnZRaUlQcHdDZ3BFNmg1ekZhRndLajRuZGtBUkRpTC95L1EwWTZxNU5rM1g5RURlTmdjY1pIcFdmOUpKQ3M2a29wdXRtYjdDczIrbVJYdER1S09DaGY5UVUyN3Bmb1NJaklYK3NGdHY1c0hhSms2aHBZMlpzUUhzaTBYbFowc3FMTnQ5ayszdTVnYnBSU1JCczlHaC9BaVY0dkNyYTRkOTh5U0dCdzRSR1FhSStpQ29RaG9YK3lxc3VrYkx6bXJUU3FXMVRXaXJReUlHZ1Q5VnFERE1mUzAxeGdQSlNFSTlIWlp6TGlFVXVGMm1CMi81Y2dqaEFUaWQrdGV1UVB4aldhN2NSc2t5YUhuTENjQURVUU9ESUFPVjJDWXROcnAwY29ZL091S3ZzaXlJT0lacVJ5dE1PMGVNZ1ZJWTBzWmdxeVEycXlubUx0NDBmWmd3SFVyV245Zm9TYTNtMkVRTy9uOS8yU2NuelJWdVZpVnNjM0tCSElQL3AzNlJlSWowTGlNcCtPQ0p3SHlLVW1UeDRBU1V0dXVhWktlRHl1QjlxcXJuUEFNWUVCeElsTGFvdXMzV1pHakIrcW9ub3QvNmk1UE40bUZjbHFDcUxhMGJHbks4ZnJxYy9yd2tuVGV0YUE0c2tXTEw1L21qNEd5MitFQkh3a0x3UXd2K0FKdmZTOXYvNDl1LzY0N1ZFYW15UzdZQ2ZEUHNBQUREQ1FFcWJNQ1h2Ui8xVmEwWi9YUWhoNlkrZUt0MEVpRDdpNmRZODJtQkFoNEJMRmRVV3VGZHVrdUVwaGZ2WXB3N2loVjNxTjB1NFM1NTRXU0dUa0ZsdlpYNG1hbkF4a1g2ekQxS0NWaEFMdEJnSDgzdkhxam9uc0lwOFMydHgwZ0tiYzEreHVaRVppVWlNVVlVdTByQVFsRFcrZHJoN3lVRHZqekFHSnBmTk01eThaMW45em93VzZ5YW5VZWFBNjhSZDd5TUxobFd0NVh6bGhBTVZDZmZYZ0pFelR1YzJEbENVOXNMLzVTVkRaV2N4R1E5aFM1cnJtK2VyQ1Jxd2FJQk1DNUtza0RCZHdOWmh2Q0FCdEpqS2Vla1FUSjd5MFp4SGNhbGVCaU1rbkYwZVRDZzFvUEhPUVZLQ3V3NE94cHRZUS9xS1V0TEFIWFZ2OTlLMGRWcWZDMmpVQWlHQmVYa0t3aGRYTGtJYlZxU0EyZmxraXBBeEhYNnByUEExQjF3eTVab3hPUFg4RVExOW92eXpBbFg1dHU0OXEwWC9PSExFN1o5T1cxenltRXR6ZFpyNXJZbWtFcVdtcHVSNU5jeHFwTWlZam93dUNXZWhubzIyeG5JM09IQ0xDZkFKaHRrcklhL1hPc0tZRFpCRzFJMGJsN2taR2R5cEtUQlhYdXl6WE5WUlU5L005ejhaVytwdG1oZ2NOUzBJS2VaaSs5bFl4cWRlS3lnbldTTTV3czdSYUpmNlRRZTNSaWJZUjFvNkhwRzB2VHpiTEtQZTZnRjJGODdiWlBJei9mcTNLWnZiM3UrSnhZcCtJVjBtQi9VN29YelhRRk1RK3VmWllpNzUxbkx6WlVxRE1ybU53TFJPVUFNUk8rVnJtblkwSVB1cFBVMXc0b0hBb1dnVGRnTk5pNk1uTFQ4V0pmUlhjT0pKMk1lbUc2K2ZNeHNZUU52UVJwa1RGY05vaFV6Y3ZjcHJ3NUV3WEVZQTJzbzczL2MvY3RIRGcreU05YlF4REppUlltRnFydkhYb29hS1JyekxnUjZLVWdoM3ltaWxaQ0lSSm9KbTE3aEtHM1pxTTE0Lzl5OUc5OE9BZjNkVTlqMDk3aUNlaEc3a2VxYXRJQ2hFWmJqbmQ4Y00rS3djN2FtVWp2ekQzQmNvMHl3MDJxT054OWF3OGhSblZiWDZhdkRJbGhySHZ6SU44MzFvUjljRHBwMG1DUEJXZFVDQlNqVGJ1RkZqRC90WElSbGxlT2JraFFKSUdSNlE2U1MxcXkzT29WT1VheFl6THY0U2s3dndrQUMwUitGREVIeVFZbFVhbVVkTWcyUmdwRUdhSVd1V3IxaGNnRm10QmREV2g3ZFBuWTF0U3VKOC95MXp4NkRvN2ZJYmNFenBBK2E0ODNtRG5vemdld3VmaFdqVCsvUS85WlEreFQ5UWJBT1pQSXhHV3VhSXVrVk8zSWxvZDhJM1NGZFJCTHY5ZXBDNzFLeXpSdVlpMktkOHJ5NVNINit1WnMxUHlZUlpRakdDK3Q4VzRtSE82Z1lFRWVXSkJ1UWhnSHdmV2xhZXlWb3hac0NBQVZKRUllT3hPZDZtNW45OHRCUDdHTmgxT1M0eDRCS2FVN1A0UVQzNVVIZW5meE84WWFQUThmbXlobUJhSVJVZklBTVN2ZTJZRFp5SWNNTTkrN0tNSVVabzJ0eXRvYzdCOGVvZzBNaUkrVkpFdFg0c29FRjFSWkhQZVV3NWlCTjI4OTh2MmVTcGNnVUJhWHFzOUN5VlZtTVJQMEtLUDJ1REt4MUdJcUhjS0ZCOXVQVWRkQS9vT3dNa0tVUWsraFZVVDVPbEVMdjd1a0FBUEE0eE4rZkczVmYxeUVKV0FiVGx5dWtGcThjNXBTRkY1cXVHbUgwVmVpQzVvVEFka1VES3Z6WGhWWUs5c3BRYjNVZ1Z0Qld6N1ZScnlOUVVST3BIZU5xeDlhZHA4YWREWCtRSHJUKytYblN4VVI3SVdGanlNTkZJRWlMWmkxdks1UVVrZlRDUU9qdjh2SHdiUi9MRHF3Z3M5bXdsT3pPY0RLdVBVK0dTb2lnVFdRejRWN0N2SHRaVDI3WUdKVG44RFFFM3IzdjB4aWxvODJ2U3VXSDg0WEU3VEJsTUpFb2R5eDNDRngwVUVkc3VhRHBPSEV3UjZYNlUyU0xseERYSXVZeEhlNXh2NjI4bXU0bDRMSnBYUjhkYmljTEZKQW55Q0FVeDJLb2dDamt1cmU4bXNUZktDbG8wamFlN1hNR05PSk15b0ZYbVlHZUh2eGhNUGMzTEtYLy9VY1p0c3p3dFJrQmNFdURXQysvQWNWZVBOSHVOWWI5MEpIcnRucGg1ZDlhL1lpTkpzY1N3QTFwUVZrdW1TQWtPQWdLdWRzcnl3c0N3Zkg1anNydVpHUTJDd1hKRXQzUU4wU2NLUlVnT1NCQ3FYa1BqZDVSVzJuOFZpamt4anovbWptakhCNmk0eHM5NEU2Nzk5STAyaldYNVd3UDZhTFRaTGt5TjhxNDUxT0RmeUZVZEY5WWsyZXQ5VUpsV1NzRFJMSWVCd0ZyQkEyZTdyRWsybWFLVUNCRW5PUWM2bUhVMXQvZ3gzK1VXVVFXbkpMZVUxbWUvbkFEdy96UGUwd3d0Vm9BaERZdDBoR1hQblJydjFoUHRGS01CeWtqckg3a0J5U0R3WDlQMi9XZkNkQlE5K1J4cHRsR2hvRmdpMUs0NVlOeEpEd05wTmd5MDV2WXUzVUtrMkpRYVNGUzcwK0Y1NzluRE5RenZpK0pPRlRsdDFmWDJGNXk5NEV2NHZobWRQSmRVOFVVRjU2Ymx0emxKREVFdmsySlFrOTM0aHpwTXJGZ1d3ZHUxUkxxSEhCN2h2T2hnaHNqV0ZGY01zNjZaRUtWcVhKUytxWWNVMHk0akwySVQrNlF2N2pvQ3BWbUdzUWtGY1FyblhxOUJiOTdaUS96UCtwaldmWTU0UmNRVlMydUU1YURObVVyVkdLK3E0d0xRcUhuRVViT2puSHFFeGlacUtxOVdRaUtUK2c3QS96bVlIQ2k0YzFTejRNVWhHb0t6U2l4aXoxYUNJUEJXdy9vczR2cUVqbXgzOGx6YnV0OWNWbElzeGNkTUpUTERRK3ZOZ0YyY1ZRaVcxRTQ0d3lWcnI3TUFaOE9KRVpFSzlEZWt5MzJQUkFuSkRUVXVqdGFscmJ0T2VOczhyS09uTjcvNFRqUEwvZmRlbEI4bjA4WXdSNXdmbU42VGpGWUhRSDFjbUZmK1AvNUxVMTI4Q1pEYjNQUStxMlFJazV3aE40eGwvcy9lb29pallmeWtDcm5aSEhHWkluTGhoU2pWbk5ISWdTL203VWV0NlhBTDdvZUl5UFRLeHVnbDJzRWtUQzNnZ0tjTnFZR0E5U3ZlYVlaQ00vWHNQRUtQbWs3QmlRNmprWFBKaE1yREd4Vkc0SW9aSDgrYjBrUWJYR2l0Mkw0L3hZdHh1bTVzcFNPSjdsTDltVFpRNnBxM2JOaTEwZU1mZ0ZWaDc3NU5JRlc0SEp3U1FtaTU0bk11blZTQjhxdjZKc0w3SGlsZ2N0ZHFSNThTTjVad1lCa2dOR1hzYjA1QXJWemVXbHh1Y21BSHNPT3dyczFnMzh6bTRZN2ZPZmducmFhV1kxanZZOFlEODZQZThkZzR4cE5paTg3UnNDZk5WK2NKVmMraktFdnpuZVY1Zzd0RmlxZCtsZHp4STlKemdSS2t0WUV6RUpRSVU5M2UvclJaN1lrVkZtNVV1cjVhMWYzcG83T0VtYkJUc2MrQ1FaOGNnYmIvbUphRXJoa3NyL3JURjBNcjNxeDl5SlJWSEJ6YWNWd0dScEFRaURPdnJkWU4xQXBVOTRyR1lrVFVzdWs1YjE1Wll2QVZxRlRzVlVMaS9HY29mbEljMm01Z2RFTFZOblRmdXY1Zlk5S1NlWHFoUU80S0pOYVZmbHAwQ0VKYWFFZFNLUXJJNXRaT2w1RkE4VXZlNmxTWVd5TVk0REl4a1RiT1JoWHVBdzR6b1RTMjgrN3d2TXhydVBkZnlKbUJCTkhQdCtEYmdKNHovcHJZWUhpTmFMTXNZamtQZE44ajNKZDczQXJFZk92Um52MzYxSVVVMFg1RDc1dlRSdlpkbzMzWERzanRlOU4weUo3K2lIQnF1a1FJY2pIVW9ic2RQN0hOajBVYWNSMHIvTmRlVTlGNFBNc1VLY2t6Tk4rZGhyMVI2d1J2R1VZb1pDRWJaWlJMWEt4QnA3SElUNEVQUktHakIvdW1xTFhhMXl6RWx2QW1WQUJhMDFZN3dGdk4wM2Ywb25FbUhTM2w1d1paRmV6cjVibnN5T01XVGxhMU5kaW1ZNXNVeE15VFliZmc4dzB2cXNEc28zWFAxYndLdzZ3M3VIRGQ1UHBSWnVDSnR0eWk0ZzJGeWI0Ymg1UU42ZkdORTI2ekRGN1Y4QmJwZXJLNkFKQ0xTWm5kaDZMMTlPUTBram4xUGpEMGk4c1BZcGFXOWxVeVJkZElPKzRWQS9LemxPUzJ4M2s5VUtUdElsTTBUSVdtZXFIS0dYUVpocGpvVGI2VlNKN203cjZaaVlQMnVsQVVvZmVWL0o2eCtzckxEQXkyQ2ZFNnFrREZ1OU9NWDBBSXVnN3loQUtOMDRyT3hVNk5tcGtjOUZ4bXUvVS9vR3hHdmIzeFVFTDYwdE1sSE9EaWtqY1I5RDJrKzRwbEc1WnV0d0FIY2kwRU02WHRrVEhQOU5QMlRTR1VFN1E5SGYvU0VEc2V0a25hZXhvWmhDczJLWDFMeU5JS0U0N2pkMkR3MTUreDRRVXV0VUFTbzU5Q1lHMVFBeW9BVVhrV3dtbXkzTGdTUWp5T3ZLV25qaE8veWpPd0FyWGd0NFBrSVVnZDQ1N05ReFpMbU41K0J4NVJoQ0FHdkUxYmxOZjlMek9keGJiaG5VZ2Z1RDM5MXVSRkhjS2RYREY3ZmVqb3gveThtaWZJcTRWVzQyajBHQnFOQUtkK0prMnJCMW9hOTRiT2hxcVVzanhqWnlRaGRXTzhNblR6T2tOaGVpZXU2blYxcW5yZ3JHU2huWTNJMlczb29GNFNnczRjZ3drZ2h2dHpFa0xUbU5OUm83RTdudVRuMkxJcmlGSnlvTmZQdUp0aWN0S0JtNzRGZytkWVBTMlIzTzNmOWxBZWxiVWZjbzZGNU9EL3hkS1VuRTh0V3FOMExVcDlWQUptWVZYZFVDaGJ4MjM4MWtDaStLNDJoRzUydFNQYU1hb1dTb0xQY2Zrb24rc1pYdjdEdEtwZi9HTzdhcUMza1pzRGpva29haHJGZGJWSlNTZWhrNGp5K3RzRHplQnJKSjBrMVZrUnJHN1NoVHZjTmd1cjVucVRUTEE5dlJMQmJNTTlhNlI1NEZ0Z1pQOWFKMU1aMEdCcUVpMnF6Ui8yd2tYQlhwcFhZdi9TcU1RV1dhbTVsSHBMVktxaDN4ZHRjNFdmck9mYldsbU1PNXA5Z0JUSFp1YUcxVGFkZXFRVVpKQmZBS01ENFdSR0NsMDFaeDRTVzE0YzZrdnFKdXExL080N215L3RsVHlLWndpYlBkQTNRMVVGd0I3R2Z4anEwaDN2ckxFbUNrS3Vsc0VBUkN6UnZNVjJSVnBVbFpUV240Y1Boc0hjcTNROElHSUYyKy9nOENFSU4vMU8xcVMvMkpXcXlDNmtIb0w4Y2R2R0VHbmkxSTNDTk1JcXhxaHhJL1V0R3REc2VwYmwrSHI0elh4MzZna3BCbXBoT2xkTFVYTHAzVEtibVVZRWJSWHcvZmRmeFQ3WDdZUFhHQ0hHVG1uTzk4WkxDOTA2Zmkvekd2b04rNlpzbCs3MkpWMGxJWEo0V3dZdWxFUmZHbkFDWGNoa0Yzei9ITWR3elcwTUFFaXptQmwvREo2ZUoyU01PSG1Uc25YbElGRDRlcFRrYnFBQ0dpZ2I1UExFdHdQRVRjYkNRckM5YUtTU1FnSTdEZXd1aWlxM2J0Y0RUWkIzeEI5WWxlbmhpU0FXNjIwcmwzc2ZjY3d3eGFSOHBDV2Rzd0x3dmFxcDhjM01PV3RCc2xPcmVTSkNEcWgvdzBYbm1WMFJVWFpNM2JvUmkwVXhsaHVUeDFlM1NTd09pbTlOczNYV3NoTmI4Lzc3VkhnUWhRVFlSUU1NRllYaWRmMElCKzBtSUpocWNoQTlUeUY3dGRjSDhrUUJUSHNEWS96bFpqK3EwNlFMd0JkbTkxc3IyK3VzZmxlaXB3WUMrcmdiNHROVnA3VU5rYkVqTnR6ZWZsTi9VRTlkbHZtT2x6V1dtZkh2NGVkUGkzMmJmeUNRS1d6SGJVVEV3NU0yVFpsZnpNaTFWUjVsaDBxQ1lqaDNITUlmL2MwcHBKd2I1b1lFTnBBenlxbnlmdmlTV3lBYzc2L1l1VWwvb2FVaysrYzBZc2d1TGo5ZGFQdVVvemhoZ3VjSytQRGlNckI0ODU1Mk83VWg0aHRwNmZ3S2dJa1JCTVFIUTd6MmV5WXovV1AwQm9ZZVhjOGc3aUprclhFNzA1bFo1bXhGU0poT3E1WlNleVJSb21pUm41K3VRemM5ZFdWQjBYb2JURXdOc0VRM2FIZ25JY29BczY2UGplUT09PC94ZW5jOkNpcGhlclZhbHVlPjwveGVuYzpDaXBoZXJEYXRhPjwveGVuYzpFbmNyeXB0ZWREYXRhPjwvc2FtbDI6RW5jcnlwdGVkQXNzZXJ0aW9uPjwvc2FtbDJwOlJlc3BvbnNlPg==
\ No newline at end of file
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/legacy.py b/rootfs/api/apps_extra/social_core/tests/backends/legacy.py
new file mode 100644
index 000000000..45443cea7
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/legacy.py
@@ -0,0 +1,47 @@
+import requests
+
+from httpretty import HTTPretty
+
+from ...utils import parse_qs
+from .base import BaseBackendTest
+
+
+class BaseLegacyTest(BaseBackendTest):
+    form = ''
+    response_body = ''
+
+    def setUp(self):
+        super().setUp()
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_{0}_FORM_URL'.format(self.name):
+                self.strategy.build_absolute_uri('/login/{0}'.format(
+                    self.backend.name))
+        })
+
+    def extra_settings(self):
+        return {'SOCIAL_AUTH_{0}_FORM_URL'.format(self.name):
+                '/login/{0}'.format(self.backend.name)}
+
+    def do_start(self):
+        start_url = self.strategy.build_absolute_uri(self.backend.start().url)
+        HTTPretty.register_uri(
+            HTTPretty.GET,
+            start_url,
+            status=200,
+            body=self.form.format(self.complete_url)
+        )
+        HTTPretty.register_uri(
+            HTTPretty.POST,
+            self.complete_url,
+            status=200,
+            body=self.response_body,
+            content_type='application/x-www-form-urlencoded'
+        )
+        response = requests.get(start_url)
+        self.assertEqual(response.text, self.form.format(self.complete_url))
+        response = requests.post(
+            self.complete_url,
+            data=parse_qs(self.response_body)
+        )
+        self.strategy.set_request_data(parse_qs(response.text), self.backend)
+        return self.backend.complete()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/oauth.py b/rootfs/api/apps_extra/social_core/tests/backends/oauth.py
new file mode 100644
index 000000000..9058a8ed9
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/oauth.py
@@ -0,0 +1,119 @@
+from urllib.parse import urlparse
+
+import requests
+from httpretty import HTTPretty
+
+from ...utils import parse_qs, url_add_parameters
+from ..models import User
+from .base import BaseBackendTest
+
+
+class BaseOAuthTest(BaseBackendTest):
+    backend = None
+    backend_path = None
+    user_data_body = None
+    user_data_url = ''
+    user_data_url_post = False
+    user_data_content_type = 'application/json'
+    access_token_body = None
+    access_token_status = 200
+    expected_username = ''
+
+    def extra_settings(self):
+        return {'SOCIAL_AUTH_' + self.name + '_KEY': 'a-key',
+                'SOCIAL_AUTH_' + self.name + '_SECRET': 'a-secret-key'}
+
+    def _method(self, method):
+        return {'GET': HTTPretty.GET,
+                'POST': HTTPretty.POST}[method]
+
+    def handle_state(self, start_url, target_url):
+        start_query = parse_qs(urlparse(start_url).query)
+        redirect_uri = start_query.get('redirect_uri')
+
+        if getattr(self.backend, 'STATE_PARAMETER', False):
+            if start_query.get('state'):
+                target_url = url_add_parameters(target_url, {
+                    'state': start_query['state']
+                })
+
+        if redirect_uri and getattr(self.backend, 'REDIRECT_STATE', False):
+            redirect_query = parse_qs(urlparse(redirect_uri).query)
+            if redirect_query.get('redirect_state'):
+                target_url = url_add_parameters(target_url, {
+                    'redirect_state': redirect_query['redirect_state']
+                })
+        return target_url
+
+    def auth_handlers(self, start_url):
+        target_url = self.handle_state(start_url,
+                                       self.strategy.build_absolute_uri(
+                                           self.complete_url
+                                       ))
+        HTTPretty.register_uri(HTTPretty.GET,
+                               start_url,
+                               status=301,
+                               location=target_url)
+        HTTPretty.register_uri(HTTPretty.GET,
+                               target_url,
+                               status=200,
+                               body='foobar')
+        HTTPretty.register_uri(self._method(self.backend.ACCESS_TOKEN_METHOD),
+                               uri=self.backend.access_token_url(),
+                               status=self.access_token_status,
+                               body=self.access_token_body or '',
+                               content_type='text/json')
+        if self.user_data_url:
+            HTTPretty.register_uri(HTTPretty.POST if self.user_data_url_post else HTTPretty.GET,
+                                   self.user_data_url,
+                                   body=self.user_data_body or '',
+                                   content_type=self.user_data_content_type)
+        return target_url
+
+    def do_start(self):
+        start_url = self.backend.start().url
+        target_url = self.auth_handlers(start_url)
+        response = requests.get(start_url)
+        self.assertEqual(response.url, target_url)
+        self.assertEqual(response.text, 'foobar')
+        self.strategy.set_request_data(parse_qs(urlparse(start_url).query),
+                                       self.backend)
+        self.strategy.set_request_data(parse_qs(urlparse(target_url).query),
+                                       self.backend)
+        return self.backend.complete()
+
+
+class OAuth1Test(BaseOAuthTest):
+    request_token_body = None
+    raw_complete_url = '/complete/{0}/?oauth_verifier=bazqux&' \
+        'oauth_token=foobar'
+
+    def request_token_handler(self):
+        HTTPretty.register_uri(self._method(self.backend.REQUEST_TOKEN_METHOD),
+                               self.backend.REQUEST_TOKEN_URL,
+                               body=self.request_token_body,
+                               status=200)
+
+    def do_start(self):
+        self.request_token_handler()
+        return super().do_start()
+
+
+class OAuth2Test(BaseOAuthTest):
+    raw_complete_url = '/complete/{0}/?code=foobar'
+    refresh_token_body = ''
+
+    def refresh_token_arguments(self):
+        return {}
+
+    def do_refresh_token(self):
+        self.do_login()
+        HTTPretty.register_uri(self._method(self.backend.REFRESH_TOKEN_METHOD),
+                               self.backend.refresh_token_url(),
+                               status=200,
+                               body=self.refresh_token_body)
+        user = list(User.cache.values())[0]
+        social = user.social[0]
+        social.refresh_token(strategy=self.strategy,
+                             **self.refresh_token_arguments())
+        return user, social
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/open_id.py b/rootfs/api/apps_extra/social_core/tests/backends/open_id.py
new file mode 100644
index 000000000..b3698b9b4
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/open_id.py
@@ -0,0 +1,104 @@
+# -*- coding: utf-8 -*-
+import sys
+from html.parser import HTMLParser
+
+import requests
+from httpretty import HTTPretty
+from openid import oidutil
+
+from ...backends.utils import load_backends
+from ...utils import parse_qs, module_member
+from ..models import TestStorage, User, TestUserSocialAuth, \
+    TestNonce, TestAssociation
+from ..strategy import TestStrategy
+from .base import BaseBackendTest
+
+
+sys.path.insert(0, '..')
+
+
+# Patch to remove the too-verbose output until a new version is released
+oidutil.log = lambda *args, **kwargs: None
+
+
+class FormHTMLParser(HTMLParser):
+    form = {}
+    inputs = {}
+
+    def handle_starttag(self, tag, attrs):
+        attrs = dict(attrs)
+        if tag == 'form':
+            self.form.update(attrs)
+        elif tag == 'input' and 'name' in attrs:
+            self.inputs[attrs['name']] = attrs['value']
+
+
+class OpenIdTest(BaseBackendTest):
+    backend_path = None
+    backend = None
+    access_token_body = None
+    user_data_body = None
+    user_data_url = ''
+    expected_username = ''
+    settings = None
+    partial_login_settings = None
+    raw_complete_url = '/complete/{0}/'
+
+    def setUp(self):
+        HTTPretty.enable()
+        Backend = module_member(self.backend_path)
+        self.strategy = TestStrategy(TestStorage)
+        self.complete_url = self.raw_complete_url.format(Backend.name)
+        self.backend = Backend(self.strategy, redirect_uri=self.complete_url)
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_AUTHENTICATION_BACKENDS': (
+                self.backend_path,
+                'social_core.tests.backends.test_broken.BrokenBackendAuth'
+            )
+        })
+        # Force backends loading to trash PSA cache
+        load_backends(
+            self.strategy.get_setting('SOCIAL_AUTH_AUTHENTICATION_BACKENDS'),
+            force_load=True
+        )
+
+    def tearDown(self):
+        self.strategy = None
+        User.reset_cache()
+        TestUserSocialAuth.reset_cache()
+        TestNonce.reset_cache()
+        TestAssociation.reset_cache()
+        HTTPretty.disable()
+
+    def get_form_data(self, html):
+        parser = FormHTMLParser()
+        parser.feed(html)
+        return parser.form, parser.inputs
+
+    def openid_url(self):
+        return self.backend.openid_url()
+
+    def post_start(self):
+        pass
+
+    def do_start(self):
+        HTTPretty.register_uri(HTTPretty.GET,
+                               self.openid_url(),
+                               status=200,
+                               body=self.discovery_body,
+                               content_type='application/xrds+xml')
+        start = self.backend.start()
+        self.post_start()
+        form, inputs = self.get_form_data(start)
+        HTTPretty.register_uri(HTTPretty.POST,
+                               form.get('action'),
+                               status=200,
+                               body=self.server_response)
+        response = requests.post(form.get('action'), data=inputs)
+        self.strategy.set_request_data(parse_qs(response.content),
+                                       self.backend)
+        HTTPretty.register_uri(HTTPretty.POST,
+                               form.get('action'),
+                               status=200,
+                               body='is_valid:true\n')
+        return self.backend.complete()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/open_id_connect.py b/rootfs/api/apps_extra/social_core/tests/backends/open_id_connect.py
new file mode 100644
index 000000000..6731039e2
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/open_id_connect.py
@@ -0,0 +1,181 @@
+# -*- coding: utf-8 -*-
+from ...exceptions import AuthTokenError
+import os
+import sys
+import json
+import datetime
+import base64
+from calendar import timegm
+
+from jose import jwt
+from httpretty import HTTPretty
+
+
+sys.path.insert(0, '..')
+
+
+TEST_ROOT = os.path.dirname(os.path.dirname(__file__))
+
+JWK_KEY = {
+    'kty': 'RSA',
+    'd': 'ZmswNokEvBcxW_Kvcy8mWUQOQCBdGbnM0xR7nhvGHC-Q24z3XAQWlMWbsmGc_R1o'
+         '_F3zK7DBlc3BokdRaO1KJirNmnHCw5TlnBlJrXiWpFBtVglUg98-4sRRO0VWnGXK'
+         'JPOkBQ6b_DYRO3b0o8CSpWowpiV6HB71cjXTqKPZf-aXU9WjCCAtxVjfIxgQFu5I'
+         '-G1Qah8mZeY8HK_y99L4f0siZcbUoaIcfeWBhxi14ODyuSAHt0sNEkhiIVBZE7QZ'
+         'm-SEP1ryT9VAaljbwHHPmg7NC26vtLZhvaBGbTTJnEH0ZubbN2PMzsfeNyoCIHy4'
+         '4QDSpQDCHfgcGOlHY_t5gQ',
+    'e': 'AQAB',
+    'use': 'sig',
+    'kid': 'testkey',
+    'alg': 'RS256',
+    'n': 'pUfcJ8WFrVue98Ygzb6KEQXHBzi8HavCu8VENB2As943--bHPcQ-nScXnrRFAUg8'
+         'H5ZltuOcHWvsGw_AQifSLmOCSWJAPkdNb0w0QzY7Re8NrPjCsP58Tytp5LicF0Ao'
+         'Ag28UK3JioY9hXHGvdZsWR1Rp3I-Z3nRBP6HyO18pEgcZ91c9aAzsqu80An9X4DA'
+         'b1lExtZorvcd5yTBzZgr-MUeytVRni2lDNEpa6OFuopHXmg27Hn3oWAaQlbymd4g'
+         'ifc01oahcwl3ze2tMK6gJxa_TdCf1y99Yq6oilmVvZJ8kwWWnbPE-oDmOVPVnEyT'
+         'vYVCvN4rBT1DQ-x0F1mo2Q',
+}
+
+JWK_PUBLIC_KEY = {key: value for key, value in JWK_KEY.items() if key != 'd'}
+
+
+class OpenIdConnectTestMixin:
+    """
+    Mixin to test OpenID Connect consumers. Inheriting classes should also
+    inherit OAuth2Test.
+    """
+    client_key = 'a-key'
+    client_secret = 'a-secret-key'
+    issuer = None  # id_token issuer
+    openid_config_body = None
+    key = None
+
+    def setUp(self):
+        super().setUp()
+        self.key = JWK_KEY.copy()
+        self.public_key = JWK_PUBLIC_KEY.copy()
+
+        HTTPretty.register_uri(HTTPretty.GET,
+                               self.backend.OIDC_ENDPOINT + '/.well-known/openid-configuration',
+                               status=200,
+                               body=self.openid_config_body
+                               )
+        oidc_config = json.loads(self.openid_config_body)
+
+        def jwks(_request, _uri, headers):
+            return 200, headers, json.dumps({'keys': [self.key]})
+
+        HTTPretty.register_uri(HTTPretty.GET,
+                               oidc_config.get('jwks_uri'),
+                               status=200,
+                               body=json.dumps({'keys': [self.public_key]}))
+
+    def extra_settings(self):
+        settings = super().extra_settings()
+        settings.update({
+            'SOCIAL_AUTH_{0}_KEY'.format(self.name): self.client_key,
+            'SOCIAL_AUTH_{0}_SECRET'.format(self.name): self.client_secret,
+            'SOCIAL_AUTH_{0}_ID_TOKEN_DECRYPTION_KEY'.format(self.name):
+                self.client_secret
+        })
+        return settings
+
+    def get_id_token(self, client_key=None, expiration_datetime=None,
+                     issue_datetime=None, nonce=None, issuer=None):
+        """
+        Return the id_token to be added to the access token body.
+        """
+        return {
+            'iss': issuer,
+            'nonce': nonce,
+            'aud': client_key,
+            'azp': client_key,
+            'exp': expiration_datetime,
+            'iat': issue_datetime,
+            'sub': '1234'
+        }
+
+    def prepare_access_token_body(self, client_key=None, tamper_message=False,
+                                  expiration_datetime=None,
+                                  issue_datetime=None, nonce=None,
+                                  issuer=None):
+        """
+        Prepares a provider access token response. Arguments:
+
+        client_id       -- (str) OAuth ID for the client that requested
+                                 authentication.
+        expiration_time -- (datetime) Date and time after which the response
+                                      should be considered invalid.
+        """
+
+        body = {'access_token': 'foobar', 'token_type': 'bearer'}
+        client_key = client_key or self.client_key
+        now = datetime.datetime.utcnow()
+        expiration_datetime = expiration_datetime or \
+            (now + datetime.timedelta(seconds=30))
+        issue_datetime = issue_datetime or now
+        nonce = nonce or 'a-nonce'
+        issuer = issuer or self.issuer
+        id_token = self.get_id_token(
+            client_key,
+            timegm(expiration_datetime.utctimetuple()),
+            timegm(issue_datetime.utctimetuple()),
+            nonce,
+            issuer
+        )
+
+        body['id_token'] = jwt.encode(
+            id_token,
+            key=dict(self.key,
+                     iat=timegm(issue_datetime.utctimetuple()),
+                     nonce=nonce),
+            algorithm='RS256',
+            access_token='foobar'
+        )
+
+        if tamper_message:
+            header, msg, sig = body['id_token'].split('.')
+            id_token['sub'] = '1235'
+            msg = base64.encodebytes(json.dumps(id_token).encode()).decode()
+            body['id_token'] = '.'.join([header, msg, sig])
+
+        return json.dumps(body)
+
+    def authtoken_raised(self, expected_message, **access_token_kwargs):
+        self.access_token_body = self.prepare_access_token_body(
+            **access_token_kwargs
+        )
+        with self.assertRaisesRegex(AuthTokenError, expected_message):
+            self.do_login()
+
+    def test_invalid_signature(self):
+        self.authtoken_raised(
+            'Token error: Signature verification failed',
+            tamper_message=True
+        )
+
+    def test_expired_signature(self):
+        expiration_datetime = datetime.datetime.utcnow() - \
+            datetime.timedelta(seconds=30)
+        self.authtoken_raised('Token error: Signature has expired',
+                              expiration_datetime=expiration_datetime)
+
+    def test_invalid_issuer(self):
+        self.authtoken_raised('Token error: Invalid issuer',
+                              issuer='someone-else')
+
+    def test_invalid_audience(self):
+        self.authtoken_raised('Token error: Invalid audience',
+                              client_key='someone-else')
+
+    def test_invalid_issue_time(self):
+        expiration_datetime = datetime.datetime.utcnow() - \
+            datetime.timedelta(hours=1)
+        self.authtoken_raised('Token error: Incorrect id_token: iat',
+                              issue_datetime=expiration_datetime)
+
+    def test_invalid_nonce(self):
+        self.authtoken_raised(
+            'Token error: Incorrect id_token: nonce',
+            nonce='something-wrong'
+        )
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_amazon.py b/rootfs/api/apps_extra/social_core/tests/backends/test_amazon.py
new file mode 100644
index 000000000..f78c4ea25
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_amazon.py
@@ -0,0 +1,48 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class AmazonOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.amazon.AmazonOAuth2'
+    user_data_url = 'https://api.amazon.com/user/profile'
+    expected_username = 'FooBar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    user_data_body = json.dumps({
+        'user_id': 'amzn1.account.ABCDE1234',
+        'email': 'foo@bar.com',
+        'name': 'Foo Bar'
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
+
+
+class AmazonOAuth2BrokenServerResponseTest(OAuth2Test):
+    backend_path = 'social_core.backends.amazon.AmazonOAuth2'
+    user_data_url = 'https://www.amazon.com/ap/user/profile'
+    expected_username = 'FooBar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    user_data_body = json.dumps({
+        'Request-Id': '02GGTU7CWMNFTV3KH3J6',
+        'Profile': {
+            'Name': 'Foo Bar',
+            'CustomerId': 'amzn1.account.ABCDE1234',
+            'PrimaryEmail': 'foo@bar.com'
+        }
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_angel.py b/rootfs/api/apps_extra/social_core/tests/backends/test_angel.py
new file mode 100644
index 000000000..cd2e499aa
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_angel.py
@@ -0,0 +1,39 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class AngelOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.angel.AngelOAuth2'
+    user_data_url = 'https://api.angel.co/1/me/'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    user_data_body = json.dumps({
+        'facebook_url': 'http://www.facebook.com/foobar',
+        'bio': None,
+        'name': 'Foo Bar',
+        'roles': [],
+        'github_url': None,
+        'angellist_url': 'https://angel.co/foobar',
+        'image': 'https://graph.facebook.com/foobar/picture?type=square',
+        'linkedin_url': None,
+        'locations': [],
+        'twitter_url': None,
+        'what_ive_built': None,
+        'dribbble_url': None,
+        'behance_url': None,
+        'blog_url': None,
+        'aboutme_url': None,
+        'follower_count': 0,
+        'online_bio_url': None,
+        'id': 101010
+    })
+    expected_username = 'foobar'
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_apple.py b/rootfs/api/apps_extra/social_core/tests/backends/test_apple.py
new file mode 100644
index 000000000..40571ca1d
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_apple.py
@@ -0,0 +1,53 @@
+import json
+
+from .oauth import OAuth2Test
+
+from unittest.mock import patch
+
+
+TEST_KEY = """
+-----BEGIN EC PRIVATE KEY-----
+MHcCAQEEIKQya8aIoeoOLeThk7Ad/lLyAo2fTp9IuhIpy2CivH/qoAoGCCqGSM49
+AwEHoUQDQgAEyEY7IMlNJtyaF/pdcM/PpQ8OCe19Sf1Yxq4HQsrB2b7QogB95Vjt
+6mTZDAhlXIBtuM/JLrdkMfPmwjVKLgxHAQ==
+-----END EC PRIVATE KEY-----
+"""
+
+
+token_data = {
+    'sub': '11011110101011011011111011101111',
+    'first_name': 'Foo',
+    'last_name': 'Bar',
+    'email': 'foobar@apple.com',
+}
+
+
+class AppleIdTest(OAuth2Test):
+    backend_path = 'social_core.backends.apple.AppleIdAuth'
+    user_data_url = 'https://appleid.apple.com/auth/authorize/'
+    id_token = 'a-id-token'
+    access_token_body = json.dumps({'id_token': id_token, 'access_token': 'a-test-token'})
+    expected_username = token_data['sub']
+
+    def extra_settings(self):
+        return {
+            'SOCIAL_AUTH_' + self.name + '_TEAM': 'a-team-id',
+            'SOCIAL_AUTH_' + self.name + '_KEY': 'a-key-id',
+            'SOCIAL_AUTH_' + self.name + '_CLIENT': 'a-client-id',
+            'SOCIAL_AUTH_' + self.name + '_SECRET': TEST_KEY,
+            'SOCIAL_AUTH_' + self.name + '_SCOPE': ['name', 'email'],
+        }
+
+    def test_login(self):
+        with patch('{}.{}'.format(self.backend_path, 'decode_id_token'),
+                   return_value=token_data) as decode_mock:
+            self.do_login()
+        assert decode_mock.called
+        assert decode_mock.call_args[0] == (self.id_token,)
+
+    def test_partial_pipeline(self):
+        with patch('{}.{}'.format(self.backend_path, 'decode_id_token'),
+                   return_value=token_data) as decode_mock:
+            self.do_partial_pipeline()
+        assert decode_mock.called
+        assert decode_mock.call_args[0] == (self.id_token,)
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_arcgis.py b/rootfs/api/apps_extra/social_core/tests/backends/test_arcgis.py
new file mode 100644
index 000000000..6ac8a4788
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_arcgis.py
@@ -0,0 +1,29 @@
+import json
+from .oauth import OAuth2Test
+
+
+class ArcGISOAuth2Test(OAuth2Test):
+    user_data_url = 'https://www.arcgis.com/sharing/rest/community/self'
+    backend_path = 'social_core.backends.arcgis.ArcGISOAuth2'
+    expected_username = 'gis@rocks.com'
+
+    user_data_body = json.dumps({
+        'first_name': 'Gis',
+        'last_name': 'Rocks',
+        'email': 'gis@rocks.com',
+        'fullName': 'Gis Rocks',
+        'username': 'gis@rocks.com'
+    })
+
+    access_token_body = json.dumps({
+        'access_token': 'CM-gcB85taGhRmoI7l3PSGaXUNsaLkTg-dHH7XtA9Dnlin'
+                        'PYKBBrIvFzhd1JtDhh7hEwSv_6eLLcLtUqe3gD6i1yaYYF'
+                        'pUQJwy8KEujke5AE87tP9XIoMtp4_l320pUL',
+        'expires_in': 86400
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_asana.py b/rootfs/api/apps_extra/social_core/tests/backends/test_asana.py
new file mode 100644
index 000000000..2df37b659
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_asana.py
@@ -0,0 +1,34 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class AsanaOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.asana.AsanaOAuth2'
+    user_data_url = 'https://app.asana.com/api/1.0/users/me'
+    expected_username = 'erlich@bachmanity.com'
+    access_token_body = json.dumps({
+        'access_token': 'aviato',
+        'token_type': 'bearer'
+    })
+    # https://asana.com/developers/api-reference/users
+    user_data_body = json.dumps({
+        'data': {
+            'id': 12345,
+            'name': 'Erlich Bachman',
+            'email': 'erlich@bachmanity.com',
+            'photo': None,
+            'workspaces': [
+                {
+                    'id': 123456,
+                    'name': 'Pied Piper'
+                }
+            ]
+        }
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_atlassian.py b/rootfs/api/apps_extra/social_core/tests/backends/test_atlassian.py
new file mode 100644
index 000000000..7838a8644
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_atlassian.py
@@ -0,0 +1,64 @@
+import json
+
+from httpretty import HTTPretty
+
+from .oauth import OAuth2Test
+
+
+class AtlassianOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.atlassian.AtlassianOAuth2'
+    tenant_url = 'https://api.atlassian.com/oauth/token/accessible-resources'
+    user_data_url = 'https://api.atlassian.com/ex/jira/FAKED_CLOUD_ID/rest/api/2/myself'
+    expected_username = 'erlich'
+    access_token_body = json.dumps({
+        'access_token': 'aviato',
+        'token_type': 'bearer'
+    })
+    tenant_data_body = json.dumps([
+        {
+            'id': 'FAKED_CLOUD_ID',
+            'name': 'bachmanity.com',
+            'avatarUrl': 'https://bachmanity.atlassian.net/avatars/240/site.png',
+            'scopes': [
+                'read:jira-user'
+            ]
+        }
+    ])
+    user_data_body = json.dumps({
+        'self': 'http://bachmanity.atlassian.net/rest/api/3/user?username=erlich',
+        'key': 'erlich',
+        'accountId': '99:27935d01-92a7-4687-8272-a9b8d3b2ae2e',
+        'name': 'erlich',
+        'emailAddress': 'erlich@bachmanity.com',
+        'avatarUrls': {
+            '48x48': 'http://bachmanity.atlassian.net/secure/useravatar?size=large&ownerId=erlich',
+            '24x24': 'http://bachmanity.atlassian.net/secure/useravatar?size=small&ownerId=erlich',
+            '16x16': 'http://bachmanity.atlassian.net/secure/useravatar?size=xsmall&ownerId=erlich',
+            '32x32': 'http://bachmanity.atlassian.net/secure/useravatar?size=medium&ownerId=erlich'
+        },
+        'displayName': 'Erlich Bachman',
+        'active': True,
+        'timeZone': 'Australia/Sydney',
+        'groups': {
+            'size': 3,
+            'items': []
+        },
+        'applicationRoles': {
+            'size': 1,
+            'items': []
+        }
+    })
+
+    def auth_handlers(self, start_url):
+        target_url = super().auth_handlers(start_url)
+        HTTPretty.register_uri(HTTPretty.GET,
+                               self.tenant_url,
+                               body=self.tenant_data_body,
+                               content_type='application/json')
+        return target_url
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_auth0.py b/rootfs/api/apps_extra/social_core/tests/backends/test_auth0.py
new file mode 100644
index 000000000..3f40a18e9
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_auth0.py
@@ -0,0 +1,68 @@
+
+import json
+
+from jose import jwt
+from httpretty import HTTPretty
+
+from .oauth import OAuth2Test
+
+JWK_KEY = {
+    'kty': 'RSA',
+    'd': 'ZmswNokEvBcxW_Kvcy8mWUQOQCBdGbnM0xR7nhvGHC-Q24z3XAQWlMWbsmGc_R1o'
+         '_F3zK7DBlc3BokdRaO1KJirNmnHCw5TlnBlJrXiWpFBtVglUg98-4sRRO0VWnGXK'
+         'JPOkBQ6b_DYRO3b0o8CSpWowpiV6HB71cjXTqKPZf-aXU9WjCCAtxVjfIxgQFu5I'
+         '-G1Qah8mZeY8HK_y99L4f0siZcbUoaIcfeWBhxi14ODyuSAHt0sNEkhiIVBZE7QZ'
+         'm-SEP1ryT9VAaljbwHHPmg7NC26vtLZhvaBGbTTJnEH0ZubbN2PMzsfeNyoCIHy4'
+         '4QDSpQDCHfgcGOlHY_t5gQ',
+    'e': 'AQAB',
+    'use': 'sig',
+    'kid': 'foobar',
+    'alg': 'RS256',
+    'n': 'pUfcJ8WFrVue98Ygzb6KEQXHBzi8HavCu8VENB2As943--bHPcQ-nScXnrRFAUg8'
+         'H5ZltuOcHWvsGw_AQifSLmOCSWJAPkdNb0w0QzY7Re8NrPjCsP58Tytp5LicF0Ao'
+         'Ag28UK3JioY9hXHGvdZsWR1Rp3I-Z3nRBP6HyO18pEgcZ91c9aAzsqu80An9X4DA'
+         'b1lExtZorvcd5yTBzZgr-MUeytVRni2lDNEpa6OFuopHXmg27Hn3oWAaQlbymd4g'
+         'ifc01oahcwl3ze2tMK6gJxa_TdCf1y99Yq6oilmVvZJ8kwWWnbPE-oDmOVPVnEyT'
+         'vYVCvN4rBT1DQ-x0F1mo2Q'
+}
+
+JWK_PUBLIC_KEY = {key: value for key, value in JWK_KEY.items() if key != 'd'}
+
+DOMAIN = 'foobar.auth0.com'
+
+
+class Auth0OAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.auth0.Auth0OAuth2'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer',
+        'expires_in': 86400,
+        'id_token': jwt.encode({
+            'nickname': 'foobar',
+            'email': 'foobar@auth0.com',
+            'name': 'John Doe',
+            'picture': 'http://example.com/image.png',
+            'sub': '123456',
+            'iss': 'https://{}/'.format(DOMAIN),
+        }, JWK_KEY, algorithm='RS256')
+    })
+    expected_username = 'foobar'
+    jwks_url = 'https://foobar.auth0.com/.well-known/jwks.json'
+
+    def extra_settings(self):
+        settings = super().extra_settings()
+        settings['SOCIAL_AUTH_' + self.name + '_DOMAIN'] = DOMAIN
+        return settings
+
+    def auth_handlers(self, start_url):
+        HTTPretty.register_uri(HTTPretty.GET,
+                               self.jwks_url,
+                               body=json.dumps({'keys': [JWK_PUBLIC_KEY]}),
+                               content_type='application/json')
+        return super().auth_handlers(start_url)
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_azuread.py b/rootfs/api/apps_extra/social_core/tests/backends/test_azuread.py
new file mode 100644
index 000000000..bfcc71da2
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_azuread.py
@@ -0,0 +1,73 @@
+"""
+Copyright (c) 2015 Microsoft Open Technologies, Inc.
+
+All rights reserved.
+
+MIT License
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+"""
+
+import json
+
+from .oauth import OAuth2Test
+
+
+class AzureADOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.azuread.AzureADOAuth2'
+    user_data_url = 'https://graph.windows.net/me'
+    expected_username = 'foobar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer',
+        'id_token': 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwczovL'
+                    '3N0cy53aW5kb3dzLm5ldC83Mjc0MDZhYy03MDY4LTQ4ZmEtOTJiOS1jMmQ'
+                    '2NzIxMWJjNTAvIiwiaWF0IjpudWxsLCJleHAiOm51bGwsImF1ZCI6IjAyO'
+                    'WNjMDEwLWJiNzQtNGQyYi1hMDQwLWY5Y2VkM2ZkMmM3NiIsInN1YiI6In'
+                    'FVOHhrczltSHFuVjZRMzR6aDdTQVpvY2loOUV6cnJJOW1wVlhPSWJWQTg'
+                    'iLCJ2ZXIiOiIxLjAiLCJ0aWQiOiI3Mjc0MDZhYy03MDY4LTQ4ZmEtOTJi'
+                    'OS1jMmQ2NzIxMWJjNTAiLCJvaWQiOiI3ZjhlMTk2OS04YjgxLTQzOGMtO'
+                    'GQ0ZS1hZDZmNTYyYjI4YmIiLCJ1cG4iOiJmb29iYXJAdGVzdC5vbm1pY3'
+                    'Jvc29mdC5jb20iLCJnaXZlbl9uYW1lIjoiZm9vIiwiZmFtaWx5X25hbWU'
+                    'iOiJiYXIiLCJuYW1lIjoiZm9vIGJhciIsInVuaXF1ZV9uYW1lIjoiZm9v'
+                    'YmFyQHRlc3Qub25taWNyb3NvZnQuY29tIiwicHdkX2V4cCI6IjQ3MzMwO'
+                    'TY4IiwicHdkX3VybCI6Imh0dHBzOi8vcG9ydGFsLm1pY3Jvc29mdG9ubG'
+                    'luZS5jb20vQ2hhbmdlUGFzc3dvcmQuYXNweCJ9.3V50dHXTZOHj9UWtkn'
+                    '2g7BjX5JxNe8skYlK4PdhiLz4',
+        'expires_in': 3600,
+        'expires_on': 1423650396,
+        'not_before': 1423646496
+    })
+    refresh_token_body = json.dumps({
+        'access_token': 'foobar-new-token',
+        'token_type': 'bearer',
+        'expires_in': 3600,
+        'refresh_token': 'foobar-new-refresh-token',
+        'scope': 'identity'
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
+
+    def test_refresh_token(self):
+        user, social = self.do_refresh_token()
+        self.assertEqual(social.extra_data['access_token'], 'foobar-new-token')
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_azuread_b2c.py b/rootfs/api/apps_extra/social_core/tests/backends/test_azuread_b2c.py
new file mode 100644
index 000000000..6c8a5e169
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_azuread_b2c.py
@@ -0,0 +1,177 @@
+"""
+Copyright (c) 2017 Noderabbit Inc., d.b.a. Appsembler
+
+All rights reserved.
+
+MIT License
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+"""
+import json
+import jwt
+
+from time import time
+from httpretty import HTTPretty
+from jwt.algorithms import RSAAlgorithm
+
+from .oauth import OAuth2Test
+
+
+# Dummy private and private keys:
+RSA_PUBLIC_JWT_KEY = {
+    # https://github.com/jpadilla/pyjwt/blob/06f461a/tests/keys/jwk_rsa_pub.json
+    'kty': 'RSA',
+    'kid': 'bilbo.baggins@hobbiton.example',
+    'use': 'sig',
+    'n': 'n4EPtAOCc9AlkeQHPzHStgAbgs7bTZLwUBZdR8_KuKPEHLd4rHVTeT-O-' + \
+         'XV2jRojdNhxJWTDvNd7nqQ0VEiZQHz_AJmSCpMaJMRBSFKrKb2wqVwGU_NsYOYL-' + \
+         'QtiWN2lbzcEe6XC0dApr5ydQLrHqkHHig3RBordaZ6Aj-oBHqFEHYpPe7Tpe-' + \
+         'OfVfHd1E6cS6M1FZcD1NNLYD5lFHpPI9bTwJlsde3uhGqC0ZCuEHg8lhzwOHrt' + \
+         'IQbS0FVbb9k3-tVTU4fg_3L_vniUFAKwuCLqKnS2BYwdq_mzSnbLY7h_qixoR7' + \
+         'jig3__kRhuaxwUkRz5iaiQkqgc5gHdrNP5zw',
+    'e': 'AQAB'
+}
+
+RSA_PRIVATE_JWT_KEY = {
+    # https://github.com/jpadilla/pyjwt/blob/06f461a/tests/keys/jwk_rsa_key.json
+    'kty': 'RSA',
+    'kid': 'bilbo.baggins@hobbiton.example',
+    'use': 'sig',
+    'n': 'n4EPtAOCc9AlkeQHPzHStgAbgs7bTZLwUBZdR8_KuKPEHLd4rHVTeT-O-' + \
+         'XV2jRojdNhxJWTDvNd7nqQ0VEiZQHz_AJmSCpMaJMRBSFKrKb2wqVwGU_NsYOYL-' + \
+         'QtiWN2lbzcEe6XC0dApr5ydQLrHqkHHig3RBordaZ6Aj-oBHqFEHYpPe7Tpe-' + \
+         'OfVfHd1E6cS6M1FZcD1NNLYD5lFHpPI9bTwJlsde3uhGqC0ZCuEHg8lhzwOHrtIQ' + \
+         'bS0FVbb9k3-tVTU4fg_3L_vniUFAKwuCLqKnS2BYwdq_mzSnbLY7h_qixoR7jig3' + \
+         '__kRhuaxwUkRz5iaiQkqgc5gHdrNP5zw',
+    'e': 'AQAB',
+    'd': 'bWUC9B-EFRIo8kpGfh0ZuyGPvMNKvYWNtB_ikiH9k20eT-O1q_I78eiZkpXxXQ0' + \
+         'UTEs2LsNRS-8uJbvQ-A1irkwMSMkK1J3XTGgdrhCku9gRldY7sNA_AKZGh-Q661_' + \
+         '42rINLRCe8W-nZ34ui_qOfkLnK9QWDDqpaIsA-bMwWWSDFu2MUBYwkHTMEzLYGqO' + \
+         'e04noqeq1hExBTHBOBdkMXiuFhUq1BU6l-DqEiWxqg82sXt2h-LMnT3046AOYJoR' + \
+         'ioz75tSUQfGCshWTBnP5uDjd18kKhyv07lhfSJdrPdM5Plyl21hsFf4L_mHCuoFa' + \
+         'u7gdsPfHPxxjVOcOpBrQzwQ',
+    'p': '3Slxg_DwTXJcb6095RoXygQCAZ5RnAvZlno1yhHtnUex_fp7AZ_9nRaO7HX_-' + \
+         'SFfGQeutao2TDjDAWU4Vupk8rw9JR0AzZ0N2fvuIAmr_WCsmGpeNqQnev1T7I' + \
+         'yEsnh8UMt-n5CafhkikzhEsrmndH6LxOrvRJlsPp6Zv8bUq0k',
+    'q': 'uKE2dh-cTf6ERF4k4e_jy78GfPYUIaUyoSSJuBzp3Cubk3OCqs6grT8bR_' + \
+         'cu0Dm1MZwWmtdqDyI95HrUeq3MP15vMMON8lHTeZu2lmKvwqW7anV5UzhM1iZ7' + \
+         'z4yMkuUwFWoBvyY898EXvRD-hdqRxHlSqAZ192zB3pVFJ0s7pFc',
+    'dp': 'B8PVvXkvJrj2L-GYQ7v3y9r6Kw5g9SahXBwsWUzp19TVlgI-YV85q1NIb1rxQtD' + \
+         '-IsXXR3-TanevuRPRt5OBOdiMGQp8pbt26gljYfKU_E9xn-RULHz0-ed9E9gXLKD' + \
+         '4VGngpz-PfQ_q29pk5xWHoJp009Qf1HvChixRX59ehik',
+    'dq': 'CLDmDGduhylc9o7r84rEUVn7pzQ6PF83Y-iBZx5NT-TpnOZKF1pErAMVeKzFEl4' + \
+         '1DlHHqqBLSM0W1sOFbwTxYWZDm6sI6og5iTbwQGIC3gnJKbi_7k_vJgGHwHxgPaX' + \
+         '2PnvP-zyEkDERuf-ry4c_Z11Cq9AqC2yeL6kdKT1cYF8',
+    'qi': '3PiqvXQN0zwMeE-sBvZgi289XP9XCQF3VWqPzMKnIgQp7_Tugo6-NZBKCQsMf3H' + \
+          'aEGBjTVJs_jcK8-TRXvaKe-7ZMaQj8VfBdYkssbu0NKDDhjJ-GtiseaDVWt7dcH' + \
+          '0cfwxgFUHpQh7FoCrjFJ6h6ZEpMF6xmujs4qMpPz8aaI4'
+}
+
+
+class AzureADOAuth2Test(OAuth2Test):
+    AUTH_KEY = 'abcdef12-1234-9876-0000-abcdef098765'
+    EXPIRES_IN = 3600
+    AUTH_TIME = int(time())
+    EXPIRES_ON = AUTH_TIME + EXPIRES_IN
+
+    backend_path = 'social_core.backends.azuread_b2c.AzureADB2COAuth2'
+    expected_username = 'FooBar'
+    refresh_token_body = json.dumps({
+        'access_token': 'foobar-new-token',
+        'token_type': 'bearer',
+        'expires_in': EXPIRES_IN,
+        'refresh_token': 'foobar-new-refresh-token',
+        'scope': 'identity'
+    })
+
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer',
+        'id_token': jwt.encode(
+            key=RSAAlgorithm.from_jwk(json.dumps(RSA_PRIVATE_JWT_KEY)),
+            headers={
+                'kid': RSA_PRIVATE_JWT_KEY['kid'],
+            },
+            algorithm='RS256',
+            payload={
+                'aud': AUTH_KEY,
+                'auth_time': AUTH_TIME,
+                'country': 'Axphain',
+                'emails': [
+                    'foobar@example.com'
+                ],
+                'exp': EXPIRES_ON,
+                'family_name': 'Bar',
+                'given_name': 'Foo',
+                'iat': AUTH_TIME,
+                'iss': 'https://login.microsoftonline.com/9a9a9a9a-1111-5555-0000-bc24adfdae00/v2.0/',
+                'name': 'FooBar',
+                'nbf': AUTH_TIME,
+                'oid': '11223344-5566-7788-9999-aabbccddeeff',
+                'postalCode': '00000',
+                'sub': '11223344-5566-7788-9999-aabbccddeeff',
+                'tfp': 'B2C_1_SignIn',
+                'ver': '1.0',
+            }
+        ),
+        'expires_in': EXPIRES_IN,
+        'expires_on': EXPIRES_ON,
+        'not_before': AUTH_TIME,
+    })
+
+    def extra_settings(self):
+        settings = super().extra_settings()
+        settings.update({
+            'SOCIAL_AUTH_' + self.name + '_POLICY': 'b2c_1_signin',
+            'SOCIAL_AUTH_' + self.name + '_KEY': self.AUTH_KEY,
+            'SOCIAL_AUTH_' + self.name + '_TENANT_ID': 'footenant.onmicrosoft.com',
+        })
+        return settings
+
+    def setUp(self):
+        super().setUp()
+
+        keys_url = 'https://login.microsoftonline.com/footenant.onmicrosoft.com/discovery/v2.0/keys?p=b2c_1_signin'
+        keys_body = json.dumps({
+            'keys': [{
+                # Dummy public key that pairs with `access_token_body` key:
+                # https://github.com/jpadilla/pyjwt/blob/06f461a/tests/keys/jwk_rsa_pub.json
+                'kty': 'RSA',
+                'kid': 'bilbo.baggins@hobbiton.example',
+                'use': 'sig',
+                'n': 'n4EPtAOCc9AlkeQHPzHStgAbgs7bTZLwUBZdR8_KuKPEHLd4rHVTeT-O-X'
+                     'V2jRojdNhxJWTDvNd7nqQ0VEiZQHz_AJmSCpMaJMRBSFKrKb2wqVwGU_Ns'
+                     'YOYL-QtiWN2lbzcEe6XC0dApr5ydQLrHqkHHig3RBordaZ6Aj-oBHqFEHY'
+                     'pPe7Tpe-OfVfHd1E6cS6M1FZcD1NNLYD5lFHpPI9bTwJlsde3uhGqC0ZCu'
+                     'EHg8lhzwOHrtIQbS0FVbb9k3-tVTU4fg_3L_vniUFAKwuCLqKnS2BYwdq_'
+                     'mzSnbLY7h_qixoR7jig3__kRhuaxwUkRz5iaiQkqgc5gHdrNP5zw',
+                'e': 'AQAB',
+            }],
+        })
+        HTTPretty.register_uri(HTTPretty.GET, keys_url, status=200, body=keys_body)
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
+
+    def test_refresh_token(self):
+        user, social = self.do_refresh_token()
+        self.assertEqual(social.extra_data['access_token'], 'foobar-new-token')
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_behance.py b/rootfs/api/apps_extra/social_core/tests/backends/test_behance.py
new file mode 100644
index 000000000..ed57a0b5b
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_behance.py
@@ -0,0 +1,51 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class BehanceOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.behance.BehanceOAuth2'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'valid': 1,
+        'user': {
+            'username': 'foobar',
+            'city': 'Foo City',
+            'first_name': 'Foo',
+            'last_name': 'Bar',
+            'display_name': 'Foo Bar',
+            'url': 'http://www.behance.net/foobar',
+            'country': 'Fooland',
+            'company': '',
+            'created_on': 1355152329,
+            'state': '',
+            'fields': [
+                'Programming',
+                'Web Design',
+                'Web Development'
+            ],
+            'images': {
+                '32': 'https://www.behance.net/assets/img/profile/'
+                      'no-image-32.jpg',
+                '50': 'https://www.behance.net/assets/img/profile/'
+                      'no-image-50.jpg',
+                '115': 'https://www.behance.net/assets/img/profile/'
+                       'no-image-138.jpg',
+                '129': 'https://www.behance.net/assets/img/profile/'
+                       'no-image-138.jpg',
+                '138': 'https://www.behance.net/assets/img/profile/'
+                       'no-image-138.jpg',
+                '78': 'https://www.behance.net/assets/img/profile/'
+                      'no-image-78.jpg'
+            },
+            'id': 1010101,
+            'occupation': 'Software Developer'
+        }
+    })
+    expected_username = 'foobar'
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_bitbucket.py b/rootfs/api/apps_extra/social_core/tests/backends/test_bitbucket.py
new file mode 100644
index 000000000..263f64190
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_bitbucket.py
@@ -0,0 +1,166 @@
+import json
+from urllib.parse import urlencode
+
+from httpretty import HTTPretty
+
+from ...exceptions import AuthForbidden
+from .oauth import OAuth1Test, OAuth2Test
+
+
+class BitbucketOAuthMixin:
+    user_data_url = 'https://api.bitbucket.org/2.0/user'
+    expected_username = 'foobar'
+    bb_api_user_emails = 'https://api.bitbucket.org/2.0/user/emails'
+
+    user_data_body = json.dumps({
+        u'created_on': u'2012-03-29T18:07:38+00:00',
+        u'display_name': u'Foo Bar',
+        u'links': {
+            u'avatar': {u'href': u'https://bitbucket.org/account/foobar/avatar/32/'},
+            u'followers': {u'href': u'https://api.bitbucket.org/2.0/users/foobar/followers'},
+            u'following': {u'href': u'https://api.bitbucket.org/2.0/users/foobar/following'},
+            u'hooks': {u'href': u'https://api.bitbucket.org/2.0/users/foobar/hooks'},
+            u'html': {u'href': u'https://bitbucket.org/foobar'},
+            u'repositories': {u'href': u'https://api.bitbucket.org/2.0/repositories/foobar'},
+            u'self': {u'href': u'https://api.bitbucket.org/2.0/users/foobar'}},
+        u'location': u'Fooville, Bar',
+        u'type': u'user',
+        u'username': u'foobar',
+        u'uuid': u'{397621dc-0f78-329f-8d6d-727396248e3f}',
+        u'website': u'http://foobar.com'
+    })
+
+    emails_body = json.dumps({
+        u'page': 1,
+        u'pagelen': 10,
+        u'size': 2,
+        u'values': [
+            {
+                u'email': u'foo@bar.com',
+                u'is_confirmed': True,
+                u'is_primary': True,
+                u'links': {u'self': {u'href': u'https://api.bitbucket.org/2.0/user/emails/foo@bar.com'}},
+                u'type': u'email'
+            },
+            {
+                u'email': u'not@confirme.com',
+                u'is_confirmed': False,
+                u'is_primary': False,
+                u'links': {u'self': {u'href': u'https://api.bitbucket.org/2.0/user/emails/not@confirmed.com'}},
+                u'type': u'email'
+            }
+        ]
+    })
+
+
+class BitbucketOAuth1Test(BitbucketOAuthMixin, OAuth1Test):
+    backend_path = 'social_core.backends.bitbucket.BitbucketOAuth'
+
+    request_token_body = urlencode({
+        'oauth_token_secret': 'foobar-secret',
+        'oauth_token': 'foobar',
+        'oauth_callback_confirmed': 'true'
+    })
+
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+
+    def test_login(self):
+        HTTPretty.register_uri(HTTPretty.GET,
+                               self.bb_api_user_emails,
+                               status=200, body=self.emails_body)
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        HTTPretty.register_uri(HTTPretty.GET,
+                               self.bb_api_user_emails,
+                               status=200, body=self.emails_body)
+        self.do_partial_pipeline()
+
+
+class BitbucketOAuth1FailTest(BitbucketOAuth1Test):
+    emails_body = json.dumps({
+        u'page': 1,
+        u'pagelen': 10,
+        u'size': 1,
+        u'values': [
+            {
+                u'email': u'foo@bar.com',
+                u'is_confirmed': False,
+                u'is_primary': True,
+                u'links': {u'self': {u'href': u'https://api.bitbucket.org/2.0/user/emails/foo@bar.com'}},
+                u'type': u'email'
+            }
+        ]
+    })
+
+    def test_login(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_BITBUCKET_VERIFIED_EMAILS_ONLY': True
+        })
+        with self.assertRaises(AuthForbidden):
+            super().test_login()
+
+    def test_partial_pipeline(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_BITBUCKET_VERIFIED_EMAILS_ONLY': True
+        })
+        with self.assertRaises(AuthForbidden):
+            super().test_partial_pipeline()
+
+
+class BitbucketOAuth2Test(BitbucketOAuthMixin, OAuth2Test):
+    backend_path = 'social_core.backends.bitbucket.BitbucketOAuth2'
+
+    access_token_body = json.dumps({
+        'access_token': 'foobar_access',
+        'scopes': 'foo_scope',
+        'expires_in': 3600,
+        'refresh_token': 'foobar_refresh',
+        'token_type': 'bearer'
+    })
+
+    def test_login(self):
+        HTTPretty.register_uri(HTTPretty.GET,
+                               self.bb_api_user_emails,
+                               status=200, body=self.emails_body)
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        HTTPretty.register_uri(HTTPretty.GET,
+                               self.bb_api_user_emails,
+                               status=200, body=self.emails_body)
+        self.do_partial_pipeline()
+
+
+class BitbucketOAuth2FailTest(BitbucketOAuth2Test):
+    emails_body = json.dumps({
+        u'page': 1,
+        u'pagelen': 10,
+        u'size': 1,
+        u'values': [
+            {
+                u'email': u'foo@bar.com',
+                u'is_confirmed': False,
+                u'is_primary': True,
+                u'links': {u'self': {u'href': u'https://api.bitbucket.org/2.0/user/emails/foo@bar.com'}},
+                u'type': u'email'
+            }
+        ]
+    })
+
+    def test_login(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_BITBUCKET_OAUTH2_VERIFIED_EMAILS_ONLY': True
+        })
+        with self.assertRaises(AuthForbidden):
+            super().test_login()
+
+    def test_partial_pipeline(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_BITBUCKET_OAUTH2_VERIFIED_EMAILS_ONLY': True
+        })
+        with self.assertRaises(AuthForbidden):
+            super().test_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_box.py b/rootfs/api/apps_extra/social_core/tests/backends/test_box.py
new file mode 100644
index 000000000..ca66562e8
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_box.py
@@ -0,0 +1,68 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class BoxOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.box.BoxOAuth2'
+    user_data_url = 'https://api.box.com/2.0/users/me'
+    expected_username = 'sean+awesome@box.com'
+    access_token_body = json.dumps({
+        'access_token': 'T9cE5asGnuyYCCqIZFoWjFHvNbvVqHjl',
+        'expires_in': 3600,
+        'restricted_to': [],
+        'token_type': 'bearer',
+        'refresh_token': 'J7rxTiWOHMoSC1isKZKBZWizoRXjkQzig5C6jFgCVJ9bU'
+                         'nsUfGMinKBDLZWP9BgR'
+    })
+    user_data_body = json.dumps({
+        'type': 'user',
+        'id': '181216415',
+        'name': 'sean rose',
+        'login': 'sean+awesome@box.com',
+        'created_at': '2012-05-03T21:39:11-07:00',
+        'modified_at': '2012-11-14T11:21:32-08:00',
+        'role': 'admin',
+        'language': 'en',
+        'space_amount': 11345156112,
+        'space_used': 1237009912,
+        'max_upload_size': 2147483648,
+        'tracking_codes': [],
+        'can_see_managed_users': True,
+        'is_sync_enabled': True,
+        'status': 'active',
+        'job_title': '',
+        'phone': '6509241374',
+        'address': '',
+        'avatar_url': 'https://www.box.com/api/avatar/large/181216415',
+        'is_exempt_from_device_limits': False,
+        'is_exempt_from_login_verification': False,
+        'enterprise': {
+            'type': 'enterprise',
+            'id': '17077211',
+            'name': 'seanrose enterprise'
+        }
+    })
+    refresh_token_body = json.dumps({
+        'access_token': 'T9cE5asGnuyYCCqIZFoWjFHvNbvVqHjl',
+        'expires_in': 3600,
+        'restricted_to': [],
+        'token_type': 'bearer',
+        'refresh_token': 'J7rxTiWOHMoSC1isKZKBZWizoRXjkQzig5C6jFgCVJ9b'
+                         'UnsUfGMinKBDLZWP9BgR'
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
+
+    def refresh_token_arguments(self):
+        uri = self.strategy.build_absolute_uri('/complete/box/')
+        return {'redirect_uri': uri}
+
+    def test_refresh_token(self):
+        user, social = self.do_refresh_token()
+        self.assertEqual(social.extra_data['access_token'],
+                         'T9cE5asGnuyYCCqIZFoWjFHvNbvVqHjl')
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_broken.py b/rootfs/api/apps_extra/social_core/tests/backends/test_broken.py
new file mode 100644
index 000000000..c0d036230
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_broken.py
@@ -0,0 +1,37 @@
+import unittest
+
+from ...backends.base import BaseAuth
+from ..strategy import TestStrategy
+from ..models import TestStorage
+
+
+class BrokenBackendAuth(BaseAuth):
+    name = 'broken'
+
+
+class BrokenBackendTest(unittest.TestCase):
+    def setUp(self):
+        self.backend = BrokenBackendAuth(TestStrategy(TestStorage))
+
+    def tearDown(self):
+        self.backend = None
+
+    def test_auth_url(self):
+        with self.assertRaisesRegex(NotImplementedError,
+                                    'Implement in subclass'):
+            self.backend.auth_url()
+
+    def test_auth_html(self):
+        with self.assertRaisesRegex(NotImplementedError,
+                                    'Implement in subclass'):
+            self.backend.auth_html()
+
+    def test_auth_complete(self):
+        with self.assertRaisesRegex(NotImplementedError,
+                                    'Implement in subclass'):
+            self.backend.auth_complete()
+
+    def test_get_user_details(self):
+        with self.assertRaisesRegex(NotImplementedError,
+                                    'Implement in subclass'):
+            self.backend.get_user_details(None)
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_chatwork.py b/rootfs/api/apps_extra/social_core/tests/backends/test_chatwork.py
new file mode 100644
index 000000000..3a0b42e7c
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_chatwork.py
@@ -0,0 +1,44 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class ChatworkOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.chatwork.ChatworkOAuth2'
+    user_data_url = 'https://api.chatwork.com/v2/me'
+    expected_username = 'hogehoge'
+    access_token_body = json.dumps({
+        'access_token': 'pyopyopyopyopyopyopyopyopyopyo',
+        'token_type': 'Bearer',
+        'expires_in': '1501138041000',
+        'refresh_token': 'pyopyopyopyopyopyo',
+        'scope': 'rooms.all:read_write'
+    })
+
+    user_data_body = json.dumps({
+        'account_id': 123,
+        'room_id': 322,
+        'name': 'Foo Bar',
+        'chatwork_id': 'hogehoge',
+        'organization_id': 101,
+        'organization_name': 'Foo foobar',
+        'department': 'Support',
+        'title': 'CMO',
+        'url': 'http://www.example.com',
+        'introduction': '',
+        'mail': 'hogehoge@example.com',
+        'tel_organization': '',
+        'tel_extension': '',
+        'tel_mobile': '',
+        'skype': '',
+        'facebook': '',
+        'twitter': '',
+        'avatar_image_url': 'https://www.example.com/hogehoge.jpg',
+        'login_mail': 'hogehoge@example.com'
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_clef.py b/rootfs/api/apps_extra/social_core/tests/backends/test_clef.py
new file mode 100644
index 000000000..1e6d93d53
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_clef.py
@@ -0,0 +1,26 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class ClefOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.clef.ClefOAuth2'
+    user_data_url = 'https://clef.io/api/v1/info'
+    expected_username = 'test'
+    access_token_body = json.dumps({
+        'access_token': 'foobar'
+    })
+    user_data_body = json.dumps({
+        'info': {
+            'id': '123456789',
+            'first_name': 'Test',
+            'last_name': 'User',
+            'email': 'test@example.com'
+        }
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_cognito.py b/rootfs/api/apps_extra/social_core/tests/backends/test_cognito.py
new file mode 100644
index 000000000..f90b5d1e0
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_cognito.py
@@ -0,0 +1,36 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class CognitoAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.cognito.CognitoOAuth2'
+    pool_domain = 'https://social_core.auth.eu-west-1.amazoncognito.com'
+    expected_username = 'cognito.account.ABCDE1234'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    user_data_body = json.dumps({
+        'given_name': 'John',
+        'family_name': 'Doe',
+        'username': 'cognito.account.ABCDE1234',
+        'email': 'john@doe.test',
+    })
+
+    @property
+    def user_data_url(self):
+        return self.backend.user_data_url()
+
+    def extra_settings(self):
+        settings = super().extra_settings()
+        settings.update({
+            'SOCIAL_AUTH_' + self.name + '_POOL_DOMAIN': self.pool_domain,
+        })
+        return settings
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_coinbase.py b/rootfs/api/apps_extra/social_core/tests/backends/test_coinbase.py
new file mode 100644
index 000000000..2f69f916c
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_coinbase.py
@@ -0,0 +1,32 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class CoinbaseOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.coinbase.CoinbaseOAuth2'
+    user_data_url = 'https://api.coinbase.com/v2/user'
+    expected_username = 'satoshi_nakomoto'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    user_data_body = json.dumps({
+        'data': {
+            'id': '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa',
+            'name': 'Satoshi Nakamoto',
+            'username': 'satoshi_nakomoto',
+            'profile_location': None,
+            'profile_bio': None,
+            'profile_url': 'https://coinbase.com/satoshi_nakomoto',
+            'avatar_url': None,
+            'resource': 'user',
+            'resource_path': '/v2/user'
+        }
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_coursera.py b/rootfs/api/apps_extra/social_core/tests/backends/test_coursera.py
new file mode 100644
index 000000000..aa5cce08a
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_coursera.py
@@ -0,0 +1,38 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class CourseraOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.coursera.CourseraOAuth2'
+    user_data_url = \
+        'https://api.coursera.org/api/externalBasicProfiles.v1?q=me'
+    expected_username = '560e7ed2076e0d589e88bd74b6aad4b7'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'Bearer',
+        'expires_in': 1795
+    })
+    request_token_body = json.dumps({
+        'code': 'foobar-code',
+        'client_id': 'foobar-client-id',
+        'client_secret': 'foobar-client-secret',
+        'redirect_uri': 'http://localhost:8000/accounts/coursera/',
+        'grant_type': 'authorization_code'
+    })
+    user_data_body = json.dumps({
+        'token_type': 'Bearer',
+        'paging': None,
+        'elements': [{
+            'id': '560e7ed2076e0d589e88bd74b6aad4b7'
+        }],
+        'access_token': 'foobar',
+        'expires_in': 1800,
+        'linked': None
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_dailymotion.py b/rootfs/api/apps_extra/social_core/tests/backends/test_dailymotion.py
new file mode 100644
index 000000000..96da8e026
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_dailymotion.py
@@ -0,0 +1,23 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class DailymotionOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.dailymotion.DailymotionOAuth2'
+    user_data_url = 'https://api.dailymotion.com/auth/'
+    expected_username = 'foobar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    user_data_body = json.dumps({
+        'id': 'foobar',
+        'screenname': 'foobar'
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_deezer.py b/rootfs/api/apps_extra/social_core/tests/backends/test_deezer.py
new file mode 100644
index 000000000..464c8e7e1
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_deezer.py
@@ -0,0 +1,36 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class DeezerOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.deezer.DeezerOAuth2'
+    user_data_url = 'http://api.deezer.com/user/me'
+    expected_username = 'foobar'
+    access_token_body = 'access_token=foobar&expires=0'
+    user_data_body = json.dumps({
+        'id': '1',
+        'name': 'foobar',
+        'lastname': '',
+        'firstname': '',
+        'status': 0,
+        'birthday': '1970-01-01',
+        'inscription_date': '2015-01-01',
+        'gender': 'M',
+        'link': 'https://www.deezer.com/profile/1',
+        'picture': 'https://api.deezer.com/user/1/image',
+        'picture_small': 'https://cdns-images.dzcdn.net/images/user//56x56-000000-80-0-0.jpg',
+        'picture_medium': 'https://cdns-images.dzcdn.net/images/user//250x250-000000-80-0-0.jpg',
+        'picture_big': 'https://cdns-images.dzcdn.net/images/user//500x500-000000-80-0-0.jpg',
+        'country': 'FR',
+        'lang': 'FR',
+        'is_kid': False,
+        'tracklist': 'https://api.deezer.com/user/1/flow',
+        'type': 'user'
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_digitalocean.py b/rootfs/api/apps_extra/social_core/tests/backends/test_digitalocean.py
new file mode 100644
index 000000000..d7d93a0cb
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_digitalocean.py
@@ -0,0 +1,34 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class DigitalOceanOAuthTest(OAuth2Test):
+    backend_path = 'social_core.backends.digitalocean.DigitalOceanOAuth'
+    user_data_url = 'https://api.digitalocean.com/v2/account'
+    expected_username = 'sammy@digitalocean.com'
+    access_token_body = json.dumps({
+        'access_token': '547cac21118ae7',
+        'token_type': 'bearer',
+        'expires_in': 2592000,
+        'refresh_token': '00a3aae641658d',
+        'scope': 'read write',
+        'info': {
+            'name': 'Sammy Shark',
+            'email': 'sammy@digitalocean.com'
+        }
+    })
+    user_data_body = json.dumps({
+        'account': {
+            'droplet_limit': 25,
+            'email': 'sammy@digitalocean.com',
+            'uuid': 'b6fr89dbf6d9156cace5f3c78dc9851d957381ef',
+            'email_verified': True
+        }
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_discourse.py b/rootfs/api/apps_extra/social_core/tests/backends/test_discourse.py
new file mode 100644
index 000000000..458f8b9ff
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_discourse.py
@@ -0,0 +1,79 @@
+# -*- coding: utf-8 -*-
+from urllib.parse import urlparse, parse_qs
+
+import requests
+from httpretty import HTTPretty
+
+from .base import BaseBackendTest
+from ...exceptions import AuthException
+
+
+class DiscourseTest(BaseBackendTest):
+    backend_path = 'social_core.backends.discourse.DiscourseAuth'
+    expected_username = 'beepboop'
+    raw_complete_url = '/complete/{0}/'
+
+    def post_start(self):
+        pass
+
+    def do_start(self):
+        self.post_start()
+        start = self.backend.start()
+        start_url = start.url
+        return_url = self.backend.redirect_uri
+        # NOTE: This is how we generated sso:
+        # sso = b64encode(urlencode({
+        #     'email': 'user@example.com',
+        #     'username': 'beepboop',
+        #     'nonce': '6YRje7xlXhpyeJ6qtvBeTUjHkXo1UCTQmCrzN8GXfja3AoAFk2' + \
+        #              'CieDRYgSqMYi4W',
+        #     'return_sso_url': 'http://myapp.com'
+        # }))
+        sso = 'dXNlcm5hbWU9YmVlcGJvb3Ambm9uY2U9NllSamU3eGxYaHB5ZUo2cXR2QmV' + \
+              'UVWpIa1hvMVVDVFFtQ3J6TjhHWGZqYTNBb0FGazJDaWVEUllnU3FNWWk0Vy' + \
+              'ZlbWFpbD11c2VyJTQwZXhhbXBsZS5jb20mcmV0dXJuX3Nzb191cmw9aHR0c' + \
+              'CUzQSUyRiUyRm15YXBwLmNvbQ=='
+        # NOTE: the signature was verified using the 'foo' key, like so:
+        # hmac.new('foo', sso, sha256).hexdigest()
+        sig = '04063f17c99a97b1a765c1e0d7bbb61afb8471d79a39ddcd6af5ba3c93eb10e1'
+        response_query_params = 'sso={0}&sig={1}'.format(sso, sig)
+
+        response_url = '{0}?{1}'.format(return_url, response_query_params)
+        HTTPretty.register_uri(
+            HTTPretty.GET, start_url, status=301, location=response_url
+        )
+        HTTPretty.register_uri(
+            HTTPretty.GET,
+            return_url,
+            status=200,
+            content_type='text/html',
+        )
+
+        response = requests.get(start_url)
+        query_values = dict(
+            (k, v[0]) for k, v in parse_qs(urlparse(response.url).query).items()
+        )
+        self.strategy.set_request_data(query_values, self.backend)
+
+        return self.backend.complete()
+
+    def test_login(self):
+        """
+        Test that we can authenticate with the Discourse IdP
+        """
+        # pretend we've started with a URL like /login/discourse:
+        self.strategy.set_settings(
+            {'SERVER_URL': 'http://example.com', 'SECRET': 'foo'}
+        )
+        self.do_login()
+
+    def test_failed_login(self):
+        """
+        Test that authentication fails when our request is signed with a
+        different secret than our payload
+        """
+        self.strategy.set_settings(
+            {'SERVER_URL': 'http://example.com', 'SECRET': 'bar'}
+        )
+        with self.assertRaises(AuthException):
+            self.do_login()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_disqus.py b/rootfs/api/apps_extra/social_core/tests/backends/test_disqus.py
new file mode 100644
index 000000000..c0de85d6e
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_disqus.py
@@ -0,0 +1,62 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class DisqusOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.disqus.DisqusOAuth2'
+    user_data_url = 'https://disqus.com/api/3.0/users/details.json'
+    expected_username = 'foobar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    user_data_body = json.dumps({
+        'code': 0,
+        'response': {
+            'username': 'foobar',
+            'numFollowers': 0,
+            'isFollowing': False,
+            'numFollowing': 0,
+            'name': 'Foo Bar',
+            'numPosts': 0,
+            'url': '',
+            'isAnonymous': False,
+            'rep': 1.231755,
+            'about': '',
+            'isFollowedBy': False,
+            'connections': {},
+            'emailHash': '5280f14cedf530b544aecc31fcfe0240',
+            'reputation': 1.231755,
+            'avatar': {
+                'small': {
+                    'permalink': 'https://disqus.com/api/users/avatars/'
+                                 'foobar.jpg',
+                    'cache': 'https://securecdn.disqus.com/uploads/'
+                             'users/453/4556/avatar32.jpg?1285535379'
+                },
+                'isCustom': False,
+                'permalink': 'https://disqus.com/api/users/avatars/foobar.jpg',
+                'cache': 'https://securecdn.disqus.com/uploads/users/453/'
+                         '4556/avatar92.jpg?1285535379',
+                'large': {
+                    'permalink': 'https://disqus.com/api/users/avatars/'
+                                 'foobar.jpg',
+                    'cache': 'https://securecdn.disqus.com/uploads/users/'
+                             '453/4556/avatar92.jpg?1285535379'
+                }
+            },
+            'profileUrl': 'http://disqus.com/foobar/',
+            'numLikesReceived': 0,
+            'isPrimary': True,
+            'joinedAt': '2010-09-26T21:09:39',
+            'id': '1010101',
+            'location': ''
+        }
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_dribbble.py b/rootfs/api/apps_extra/social_core/tests/backends/test_dribbble.py
new file mode 100644
index 000000000..b1b1690cf
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_dribbble.py
@@ -0,0 +1,26 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class DribbbleOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.dribbble.DribbbleOAuth2'
+    user_data_url = 'https://api.dribbble.com/v1/user'
+    expected_username = 'foobar'
+
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+
+    user_data_body = json.dumps({
+        'id': 'foobar',
+        'username': 'foobar',
+        'name': 'Foo Bar'
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_drip.py b/rootfs/api/apps_extra/social_core/tests/backends/test_drip.py
new file mode 100644
index 000000000..e8300d226
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_drip.py
@@ -0,0 +1,29 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class DripOAuthTest(OAuth2Test):
+    backend_path = 'social_core.backends.drip.DripOAuth'
+    user_data_url = 'https://api.getdrip.com/v2/user'
+    expected_username = 'other@example.com'
+    access_token_body = json.dumps({
+        'access_token': '822bbf7cd12243df',
+        'token_type': 'bearer',
+        'scope': 'public'
+    })
+
+    user_data_body = json.dumps({
+        'users': [
+            {
+                'email': 'other@example.com',
+                'name': None
+            }
+        ]
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_dropbox.py b/rootfs/api/apps_extra/social_core/tests/backends/test_dropbox.py
new file mode 100644
index 000000000..3268e07a9
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_dropbox.py
@@ -0,0 +1,31 @@
+import json
+from urllib.parse import urlencode
+
+from .oauth import OAuth2Test
+
+
+class DropboxOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.dropbox.DropboxOAuth2V2'
+    user_data_url = 'https://api.dropboxapi.com/2/users/get_current_account'
+    user_data_url_post = True
+    expected_username = 'dbidAAH4f99T0taONIb-OurWxbNQ6ywGRopQngc'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    user_data_body = json.dumps({
+        'account_id': 'dbid:AAH4f99T0taONIb-OurWxbNQ6ywGRopQngc',
+        'name': {
+            'given_name': 'Franz',
+            'surname': 'Ferdinand',
+            'familiar_name': 'Franz',
+            'display_name': 'Franz Ferdinand (Personal)',
+            'abbreviated_name': 'FF'
+        },
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_dummy.py b/rootfs/api/apps_extra/social_core/tests/backends/test_dummy.py
new file mode 100644
index 000000000..d508a1ae3
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_dummy.py
@@ -0,0 +1,170 @@
+import json
+import datetime
+import time
+
+from httpretty import HTTPretty
+
+from ...actions import do_disconnect
+from ...backends.oauth import BaseOAuth2
+from ...exceptions import AuthForbidden
+
+from ..models import User
+from .oauth import OAuth2Test
+
+
+class DummyOAuth2(BaseOAuth2):
+    name = 'dummy'
+    AUTHORIZATION_URL = 'http://dummy.com/oauth/authorize'
+    ACCESS_TOKEN_URL = 'http://dummy.com/oauth/access_token'
+    REVOKE_TOKEN_URL = 'https://dummy.com/oauth/revoke'
+    REVOKE_TOKEN_METHOD = 'GET'
+    GET_ALL_EXTRA_DATA = False
+    EXTRA_DATA = [
+        ('id', 'id'),
+        ('expires', 'expires'),
+        ('empty', 'empty', True),
+        'url'
+    ]
+
+    def get_user_details(self, response):
+        """Return user details from Github account"""
+        return {'username': response.get('username'),
+                'email': response.get('email', ''),
+                'first_name': response.get('first_name', ''),
+                'last_name': response.get('last_name', '')}
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        return self.get_json('http://dummy.com/user', params={
+            'access_token': access_token
+        })
+
+
+class Dummy2OAuth2(DummyOAuth2):
+    GET_ALL_EXTRA_DATA = True
+
+
+class DummyOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.tests.backends.test_dummy.DummyOAuth2'
+    user_data_url = 'http://dummy.com/user'
+    expected_username = 'foobar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    user_data_body = json.dumps({
+        'id': 1,
+        'username': 'foobar',
+        'url': 'http://dummy.com/user/foobar',
+        'first_name': 'Foo',
+        'last_name': 'Bar',
+        'email': 'foo@bAr.coM'  # mixed case domain for testing case sensitivity
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
+
+    def test_tokens(self):
+        user = self.do_login()
+        self.assertEqual(user.social[0].access_token, 'foobar')
+
+    def test_revoke_token(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_REVOKE_TOKENS_ON_DISCONNECT': True
+        })
+        self.do_login()
+        user = User.get(self.expected_username)
+        user.password = 'password'
+        HTTPretty.register_uri(self._method(self.backend.REVOKE_TOKEN_METHOD),
+                               self.backend.REVOKE_TOKEN_URL,
+                               status=200)
+        do_disconnect(self.backend, user)
+
+
+class WhitelistEmailsTest(DummyOAuth2Test):
+    def test_valid_login(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_WHITELISTED_EMAILS': ['foo@bar.com']
+        })
+        self.do_login()
+
+    def test_invalid_login(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_WHITELISTED_EMAILS': ['foo2@bar.com']
+        })
+        with self.assertRaises(AuthForbidden):
+            self.do_login()
+
+    def test_login_case_sensitive_local_part(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_WHITELISTED_EMAILS': ['fOo@bar.com']
+        })
+        self.do_login()
+
+    def test_login_case_sensitive_domain(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_WHITELISTED_EMAILS': ['foo@bAR.com']
+        })
+        self.do_login()
+
+class WhitelistDomainsTest(DummyOAuth2Test):
+    def test_valid_login(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_WHITELISTED_DOMAINS': ['bar.com']
+        })
+        self.do_login()
+
+    def test_invalid_login(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_WHITELISTED_EMAILS': ['bar2.com']
+        })
+        with self.assertRaises(AuthForbidden):
+            self.do_login()
+
+
+DELTA = datetime.timedelta(days=1)
+
+
+class ExpirationTimeTest(DummyOAuth2Test):
+    user_data_body = json.dumps({
+        'id': 1,
+        'username': 'foobar',
+        'url': 'http://dummy.com/user/foobar',
+        'first_name': 'Foo',
+        'last_name': 'Bar',
+        'email': 'foo@bar.com',
+        'expires': time.mktime((datetime.datetime.utcnow() +
+                                DELTA).timetuple())
+    })
+
+    def test_expires_time(self):
+        user = self.do_login()
+        social = user.social[0]
+        expiration = social.expiration_timedelta()
+        self.assertEqual(expiration <= DELTA, True)
+
+
+class AllExtraDataTest(DummyOAuth2Test):
+    backend_path = 'social_core.tests.backends.test_dummy.Dummy2OAuth2'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    user_data_body = json.dumps({
+        'id': 1,
+        'username': 'foobar',
+        'url': 'http://dummy.com/user/foobar',
+        'first_name': 'Foo',
+        'last_name': 'Bar',
+        'email': 'foo@bar.com',
+        'not_normally_in_extra_data': 'value'
+    })
+
+    def test_get_all_extra_data(self):
+        user = self.do_login()
+        social = user.social[0]
+        self.assertIn('not_normally_in_extra_data', social.extra_data)
+        self.assertEqual(len(social.extra_data), 10)  # Includes auth_time.
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_edmodo.py b/rootfs/api/apps_extra/social_core/tests/backends/test_edmodo.py
new file mode 100644
index 000000000..d1250826f
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_edmodo.py
@@ -0,0 +1,44 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class EdmodoOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.edmodo.EdmodoOAuth2'
+    user_data_url = 'https://api.edmodo.com/users/me'
+    expected_username = 'foobar12345'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    user_data_body = json.dumps({
+        'username': 'foobar12345',
+        'coppa_verified': False,
+        'first_name': 'Foo',
+        'last_name': 'Bar',
+        'premium': False,
+        'verified_institution_member': False,
+        'url': 'https://api.edmodo.com/users/12345',
+        'type': 'teacher',
+        'time_zone': None,
+        'end_level': None,
+        'start_level': None,
+        'locale': 'en',
+        'subjects': None,
+        'utc_offset': None,
+        'email': 'foo.bar@example.com',
+        'gender': None,
+        'about': None,
+        'user_title': None,
+        'id': 12345,
+        'avatars': {
+            'small': 'https://api.edmodo.com/users/12345/avatar?type=small&u=5a15xug93m53mi4ey3ck4fvkq',
+            'large': 'https://api.edmodo.com/users/12345/avatar?type=large&u=5a15xug93m53mi4ey3ck4fvkq'
+        }
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_elixir.py b/rootfs/api/apps_extra/social_core/tests/backends/test_elixir.py
new file mode 100644
index 000000000..766c76be9
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_elixir.py
@@ -0,0 +1,183 @@
+from .oauth import OAuth2Test
+from .open_id_connect import OpenIdConnectTestMixin
+
+
+class ElixirOpenIdConnectTest(OpenIdConnectTestMixin, OAuth2Test):
+    backend_path = 'social_core.backends.elixir.ElixirOpenIdConnect'
+    issuer = 'https://login.elixir-czech.org/oidc/'
+    openid_config_body = """
+    {
+        "claims_supported": [
+            "sub",
+            "name",
+            "preferred_username",
+            "given_name",
+            "family_name",
+            "middle_name",
+            "nickname",
+            "profile",
+            "picture",
+            "website",
+            "gender",
+            "zoneinfo",
+            "locale",
+            "updated_at",
+            "birthdate",
+            "email",
+            "email_verified",
+            "phone_number",
+            "phone_number_verified",
+            "address"
+        ],
+        "op_policy_uri": "https://login.elixir-czech.org/oidc/about",
+        "subject_types_supported": [
+            "public",
+            "pairwise"
+        ],
+        "request_parameter_supported": true,
+        "userinfo_signing_alg_values_supported": [
+            "HS256",
+            "HS384",
+            "HS512",
+            "RS256",
+            "RS384",
+            "RS512",
+            "ES256",
+            "ES384",
+            "ES512",
+            "PS256",
+            "PS384",
+            "PS512"
+        ],
+        "revocation_endpoint": "https://login.elixir-czech.org/oidc/revoke",
+        "issuer": "https://login.elixir-czech.org/oidc/",
+        "id_token_encryption_enc_values_supported": [
+            "A256CBC+HS512",
+            "A256GCM",
+            "A192GCM",
+            "A128GCM",
+            "A128CBC-HS256",
+            "A192CBC-HS384",
+            "A256CBC-HS512",
+            "A128CBC+HS256"
+        ],
+        "require_request_uri_registration": false,
+        "grant_types_supported": [
+            "authorization_code",
+            "implicit",
+            "urn:ietf:params:oauth:grant-type:jwt-bearer",
+            "client_credentials",
+            "urn:ietf:params:oauth:grant_type:redelegate",
+            "urn:ietf:params:oauth:grant-type:device_code"
+        ],
+        "token_endpoint": "https://login.elixir-czech.org/oidc/token",
+        "request_uri_parameter_supported": false,
+        "service_documentation": "https://login.elixir-czech.org/oidc/about",
+        "registration_endpoint": "https://login.elixir-czech.org/oidc/register",
+        "jwks_uri": "https://login.elixir-czech.org/oidc/jwk",
+        "userinfo_encryption_alg_values_supported": [
+            "RSA-OAEP",
+            "RSA-OAEP-256",
+            "RSA1_5"
+        ],
+        "scopes_supported": [],
+        "token_endpoint_auth_methods_supported": [
+            "client_secret_post",
+            "client_secret_basic",
+            "client_secret_jwt",
+            "private_key_jwt",
+            "none"
+        ],
+        "userinfo_encryption_enc_values_supported": [
+            "A256CBC+HS512",
+            "A256GCM",
+            "A192GCM",
+            "A128GCM",
+            "A128CBC-HS256",
+            "A192CBC-HS384",
+            "A256CBC-HS512",
+            "A128CBC+HS256"
+        ],
+        "claim_types_supported": [
+            "normal"
+        ],
+        "request_object_encryption_enc_values_supported": [
+            "A256CBC+HS512",
+            "A256GCM",
+            "A192GCM",
+            "A128GCM",
+            "A128CBC-HS256",
+            "A192CBC-HS384",
+            "A256CBC-HS512",
+            "A128CBC+HS256"
+        ],
+        "claims_parameter_supported": false,
+        "id_token_encryption_alg_values_supported": [
+            "RSA-OAEP",
+            "RSA-OAEP-256",
+            "RSA1_5"
+        ],
+        "code_challenge_methods_supported": [
+            "plain",
+            "S256"
+        ],
+        "token_endpoint_auth_signing_alg_values_supported": [
+            "HS256",
+            "HS384",
+            "HS512",
+            "RS256",
+            "RS384",
+            "RS512",
+            "ES256",
+            "ES384",
+            "ES512",
+            "PS256",
+            "PS384",
+            "PS512"
+        ],
+        "userinfo_endpoint": "https://login.elixir-czech.org/oidc/userinfo",
+        "introspection_endpoint": "https://login.elixir-czech.org/oidc/introspect",
+        "id_token_signing_alg_values_supported": [
+            "HS256",
+            "HS384",
+            "HS512",
+            "RS256",
+            "RS384",
+            "RS512",
+            "ES256",
+            "ES384",
+            "ES512",
+            "PS256",
+            "PS384",
+            "PS512",
+            "none"
+        ],
+        "device_authorization_endpoint": "https://login.elixir-czech.org/oidc/devicecode",
+        "op_tos_uri": "https://login.elixir-czech.org/oidc/about",
+        "request_object_encryption_alg_values_supported": [
+            "RSA-OAEP",
+            "RSA-OAEP-256",
+            "RSA1_5"
+        ],
+        "request_object_signing_alg_values_supported": [
+            "HS256",
+            "HS384",
+            "HS512",
+            "RS256",
+            "RS384",
+            "RS512",
+            "ES256",
+            "ES384",
+            "ES512",
+            "PS256",
+            "PS384",
+            "PS512"
+        ],
+        "response_types_supported": [
+            "code",
+            "token"
+        ],
+        "end_session_endpoint": "https://login.elixir-czech.org/oidc/endsession",
+        "authorization_endpoint": "https://login.elixir-czech.org/oidc/authorize"
+    }
+    """
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_email.py b/rootfs/api/apps_extra/social_core/tests/backends/test_email.py
new file mode 100644
index 000000000..d59a32b9d
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_email.py
@@ -0,0 +1,19 @@
+from .legacy import BaseLegacyTest
+
+
+class EmailTest(BaseLegacyTest):
+    backend_path = 'social_core.backends.email.EmailAuth'
+    expected_username = 'foo'
+    response_body = 'email=foo@bar.com'
+    form = """
+    <form method="post" action="{0}">
+        <input name="email" type="text">
+        <button>Submit</button>
+    </form>
+    """
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_eventbrite.py b/rootfs/api/apps_extra/social_core/tests/backends/test_eventbrite.py
new file mode 100644
index 000000000..9d2311331
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_eventbrite.py
@@ -0,0 +1,38 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class EventbriteAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.eventbrite.EventbriteOAuth2'
+    user_data_url = 'https://www.eventbriteapi.com/v3/users/me'
+    expected_username = 'sean+awesome@eventbrite.com'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    user_data_body = json.dumps({
+        'first_name': 'sean',
+        'last_name': 'rose',
+        'name': 'sean rose',
+        'access_token': 'YQEN5H2W2OTKQZWZMYER',
+        'emails': [
+            {
+                'verified': True,
+                'email': 'sean+awesome2@eventbrite.com',
+                'primary': False,
+            },
+            {
+                'verified': True,
+                'email': 'sean+awesome@eventbrite.com',
+                'primary': True,
+            },
+        ],
+        'token_type': 'bearer',
+        'image_id': None,
+        'is_public': False,
+        'id': '128559602587',
+    })
+
+    def test_login(self):
+        self.do_login()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_evernote.py b/rootfs/api/apps_extra/social_core/tests/backends/test_evernote.py
new file mode 100644
index 000000000..dc3eb702c
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_evernote.py
@@ -0,0 +1,56 @@
+from urllib.parse import urlencode
+
+from requests import HTTPError
+
+from ...exceptions import AuthCanceled
+from .oauth import OAuth1Test
+
+
+class EvernoteOAuth1Test(OAuth1Test):
+    backend_path = 'social_core.backends.evernote.EvernoteOAuth'
+    expected_username = '101010'
+    access_token_body = urlencode({
+        'edam_webApiUrlPrefix': 'https://sandbox.evernote.com/shard/s1/',
+        'edam_shard': 's1',
+        'oauth_token': 'foobar',
+        'edam_expires': '1395118279645',
+        'edam_userId': '101010',
+        'edam_noteStoreUrl': 'https://sandbox.evernote.com/shard/s1/notestore'
+    })
+    request_token_body = urlencode({
+        'oauth_token_secret': 'foobar-secret',
+        'oauth_token': 'foobar',
+        'oauth_callback_confirmed': 'true'
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
+
+
+class EvernoteOAuth1CanceledTest(EvernoteOAuth1Test):
+    access_token_status = 401
+
+    def test_login(self):
+        with self.assertRaises(AuthCanceled) as cm:
+            self.do_login()
+        self.assertTrue(cm.exception.response is not None)
+
+    def test_partial_pipeline(self):
+        with self.assertRaises(AuthCanceled) as cm:
+            self.do_partial_pipeline()
+        self.assertTrue(cm.exception.response is not None)
+
+
+class EvernoteOAuth1ErrorTest(EvernoteOAuth1Test):
+    access_token_status = 500
+
+    def test_login(self):
+        with self.assertRaises(HTTPError):
+            self.do_login()
+
+    def test_partial_pipeline(self):
+        with self.assertRaises(HTTPError):
+            self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_facebook.py b/rootfs/api/apps_extra/social_core/tests/backends/test_facebook.py
new file mode 100644
index 000000000..9822e33c6
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_facebook.py
@@ -0,0 +1,69 @@
+import json
+
+from ...backends.facebook import API_VERSION
+from ...exceptions import AuthUnknownError, AuthCanceled
+
+from .oauth import OAuth2Test
+
+
+class FacebookOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.facebook.FacebookOAuth2'
+    user_data_url = 'https://graph.facebook.com/v{version}/me'.format(
+        version=API_VERSION
+    )
+    expected_username = 'foobar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    user_data_body = json.dumps({
+        'username': 'foobar',
+        'first_name': 'Foo',
+        'last_name': 'Bar',
+        'verified': True,
+        'name': 'Foo Bar',
+        'gender': 'male',
+        'updated_time': '2013-02-13T14:59:42+0000',
+        'link': 'http://www.facebook.com/foobar',
+        'id': '110011001100010'
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
+
+
+class FacebookOAuth2WrongUserDataTest(FacebookOAuth2Test):
+    user_data_body = 'null'
+
+    def test_login(self):
+        with self.assertRaises(AuthUnknownError):
+            self.do_login()
+
+    def test_partial_pipeline(self):
+        with self.assertRaises(AuthUnknownError):
+            self.do_partial_pipeline()
+
+
+class FacebookOAuth2AuthCancelTest(FacebookOAuth2Test):
+    access_token_status = 400
+    access_token_body = json.dumps({
+        'error': {
+            'message': "redirect_uri isn't an absolute URI. Check RFC 3986.",
+            'code': 191,
+            'type': 'OAuthException',
+            'fbtrace_id': '123Abc'
+        }
+    })
+
+    def test_login(self):
+        with self.assertRaises(AuthCanceled) as cm:
+            self.do_login()
+        self.assertIn('error', cm.exception.response.json())
+
+    def test_partial_pipeline(self):
+        with self.assertRaises(AuthCanceled) as cm:
+            self.do_partial_pipeline()
+        self.assertIn('error', cm.exception.response.json())
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_fence.py b/rootfs/api/apps_extra/social_core/tests/backends/test_fence.py
new file mode 100644
index 000000000..24a8362e8
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_fence.py
@@ -0,0 +1,54 @@
+import json
+
+from .oauth import OAuth2Test
+from .open_id_connect import OpenIdConnectTestMixin
+
+
+class FenceOpenIdConnectTest(OpenIdConnectTestMixin, OAuth2Test):
+    backend_path = \
+        'social_core.backends.fence.Fence'
+    issuer = 'https://nci-crdc.datacommons.io/'
+    openid_config_body = json.dumps({
+        'issuer': 'https://nci-crdc.datacommons.io/',
+        'authorization_endpoint': 'https://nci-crdc.datacommons.io/user/oauth2/authorize',
+        'userinfo_endpoint': 'https://nci-crdc.datacommons.io/user/user/',
+        'token_endpoint': 'https://nci-crdc.datacommons.io/user/oauth2/token',
+        'revocation_endpoint': 'https://nci-crdc.datacommons.io/user/oauth2/revoke',
+        'jwks_uri': 'https://auth.globus.org/jwk.json',
+        'response_types_supported': [
+            'code',
+            'token',
+            'token id_token',
+            'id_token'
+        ],
+        'id_token_signing_alg_values_supported': [
+            'RS512'
+        ],
+        'scopes_supported': [
+            'ga4gh_passport_v1',
+            'openid',
+            'google_credentials',
+            'google_service_account',
+            'data',
+            'user',
+            'google_link',
+            'admin',
+            'fence'
+        ],
+        'token_endpoint_auth_methods_supported': [
+            'authorization_code',
+            'implicit'
+        ],
+        'claims_supported': [
+            'aud',
+            'sub',
+            'iss',
+            'exp',
+            'jti',
+            'auth_time',
+            'azp',
+            'nonce',
+            'context'
+        ],
+        'subject_types_supported': ['public']
+    })
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_fitbit.py b/rootfs/api/apps_extra/social_core/tests/backends/test_fitbit.py
new file mode 100644
index 000000000..265e1904f
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_fitbit.py
@@ -0,0 +1,50 @@
+import json
+from urllib.parse import urlencode
+
+from .oauth import OAuth1Test
+
+
+class FitbitOAuth1Test(OAuth1Test):
+    backend_path = 'social_core.backends.fitbit.FitbitOAuth1'
+    expected_username = 'foobar'
+    access_token_body = urlencode({
+        'oauth_token_secret': 'a-secret',
+        'encoded_user_id': '101010',
+        'oauth_token': 'foobar'
+    })
+    request_token_body = urlencode({
+        'oauth_token_secret': 'foobar-secret',
+        'oauth_token': 'foobar',
+        'oauth_callback_confirmed': 'true'
+    })
+    user_data_url = 'https://api.fitbit.com/1/user/-/profile.json'
+    user_data_body = json.dumps({
+        'user': {
+            'weightUnit': 'en_US',
+            'strideLengthWalking': 0,
+            'displayName': 'foobar',
+            'weight': 62.6,
+            'foodsLocale': 'en_US',
+            'heightUnit': 'en_US',
+            'locale': 'en_US',
+            'gender': 'NA',
+            'memberSince': '2011-12-26',
+            'offsetFromUTCMillis': -25200000,
+            'height': 0,
+            'timezone': 'America/Los_Angeles',
+            'dateOfBirth': '',
+            'encodedId': '101010',
+            'avatar': 'http://www.fitbit.com/images/profile/'
+                      'defaultProfile_100_male.gif',
+            'waterUnit': 'en_US',
+            'distanceUnit': 'en_US',
+            'glucoseUnit': 'en_US',
+            'strideLengthRunning': 0
+        }
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_five_hundred_px.py b/rootfs/api/apps_extra/social_core/tests/backends/test_five_hundred_px.py
new file mode 100644
index 000000000..1c951d441
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_five_hundred_px.py
@@ -0,0 +1,107 @@
+import json
+from urllib.parse import urlencode
+
+from .oauth import OAuth1Test
+
+
+class FiveHundredPxOAuth1Test(OAuth1Test):
+    backend_path = 'social_core.backends.five_hundred_px.FiveHundredPxOAuth'
+    user_data_url = 'https://api.500px.com/v1/users'
+    expected_username = 'foobar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    request_token_body = urlencode({
+        'oauth_token_secret': 'foobar-secret',
+        'oauth_token': 'foobar',
+        'oauth_callback_confirmed': 'true'
+    })
+    user_data_body = json.dumps({
+        'user': {
+            'id': 10101010,
+            'username': 'foobar',
+            'firstname': '',
+            'lastname': '',
+            'birthday': None,
+            'sex': 1,
+            'city': '',
+            'state': '',
+            'country': '',
+            'registration_date': '2011-11-11T00:00:00-00:00',
+            'about': '',
+            'usertype': 0,
+            'domain': 'UserName.500px.com',
+            'fotomoto_on': False,
+            'locale': 'en',
+            'show_nude': False,
+            'allow_sale_requests': 1,
+            'fullname': 'UserName',
+            'userpic_url': 'https://graph.facebook.com/v2.7/'
+                           '1000000000/picture?height=100&width=100',
+            'userpic_https_url': 'https://graph.facebook.com/v2.7/'
+                                 '1000000000/picture?'
+                                 'height=100&width=100',
+            'cover_url': None,
+            'upgrade_status': 0,
+            'store_on': False,
+            'photos_count': 0,
+            'galleries_count': 0,
+            'affection': 51,
+            'in_favorites_count': 0,
+            'friends_count': 2,
+            'followers_count': 3,
+            'analytics_code': None,
+            'invite_pending': False,
+            'invite_accepted': False,
+            'email': 'user@user.com',
+            'shadow_email': 'user@user.com',
+            'upload_limit': 20,
+            'upload_limit_expiry': '2021-11-11T00:00:00-00:00',
+            'upgrade_type': 0,
+            'upgrade_status_expiry': '2011-11-21',
+            'portfolio_enabled': False,
+            'auth': {
+                'facebook': 1,
+                'twitter': 0,
+                'google_oauth2': 1
+            },
+            'presubmit_for_licensing': None,
+            'contacts': {
+                'facebook': '1000000000'
+            },
+            'equipment': {},
+            'avatars': {
+                'default': {
+                    'http': 'https://graph.facebook.com/v2.7/'
+                            '1000000000/picture?height=100&width=100',
+                    'https': 'https://graph.facebook.com/v2.7/'
+                             '1000000000/picture?height=100&width=100'
+                },
+                'large': {
+                    'http': 'https://graph.facebook.com/v2.7/'
+                            '1000000000/picture?height=100&width=100',
+                    'https': 'https://graph.facebook.com/v2.7/'
+                             '1000000000/picture?height=100&width=100'
+                },
+                'small': {
+                    'http': 'https://graph.facebook.com/v2.7/'
+                            '1000000000/picture?height=100&width=100',
+                    'https': 'https://graph.facebook.com/v2.7/'
+                             '1000000000/picture?height=100&width=100'
+                },
+                'tiny': {
+                    'http': 'https://graph.facebook.com/v2.7/'
+                            '1000000000/picture?height=100&width=100',
+                    'https': 'https://graph.facebook.com/v2.7/'
+                             '1000000000/picture?height=100&width=100'
+                }
+            }
+        }
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_flat.py b/rootfs/api/apps_extra/social_core/tests/backends/test_flat.py
new file mode 100644
index 000000000..77e4a723a
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_flat.py
@@ -0,0 +1,36 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class FlatOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.flat.FlatOAuth2'
+    user_data_url = 'https://api.flat.io/v2/me'
+    expected_username = 'vincent'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    user_data_body = json.dumps({
+        'id': '541a137946fd04d57cb2e3c0',
+        'username': 'vincent',
+        'name': 'Vincent Foo',
+        'printableName': 'Vincent Foo',
+        'bio': 'Foo bio',
+        'instruments': [],
+        'picture': 'https://flat-prod-public.s3.amazonaws.com/00000000/a0d2cb86-ab1e-4fdb-9286-dd94aa6d386c.jpeg',
+        'registrationDate': '2014-09-17T23:04:25.042Z',
+        'htmlUrl': 'https://flat.io/vincent',
+        'starredScoresCount': 85,
+        'likedScoresCount': 85,
+        'followersCount': 183,
+        'followingCount': 52,
+        'ownedPublicScoresCount': 15,
+        'isPowerUser': True
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_flickr.py b/rootfs/api/apps_extra/social_core/tests/backends/test_flickr.py
new file mode 100644
index 000000000..fbebe58c7
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_flickr.py
@@ -0,0 +1,25 @@
+from urllib.parse import urlencode
+
+from .oauth import OAuth1Test
+
+
+class FlickrOAuth1Test(OAuth1Test):
+    backend_path = 'social_core.backends.flickr.FlickrOAuth'
+    expected_username = 'foobar'
+    access_token_body = urlencode({
+        'oauth_token_secret': 'a-secret',
+        'username': 'foobar',
+        'oauth_token': 'foobar',
+        'user_nsid': '10101010@N01'
+    })
+    request_token_body = urlencode({
+        'oauth_token_secret': 'foobar-secret',
+        'oauth_token': 'foobar',
+        'oauth_callback_confirmed': 'true'
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_foursquare.py b/rootfs/api/apps_extra/social_core/tests/backends/test_foursquare.py
new file mode 100644
index 000000000..1aa2f8d5b
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_foursquare.py
@@ -0,0 +1,127 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class FoursquareOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.foursquare.FoursquareOAuth2'
+    user_data_url = 'https://api.foursquare.com/v2/users/self'
+    expected_username = 'FooBar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    user_data_body = json.dumps({
+        'notifications': [{
+            'item': {
+                'unreadCount': 0
+            },
+            'type': 'notificationTray'
+        }],
+        'meta': {
+            'errorType': 'deprecated',
+            'code': 200,
+            'errorDetail': 'Please provide an API version to avoid future '
+                           'errors.See http://bit.ly/vywCav'
+        },
+        'response': {
+            'user': {
+                'photo': 'https://is0.4sqi.net/userpix_thumbs/'
+                         'BYKIT01VN4T4BISN.jpg',
+                'pings': False,
+                'homeCity': 'Foo, Bar',
+                'id': '1010101',
+                'badges': {
+                    'count': 0,
+                    'items': []
+                },
+                'friends': {
+                    'count': 1,
+                    'groups': [{
+                        'count': 0,
+                        'items': [],
+                        'type': 'friends',
+                        'name': 'Mutual friends'
+                    }, {
+                        'count': 1,
+                        'items': [{
+                            'bio': '',
+                            'gender': 'male',
+                            'firstName': 'Baz',
+                            'relationship': 'friend',
+                            'photo': 'https://is0.4sqi.net/userpix_thumbs/'
+                                     'BYKIT01VN4T4BISN.jpg',
+                            'lists': {
+                                'groups': [{
+                                    'count': 1,
+                                    'items': [],
+                                    'type': 'created'
+                                }]
+                            },
+                            'homeCity': 'Baz, Qux',
+                            'lastName': 'Qux',
+                            'tips': {
+                                'count': 0
+                            },
+                            'id': '10101010'
+                        }],
+                        'type': 'others',
+                        'name': 'Other friends'
+                    }]
+                },
+                'referralId': 'u-1010101',
+                'tips': {
+                    'count': 0
+                },
+                'type': 'user',
+                'todos': {
+                    'count': 0
+                },
+                'bio': '',
+                'relationship': 'self',
+                'lists': {
+                    'groups': [{
+                        'count': 1,
+                        'items': [],
+                        'type': 'created'
+                    }]
+                },
+                'photos': {
+                    'count': 0,
+                    'items': []
+                },
+                'checkinPings': 'off',
+                'scores': {
+                    'max': 0,
+                    'checkinsCount': 0,
+                    'goal': 50,
+                    'recent': 0
+                },
+                'checkins': {
+                    'count': 0
+                },
+                'firstName': 'Foo',
+                'gender': 'male',
+                'contact': {
+                    'email': 'foo@bar.com'
+                },
+                'lastName': 'Bar',
+                'following': {
+                    'count': 0
+                },
+                'requests': {
+                    'count': 0
+                },
+                'mayorships': {
+                    'count': 0,
+                    'items': []
+                }
+            }
+        }
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_github.py b/rootfs/api/apps_extra/social_core/tests/backends/test_github.py
new file mode 100644
index 000000000..53009f697
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_github.py
@@ -0,0 +1,183 @@
+import json
+
+from httpretty import HTTPretty
+
+from ...exceptions import AuthFailed
+
+from .oauth import OAuth2Test
+
+
+class GithubOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.github.GithubOAuth2'
+    user_data_url = 'https://api.github.com/user'
+    expected_username = 'foobar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    user_data_body = json.dumps({
+        'login': 'foobar',
+        'id': 1,
+        'avatar_url': 'https://github.com/images/error/foobar_happy.gif',
+        'gravatar_id': 'somehexcode',
+        'url': 'https://api.github.com/users/foobar',
+        'name': 'monalisa foobar',
+        'company': 'GitHub',
+        'blog': 'https://github.com/blog',
+        'location': 'San Francisco',
+        'email': 'foo@bar.com',
+        'hireable': False,
+        'bio': 'There once was...',
+        'public_repos': 2,
+        'public_gists': 1,
+        'followers': 20,
+        'following': 0,
+        'html_url': 'https://github.com/foobar',
+        'created_at': '2008-01-14T04:33:35Z',
+        'type': 'User',
+        'total_private_repos': 100,
+        'owned_private_repos': 100,
+        'private_gists': 81,
+        'disk_usage': 10000,
+        'collaborators': 8,
+        'plan': {
+            'name': 'Medium',
+            'space': 400,
+            'collaborators': 10,
+            'private_repos': 20
+        }
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
+
+
+class GithubOAuth2NoEmailTest(GithubOAuth2Test):
+    user_data_body = json.dumps({
+        'login': 'foobar',
+        'id': 1,
+        'avatar_url': 'https://github.com/images/error/foobar_happy.gif',
+        'gravatar_id': 'somehexcode',
+        'url': 'https://api.github.com/users/foobar',
+        'name': 'monalisa foobar',
+        'company': 'GitHub',
+        'blog': 'https://github.com/blog',
+        'location': 'San Francisco',
+        'email': '',
+        'hireable': False,
+        'bio': 'There once was...',
+        'public_repos': 2,
+        'public_gists': 1,
+        'followers': 20,
+        'following': 0,
+        'html_url': 'https://github.com/foobar',
+        'created_at': '2008-01-14T04:33:35Z',
+        'type': 'User',
+        'total_private_repos': 100,
+        'owned_private_repos': 100,
+        'private_gists': 81,
+        'disk_usage': 10000,
+        'collaborators': 8,
+        'plan': {
+            'name': 'Medium',
+            'space': 400,
+            'collaborators': 10,
+            'private_repos': 20
+        }
+    })
+
+    def test_login(self):
+        url = 'https://api.github.com/user/emails'
+        HTTPretty.register_uri(HTTPretty.GET, url, status=200,
+                               body=json.dumps(['foo@bar.com']),
+                               content_type='application/json')
+        self.do_login()
+
+    def test_login_next_format(self):
+        url = 'https://api.github.com/user/emails'
+        HTTPretty.register_uri(HTTPretty.GET, url, status=200,
+                               body=json.dumps([{'email': 'foo@bar.com'}]),
+                               content_type='application/json')
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
+
+
+class GithubOrganizationOAuth2Test(GithubOAuth2Test):
+    backend_path = 'social_core.backends.github.GithubOrganizationOAuth2'
+
+    def auth_handlers(self, start_url):
+        url = 'https://api.github.com/orgs/foobar/members/foobar'
+        HTTPretty.register_uri(HTTPretty.GET, url, status=204, body='')
+        return super().auth_handlers(start_url)
+
+    def test_login(self):
+        self.strategy.set_settings({'SOCIAL_AUTH_GITHUB_ORG_NAME': 'foobar'})
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.strategy.set_settings({'SOCIAL_AUTH_GITHUB_ORG_NAME': 'foobar'})
+        self.do_partial_pipeline()
+
+
+class GithubOrganizationOAuth2FailTest(GithubOAuth2Test):
+    backend_path = 'social_core.backends.github.GithubOrganizationOAuth2'
+
+    def auth_handlers(self, start_url):
+        url = 'https://api.github.com/orgs/foobar/members/foobar'
+        HTTPretty.register_uri(HTTPretty.GET, url, status=404,
+                               body='{"message": "Not Found"}',
+                               content_type='application/json')
+        return super().auth_handlers(start_url)
+
+    def test_login(self):
+        self.strategy.set_settings({'SOCIAL_AUTH_GITHUB_ORG_NAME': 'foobar'})
+        with self.assertRaises(AuthFailed):
+            self.do_login()
+
+    def test_partial_pipeline(self):
+        self.strategy.set_settings({'SOCIAL_AUTH_GITHUB_ORG_NAME': 'foobar'})
+        with self.assertRaises(AuthFailed):
+            self.do_partial_pipeline()
+
+
+class GithubTeamOAuth2Test(GithubOAuth2Test):
+    backend_path = 'social_core.backends.github.GithubTeamOAuth2'
+
+    def auth_handlers(self, start_url):
+        url = 'https://api.github.com/teams/123/members/foobar'
+        HTTPretty.register_uri(HTTPretty.GET, url, status=204, body='')
+        return super().auth_handlers(start_url)
+
+    def test_login(self):
+        self.strategy.set_settings({'SOCIAL_AUTH_GITHUB_TEAM_ID': '123'})
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.strategy.set_settings({'SOCIAL_AUTH_GITHUB_TEAM_ID': '123'})
+        self.do_partial_pipeline()
+
+
+class GithubTeamOAuth2FailTest(GithubOAuth2Test):
+    backend_path = 'social_core.backends.github.GithubTeamOAuth2'
+
+    def auth_handlers(self, start_url):
+        url = 'https://api.github.com/teams/123/members/foobar'
+        HTTPretty.register_uri(HTTPretty.GET, url, status=404,
+                               body='{"message": "Not Found"}',
+                               content_type='application/json')
+        return super().auth_handlers(start_url)
+
+    def test_login(self):
+        self.strategy.set_settings({'SOCIAL_AUTH_GITHUB_TEAM_ID': '123'})
+        with self.assertRaises(AuthFailed):
+            self.do_login()
+
+    def test_partial_pipeline(self):
+        self.strategy.set_settings({'SOCIAL_AUTH_GITHUB_TEAM_ID': '123'})
+        with self.assertRaises(AuthFailed):
+            self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_github_enterprise.py b/rootfs/api/apps_extra/social_core/tests/backends/test_github_enterprise.py
new file mode 100644
index 000000000..12f679971
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_github_enterprise.py
@@ -0,0 +1,235 @@
+import json
+
+from httpretty import HTTPretty
+
+from ...exceptions import AuthFailed
+
+from .oauth import OAuth2Test
+
+
+class GithubEnterpriseOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.github_enterprise.GithubEnterpriseOAuth2'
+    user_data_url = 'https://www.example.com/api/v3/user'
+    expected_username = 'foobar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    user_data_body = json.dumps({
+        'login': 'foobar',
+        'id': 1,
+        'avatar_url': 'https://www.example.com/images/error/foobar_happy.gif',
+        'gravatar_id': 'somehexcode',
+        'url': 'https://www.example.com/api/v3/users/foobar',
+        'name': 'monalisa foobar',
+        'company': 'GitHub',
+        'blog': 'https://www.example.com/blog',
+        'location': 'San Francisco',
+        'email': 'foo@bar.com',
+        'hireable': False,
+        'bio': 'There once was...',
+        'public_repos': 2,
+        'public_gists': 1,
+        'followers': 20,
+        'following': 0,
+        'html_url': 'https://www.example.com/foobar',
+        'created_at': '2008-01-14T04:33:35Z',
+        'type': 'User',
+        'total_private_repos': 100,
+        'owned_private_repos': 100,
+        'private_gists': 81,
+        'disk_usage': 10000,
+        'collaborators': 8,
+        'plan': {
+            'name': 'Medium',
+            'space': 400,
+            'collaborators': 10,
+            'private_repos': 20
+        }
+    })
+
+    def test_login(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_GITHUB_ENTERPRISE_URL': 'https://www.example.com'})
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL': 'https://www.example.com/api/v3'})
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_GITHUB_ENTERPRISE_URL': 'https://www.example.com'})
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL': 'https://www.example.com/api/v3'})
+        self.do_partial_pipeline()
+
+
+class GithubEnterpriseOAuth2NoEmailTest(GithubEnterpriseOAuth2Test):
+    user_data_body = json.dumps({
+        'login': 'foobar',
+        'id': 1,
+        'avatar_url': 'https://www.example.com/images/error/foobar_happy.gif',
+        'gravatar_id': 'somehexcode',
+        'url': 'https://www.example.com/api/v3/users/foobar',
+        'name': 'monalisa foobar',
+        'company': 'GitHub',
+        'blog': 'https://www.example.com/blog',
+        'location': 'San Francisco',
+        'email': '',
+        'hireable': False,
+        'bio': 'There once was...',
+        'public_repos': 2,
+        'public_gists': 1,
+        'followers': 20,
+        'following': 0,
+        'html_url': 'https://www.example.com/foobar',
+        'created_at': '2008-01-14T04:33:35Z',
+        'type': 'User',
+        'total_private_repos': 100,
+        'owned_private_repos': 100,
+        'private_gists': 81,
+        'disk_usage': 10000,
+        'collaborators': 8,
+        'plan': {
+            'name': 'Medium',
+            'space': 400,
+            'collaborators': 10,
+            'private_repos': 20
+        }
+    })
+
+    def test_login(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_GITHUB_ENTERPRISE_URL': 'https://www.example.com'})
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL': 'https://www.example.com/api/v3'})
+        url = 'https://www.example.com/api/v3/user/emails'
+        HTTPretty.register_uri(HTTPretty.GET, url, status=200,
+                               body=json.dumps(['foo@bar.com']),
+                               content_type='application/json')
+        self.do_login()
+
+    def test_login_next_format(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_GITHUB_ENTERPRISE_URL': 'https://www.example.com'})
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL': 'https://www.example.com/api/v3'})
+        url = 'https://www.example.com/api/v3/user/emails'
+        HTTPretty.register_uri(HTTPretty.GET, url, status=200,
+                               body=json.dumps([{'email': 'foo@bar.com'}]),
+                               content_type='application/json')
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_GITHUB_ENTERPRISE_URL': 'https://www.example.com'})
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_GITHUB_ENTERPRISE_API_URL': 'https://www.example.com/api/v3'})
+        self.do_partial_pipeline()
+
+
+class GithubEnterpriseOrganizationOAuth2Test(GithubEnterpriseOAuth2Test):
+    backend_path = 'social_core.backends.github_enterprise.GithubEnterpriseOrganizationOAuth2'
+
+    def auth_handlers(self, start_url):
+        url = 'https://www.example.com/api/v3/orgs/foobar/members/foobar'
+        HTTPretty.register_uri(HTTPretty.GET, url, status=204, body='')
+        return super().auth_handlers(start_url)
+
+    def test_login(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_URL': 'https://www.example.com'})
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_API_URL': 'https://www.example.com/api/v3'})
+        self.strategy.set_settings({'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_NAME': 'foobar'})
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_URL': 'https://www.example.com'})
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_API_URL': 'https://www.example.com/api/v3'})
+        self.strategy.set_settings({'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_NAME': 'foobar'})
+        self.do_partial_pipeline()
+
+
+class GithubEnterpriseOrganizationOAuth2FailTest(GithubEnterpriseOAuth2Test):
+    backend_path = 'social_core.backends.github_enterprise.GithubEnterpriseOrganizationOAuth2'
+
+    def auth_handlers(self, start_url):
+        url = 'https://www.example.com/api/v3/orgs/foobar/members/foobar'
+        HTTPretty.register_uri(HTTPretty.GET, url, status=404,
+                               body='{"message": "Not Found"}',
+                               content_type='application/json')
+        return super().auth_handlers(start_url)
+
+    def test_login(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_URL': 'https://www.example.com'})
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_API_URL': 'https://www.example.com/api/v3'})
+        self.strategy.set_settings({'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_NAME': 'foobar'})
+        with self.assertRaises(AuthFailed):
+            self.do_login()
+
+    def test_partial_pipeline(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_URL': 'https://www.example.com'})
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_API_URL': 'https://www.example.com/api/v3'})
+        self.strategy.set_settings({'SOCIAL_AUTH_GITHUB_ENTERPRISE_ORG_NAME': 'foobar'})
+        with self.assertRaises(AuthFailed):
+            self.do_partial_pipeline()
+
+
+class GithubEnterpriseTeamOAuth2Test(GithubEnterpriseOAuth2Test):
+    backend_path = 'social_core.backends.github_enterprise.GithubEnterpriseTeamOAuth2'
+
+    def auth_handlers(self, start_url):
+        url = 'https://www.example.com/api/v3/teams/123/members/foobar'
+        HTTPretty.register_uri(HTTPretty.GET, url, status=204, body='')
+        return super().auth_handlers(start_url)
+
+    def test_login(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_URL': 'https://www.example.com'})
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_API_URL': 'https://www.example.com/api/v3'})
+        self.strategy.set_settings({'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_ID': '123'})
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_URL': 'https://www.example.com'})
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_API_URL': 'https://www.example.com/api/v3'})
+        self.strategy.set_settings({'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_ID': '123'})
+        self.do_partial_pipeline()
+
+
+class GithubEnterpriseTeamOAuth2FailTest(GithubEnterpriseOAuth2Test):
+    backend_path = 'social_core.backends.github_enterprise.GithubEnterpriseTeamOAuth2'
+
+    def auth_handlers(self, start_url):
+        url = 'https://www.example.com/api/v3/teams/123/members/foobar'
+        HTTPretty.register_uri(HTTPretty.GET, url, status=404,
+                               body='{"message": "Not Found"}',
+                               content_type='application/json')
+        return super().auth_handlers(start_url)
+
+    def test_login(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_URL': 'https://www.example.com'})
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_API_URL': 'https://www.example.com/api/v3'})
+        self.strategy.set_settings({'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_ID': '123'})
+        with self.assertRaises(AuthFailed):
+            self.do_login()
+
+    def test_partial_pipeline(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_URL': 'https://www.example.com'})
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_API_URL': 'https://www.example.com/api/v3'})
+        self.strategy.set_settings({'SOCIAL_AUTH_GITHUB_ENTERPRISE_TEAM_ID': '123'})
+        with self.assertRaises(AuthFailed):
+            self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_gitlab.py b/rootfs/api/apps_extra/social_core/tests/backends/test_gitlab.py
new file mode 100644
index 000000000..c90e47aa2
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_gitlab.py
@@ -0,0 +1,103 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class GitLabOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.gitlab.GitLabOAuth2'
+    user_data_url = 'https://gitlab.com/api/v4/user'
+    expected_username = 'foobar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer',
+        'expires_in': 7200,
+        'refresh_token': 'barfoo'
+    })
+    user_data_body = json.dumps({
+        'two_factor_enabled': False,
+        'can_create_project': True,
+        'confirmed_at': '2016-12-28T12:26:19.256Z',
+        'twitter': '',
+        'linkedin': '',
+        'color_scheme_id': 1,
+        'web_url': 'https://gitlab.com/foobar',
+        'skype': '',
+        'identities': [],
+        'id': 123456,
+        'projects_limit': 100000,
+        'current_sign_in_at': '2016-12-28T12:26:19.795Z',
+        'state': 'active',
+        'location': None,
+        'email': 'foobar@example.com',
+        'website_url': '',
+        'username': 'foobar',
+        'bio': None,
+        'last_sign_in_at': '2016-12-28T12:26:19.795Z',
+        'is_admin': False,
+        'external': False,
+        'organization': None,
+        'name': 'Foo Bar',
+        'can_create_group': True,
+        'created_at': '2016-12-28T12:26:19.638Z',
+        'avatar_url': 'https://secure.gravatar.com/avatar/94d093eda664addd6e450d7e9881bcae?s=32&d=identicon',
+        'theme_id': 2
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
+
+
+class GitLabCustomDomainOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.gitlab.GitLabOAuth2'
+    user_data_url = 'https://example.com/api/v4/user'
+    expected_username = 'foobar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer',
+        'expires_in': 7200,
+        'refresh_token': 'barfoo'
+    })
+    user_data_body = json.dumps({
+        'two_factor_enabled': False,
+        'can_create_project': True,
+        'confirmed_at': '2016-12-28T12:26:19.256Z',
+        'twitter': '',
+        'linkedin': '',
+        'color_scheme_id': 1,
+        'web_url': 'https://example.com/foobar',
+        'skype': '',
+        'identities': [],
+        'id': 123456,
+        'projects_limit': 100000,
+        'current_sign_in_at': '2016-12-28T12:26:19.795Z',
+        'state': 'active',
+        'location': None,
+        'email': 'foobar@example.com',
+        'website_url': '',
+        'username': 'foobar',
+        'bio': None,
+        'last_sign_in_at': '2016-12-28T12:26:19.795Z',
+        'is_admin': False,
+        'external': False,
+        'organization': None,
+        'name': 'Foo Bar',
+        'can_create_group': True,
+        'created_at': '2016-12-28T12:26:19.638Z',
+        'avatar_url': 'https://secure.gravatar.com/avatar/94d093eda664addd6e450d7e9881bcae?s=32&d=identicon',
+        'theme_id': 2
+    })
+
+    def test_login(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_GITLAB_API_URL': 'https://example.com'
+        })
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_GITLAB_API_URL': 'https://example.com'
+        })
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_globus.py b/rootfs/api/apps_extra/social_core/tests/backends/test_globus.py
new file mode 100644
index 000000000..d9381bccb
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_globus.py
@@ -0,0 +1,48 @@
+import json
+
+from .oauth import OAuth2Test
+from .open_id_connect import OpenIdConnectTestMixin
+
+
+class GlobusOpenIdConnectTest(OpenIdConnectTestMixin, OAuth2Test):
+    backend_path = \
+        'social_core.backends.globus.GlobusOpenIdConnect'
+    issuer = 'https://auth.globus.org'
+    openid_config_body = json.dumps({
+        'issuer': 'https://auth.globus.org',
+        'authorization_endpoint': 'https://auth.globus.org/v2/oauth2/authorize',
+        'userinfo_endpoint': 'https://auth.globus.org/v2/oauth2/userinfo',
+        'token_endpoint': 'https://auth.globus.org/v2/oauth2/token',
+        'revocation_endpoint': 'https://auth.globus.org/v2/oauth2/token/revoke',
+        'jwks_uri': 'https://auth.globus.org/jwk.json',
+        'response_types_supported': [
+            'code',
+            'token',
+            'token id_token',
+            'id_token'
+        ],
+        'id_token_signing_alg_values_supported': [
+            'RS512'
+        ],
+        'scopes_supported': [
+            'openid',
+            'email',
+            'profile'
+        ],
+        'token_endpoint_auth_methods_supported': [
+            'client_secret_basic'
+        ],
+        'claims_supported': [
+            'at_hash',
+            'aud',
+            'email',
+            'exp',
+            'name',
+            'nonce',
+            'preferred_username',
+            'iat',
+            'iss',
+            'sub'
+        ],
+        'subject_types_supported': ['public']
+    })
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_google.py b/rootfs/api/apps_extra/social_core/tests/backends/test_google.py
new file mode 100644
index 000000000..e3c7b58a5
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_google.py
@@ -0,0 +1,160 @@
+import json
+from urllib.parse import urlencode
+
+from httpretty import HTTPretty
+
+from ...actions import do_disconnect
+
+from ..models import User
+from .oauth import OAuth1Test, OAuth2Test
+from .open_id_connect import OpenIdConnectTestMixin
+
+
+class GoogleOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.google.GoogleOAuth2'
+    user_data_url = 'https://www.googleapis.com/oauth2/v3/userinfo'
+    expected_username = 'foo'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    user_data_body = json.dumps({
+        'profile': 'https://plus.google.com/101010101010101010101',
+        'family_name': 'Bar',
+        'sub': '101010101010101010101',
+        'picture': 'https://lh5.googleusercontent.com/-ui-GqpNh5Ms/'
+                   'AAAAAAAAAAI/AAAAAAAAAZw/a7puhHMO_fg/photo.jpg',
+        'locale': 'en',
+        'email_verified': True,
+        'given_name': 'Foo',
+        'email': 'foo@bar.com',
+        'name': 'Foo Bar',
+    })
+
+    def test_login(self):
+        self.do_login()
+        last_request = HTTPretty.last_request
+        self.assertEqual(last_request.method, 'GET')
+        self.assertTrue(self.user_data_url.endswith(last_request.path))
+        self.assertEqual(
+            last_request.headers['Authorization'],
+            'Bearer foobar',
+        )
+        self.assertEqual(last_request.querystring, {})
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
+
+    def test_with_unique_user_id(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_GOOGLE_OAUTH2_USE_UNIQUE_USER_ID': True,
+        })
+        self.do_login()
+
+
+class GoogleOAuth1Test(OAuth1Test):
+    backend_path = 'social_core.backends.google.GoogleOAuth'
+    user_data_url = 'https://www.googleapis.com/userinfo/email'
+    expected_username = 'foobar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    request_token_body = urlencode({
+        'oauth_token_secret': 'foobar-secret',
+        'oauth_token': 'foobar',
+        'oauth_callback_confirmed': 'true'
+    })
+    user_data_body = urlencode({
+        'email': 'foobar@gmail.com',
+        'isVerified': 'true',
+        'id': '101010101010101010101'
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
+
+    def test_with_unique_user_id(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_GOOGLE_OAUTH_USE_UNIQUE_USER_ID': True
+        })
+        self.do_login()
+
+    def test_with_anonymous_key_and_secret(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_GOOGLE_OAUTH_KEY': None,
+            'SOCIAL_AUTH_GOOGLE_OAUTH_SECRET': None
+        })
+        self.do_login()
+
+
+class GoogleRevokeTokenTest(GoogleOAuth2Test):
+    def test_revoke_token(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_GOOGLE_OAUTH2_REVOKE_TOKENS_ON_DISCONNECT': True
+        })
+        self.do_login()
+        user = User.get(self.expected_username)
+        user.password = 'password'
+        HTTPretty.register_uri(self._method(self.backend.REVOKE_TOKEN_METHOD),
+                               self.backend.REVOKE_TOKEN_URL,
+                               status=200)
+        do_disconnect(self.backend, user)
+
+
+class GoogleOpenIdConnectTest(OpenIdConnectTestMixin, OAuth2Test):
+    backend_path = \
+        'social_core.backends.google_openidconnect.GoogleOpenIdConnect'
+    user_data_url = \
+        'https://www.googleapis.com/plus/v1/people/me/openIdConnect'
+    issuer = 'accounts.google.com'
+    openid_config_body = json.dumps({
+        'issuer': 'https://accounts.google.com',
+        'authorization_endpoint': 'https://accounts.google.com/o/oauth2/v2/auth',
+        'token_endpoint': 'https://www.googleapis.com/oauth2/v4/token',
+        'userinfo_endpoint': 'https://www.googleapis.com/oauth2/v3/userinfo',
+        'revocation_endpoint': 'https://accounts.google.com/o/oauth2/revoke',
+        'jwks_uri': 'https://www.googleapis.com/oauth2/v3/certs',
+        'response_types_supported': [
+            'code',
+            'token',
+            'id_token',
+            'code token',
+            'code id_token',
+            'token id_token',
+            'code token id_token',
+            'none',
+        ],
+        'subject_types_supported': [
+            'public',
+        ],
+        'id_token_signing_alg_values_supported': [
+            'RS256',
+        ],
+        'scopes_supported': [
+            'openid',
+            'email',
+            'profile',
+        ],
+        'token_endpoint_auth_methods_supported': [
+            'client_secret_post',
+            'client_secret_basic',
+        ],
+        'claims_supported': [
+            'aud',
+            'email',
+            'email_verified',
+            'exp',
+            'family_name',
+            'given_name',
+            'iat',
+            'iss',
+            'locale',
+            'name',
+            'picture',
+            'sub',
+        ],
+    })
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_instagram.py b/rootfs/api/apps_extra/social_core/tests/backends/test_instagram.py
new file mode 100644
index 000000000..e07e36db9
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_instagram.py
@@ -0,0 +1,33 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class InstagramOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.instagram.InstagramOAuth2'
+    user_data_url = 'https://graph.instagram.com/me'
+    expected_username = 'foobar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer',
+        'meta': {
+            'code': 200
+        },
+        'user': {
+            'username': 'foobar',
+            'id': '101010101'
+        }
+    })
+    user_data_body = json.dumps({
+        'meta': {
+            'code': 200
+        },
+        'username': 'foobar',
+        'id': '101010101'
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_itembase.py b/rootfs/api/apps_extra/social_core/tests/backends/test_itembase.py
new file mode 100644
index 000000000..1cfba6694
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_itembase.py
@@ -0,0 +1,45 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class ItembaseOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.itembase.ItembaseOAuth2'
+    user_data_url = 'https://users.itembase.com/v1/me'
+    expected_username = 'foobar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar-token',
+        'expires_in': 2592000,
+        'token_type': 'bearer',
+        'scope': 'user.minimal',
+        'refresh_token': 'foobar-refresh-token'
+    })
+    user_data_body = json.dumps({
+        'uuid': 'a4b91ee7-ec1a-49b9-afce-371dc8797749',
+        'username': 'foobar',
+        'email': 'foobar@itembase.biz',
+        'first_name': 'Foo',
+        'middle_name': None,
+        'last_name': 'Bar',
+        'name_format': 'first middle last',
+        'locale': 'en',
+        'preferred_currency': 'EUR'
+    })
+    refresh_token_body = json.dumps({
+        'access_token': 'foobar-new-token',
+        'expires_in': 2592000,
+        'token_type': 'bearer',
+        'scope': 'user.minimal',
+        'refresh_token': 'foobar-new-refresh-token'
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
+
+
+class ItembaseOAuth2SandboxTest(OAuth2Test):
+    backend_path = 'social_core.backends.itembase.ItembaseOAuth2Sandbox'
+    user_data_url = 'http://sandbox.users.itembase.io/v1/me'
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_kakao.py b/rootfs/api/apps_extra/social_core/tests/backends/test_kakao.py
new file mode 100644
index 000000000..545eee044
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_kakao.py
@@ -0,0 +1,31 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class KakaoOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.kakao.KakaoOAuth2'
+    user_data_url = 'https://kapi.kakao.com/v2/user/me'
+    expected_username = 'foobar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar'
+    })
+    user_data_body = json.dumps({
+        'id': '101010101',
+        'properties': {
+            'nickname': 'foobar',
+            'thumbnail_image': 'http://mud-kage.kakao.co.kr/14/dn/btqbh1AKmRf/'
+                               'ujlHpQhxtMSbhKrBisrxe1/o.jpg',
+            'profile_image': 'http://mud-kage.kakao.co.kr/14/dn/btqbjCnl06Q/'
+                             'wbMJSVAUZB7lzSImgGdsoK/o.jpg'
+        },
+        'kakao_account': {
+            'email': 'a'
+        }
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_keycloak.py b/rootfs/api/apps_extra/social_core/tests/backends/test_keycloak.py
new file mode 100644
index 000000000..b61735477
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_keycloak.py
@@ -0,0 +1,120 @@
+import json
+import time
+import jwt
+
+from .oauth import OAuth2Test
+
+
+_PRIVATE_KEY_HEADERLESS = """
+MIIEowIBAAKCAQEAvyo2hx1L3ALHeUd/6xk/lIhTyZ/HJZ+Sss/ge6T6gPdES4Dw
+BvwGlAp21iEbmjmizsv6+ZsyuKZUiC1J4A90lmIA57aYXHHoh9GBWQZzXeCNgghP
+JpGYYSCN+1qeD4nbwD9cQBtPrGBpPpPtv2a/xdPqDm5ko6adMhmbm8e4Me/ppWPi
+0U+skWQJepBhjEt3x+AOMKDv2TUBWOc3mYFNkr9qNOPe7FxnqUk6ZtkI3QNjZTky
+AU7cat87u1vT5thAxVY18i1GfSZwtQbU3Ba6hXI5SIHB1lS88SJ9/+E/flJJPD2N
+Nzv2z3HAVuTUOYi48fnXFHpJLGv+mGLNtE77hwIDAQABAoIBAQCUyQYno2Wnl4Ip
+orys/rm9oV2VUAZwAgLrqV/O3Fkch1dgbLpktUNpdbuIbbxODQ3qZliwbVrM3Khu
+VNFq0pyrbxvFPRjY2s9g5m8GGz8vkdaRnmX8XtV6wxu+xoi/D006FBZ4zsj0IRXI
+3tnsXsxj7Mv+72zk8ojmtYend4qlUfzBVTpMRDc4XDC4Ya91fgFgfibtUE1qc8Ap
+ctCzk9wZgN9SOKXHcKANhqC8BmQv7NspI0RT0Oq0n/U921P/+y1M++Z0Z2vQvjiR
+GvDpSlnmlLB3S3E7zHbmksyUyK4Ab7xYi51yFKgrYjtaM3QLCTlSmTQBM8EhjZNG
+VJvgEI4BAoGBAOQzd4PdB3jW6OnUXDrzWqfCAgpMxWtAmP5h+LrmsrV+upXggRnv
+iNzTqiq0QpdrgYrikFGDkGEsqfrnd7IkskDMT8PMuncmDkuF67kg6G4/vzyz5QyA
+jolf6qmHQPMCfxtCPUZZOUcuRKnj5KVIGfJu2gu5Z2lEysuV5ZQE0OiNAoGBANZz
+wqLhqN376MT3YEbsOcWXYBDXX+FzWAYHsxf4APHJrNr6pkM4dTsvXU2tQIUV4N3c
+SJvMVSI91VL8mdgQxHKaECUizye19brJ1BysbeFjBCK+Y6bYMd8n0Hdxxi/ZyeF9
+AzfIPQN2uZSzU/I+Nt0tlz/SCoL4Qi3FQpgtLoFjAoGAb8XVsDy+wC1jf8SIOEei
+C7E3FpxrxhCp309VaRY+Si98bJS+J1nwC1mRa8FHLKt3k/NNBOAQA8jAqShetF7N
+AHgSSbEpU9rL/anmv5Kixf1rSexDMFB3gEn+wnKBGYYLg+p54M8rAvZio2QARgR+
+0QQCwONbB3Cuc/FDtbB2MrECgYAhSqtGmf2bKIZEPZsGp5l4YT2an7TUzRE3Lm7R
+I8ERyBs7i3nQKa2ZWIsFigXgIztbdd0Xwqrcu/in/2rqrf+xQtWKzlKWeZsCOl7h
+bKtKOBLmSeQyfJGRcR7dzB3WQ9shVETxnfZK2V2KBiTcEGh4AaHfWH4lQuETNfJW
+qXz0vQKBgDVz+ZvULA/OZWXrOI1il7KoahWdb9vr8VhWgHKnDW7hInDFh6SEQHn6
+mSNns0AssDwr4TheET7klb7AvbBKrNSP/Tz9AzkwMz148T2ffkPFMZRuvRT+eQ5Z
+ey4gIBKESJF6X9fefiawCrI3+PC7x9x0ngP9R4t/OzDWVAYn9gmd
+""".strip()
+
+_PRIVATE_KEY = """
+-----BEGIN RSA PRIVATE KEY-----
+{_PRIVATE_KEY_HEADERLESS}
+-----END RSA PRIVATE KEY-----
+""".format(
+    _PRIVATE_KEY_HEADERLESS=_PRIVATE_KEY_HEADERLESS
+)
+
+_PUBLIC_KEY_HEADERLESS = """
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvyo2hx1L3ALHeUd/6xk/
+lIhTyZ/HJZ+Sss/ge6T6gPdES4DwBvwGlAp21iEbmjmizsv6+ZsyuKZUiC1J4A90
+lmIA57aYXHHoh9GBWQZzXeCNgghPJpGYYSCN+1qeD4nbwD9cQBtPrGBpPpPtv2a/
+xdPqDm5ko6adMhmbm8e4Me/ppWPi0U+skWQJepBhjEt3x+AOMKDv2TUBWOc3mYFN
+kr9qNOPe7FxnqUk6ZtkI3QNjZTkyAU7cat87u1vT5thAxVY18i1GfSZwtQbU3Ba6
+hXI5SIHB1lS88SJ9/+E/flJJPD2NNzv2z3HAVuTUOYi48fnXFHpJLGv+mGLNtE77
+hwIDAQAB
+""".strip()
+
+_PUBLIC_KEY = """
+-----BEGIN PUBLIC KEY-----
+{_PUBLIC_KEY_HEADERLESS}
+-----END PUBLIC KEY-----
+""".format(
+    _PUBLIC_KEY_HEADERLESS=_PUBLIC_KEY_HEADERLESS
+)
+
+_KEY = 'example'
+_SECRET = '1234abcd-1234-abcd-1234-abcd1234adcd'
+
+_AUTHORIZATION_URL = 'https://sso.example.com/auth/realms/example/protocol/openid-connect/auth'
+_ACCESS_TOKEN_URL = 'https://sso.example.com/auth/realms/example/protocol/openid-connect/token'
+
+_ALGORITHM = 'RS256'
+_AUTH_TIME = int(time.time())
+_PAYLOAD = {
+    'preferred_username': 'john.doe',
+    'email': 'john.doe@example.com',
+    'name': 'John Doe',
+    'given_name': 'John',
+    'family_name': 'Doe',
+
+    'iss': 'https://sso.example.com',
+    'sub': 'john.doe',
+    'aud': _KEY,
+    'exp': _AUTH_TIME + 3600,
+    'iat': _AUTH_TIME,
+}
+
+
+def _encode(payload, key=_PRIVATE_KEY, algorithm=_ALGORITHM):
+    return jwt.encode(payload, key=key, algorithm=algorithm)
+
+
+def _decode(token, key=_PUBLIC_KEY, algorithms=[_ALGORITHM], audience=_KEY):
+    return jwt.decode(token, key=key, algorithms=algorithms, audience=audience)
+
+
+class KeycloakOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.keycloak.KeycloakOAuth2'
+    expected_username = 'john.doe'
+    access_token_body = json.dumps({
+        'token_type': 'Bearer',
+        'id_token': _encode(_PAYLOAD),
+        'access_token': _encode(_PAYLOAD),
+    })
+
+    def extra_settings(self):
+        return {
+            'SOCIAL_AUTH_KEYCLOAK_KEY': _KEY,
+            'SOCIAL_AUTH_KEYCLOAK_SECRET': _SECRET,
+            'SOCIAL_AUTH_KEYCLOAK_PUBLIC_KEY': _PUBLIC_KEY_HEADERLESS,
+            'SOCIAL_AUTH_KEYCLOAK_ALGORITHM': _ALGORITHM,
+            'SOCIAL_AUTH_KEYCLOAK_AUTHORIZATION_URL': _AUTHORIZATION_URL,
+            'SOCIAL_AUTH_KEYCLOAK_ACCESS_TOKEN_URL': _ACCESS_TOKEN_URL,
+        }
+
+    def test_encode_decode(self):
+        token = _encode(_PAYLOAD)
+        self.assertEqual(_PAYLOAD, _decode(token))
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_khanacademy.py b/rootfs/api/apps_extra/social_core/tests/backends/test_khanacademy.py
new file mode 100644
index 000000000..a3db9a715
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_khanacademy.py
@@ -0,0 +1,30 @@
+import json
+from urllib.parse import urlencode
+
+
+from .oauth import OAuth1Test
+
+
+class KhanAcademyOAuth1Test(OAuth1Test):
+    backend_path = 'social_core.backends.khanacademy.KhanAcademyOAuth1'
+    user_data_url = 'https://www.khanacademy.org/api/v1/user'
+    expected_username = 'foo@bar.com'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    request_token_body = urlencode({
+        'oauth_token_secret': 'foobar-secret',
+        'oauth_token': 'foobar',
+        'oauth_callback_confirmed': 'true'
+    })
+    user_data_body = json.dumps({
+        'email': 'foo@bar.com',
+        'user_id': 'http://googleid.khanacademy.org/11111111111111',
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_linkedin.py b/rootfs/api/apps_extra/social_core/tests/backends/test_linkedin.py
new file mode 100644
index 000000000..59e954d26
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_linkedin.py
@@ -0,0 +1,52 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class BaseLinkedinTest:
+    user_data_url = 'https://api.linkedin.com/v2/me' \
+                    '?projection=(firstName,id,lastName)'
+    expected_username = 'FooBar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+
+    # Reference:
+    # https://docs.microsoft.com/en-us/linkedin/consumer/integrations/self
+    # -serve/sign-in-with-linkedin?context=linkedin/consumer/context#api-request
+    user_data_body = json.dumps({
+        'id': '1010101010',
+        'firstName': {
+            'localized': {
+                'en_US': 'Foo'
+            },
+            'preferredLocale': {
+                'country': 'US',
+                'language': 'en'
+            }
+        },
+        'lastName': {
+            'localized': {
+                'en_US': 'Bar'
+            },
+            'preferredLocale': {
+                'country': 'US',
+                'language': 'en'
+            }
+        }
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
+
+
+class LinkedinOAuth2Test(BaseLinkedinTest, OAuth2Test):
+    backend_path = 'social_core.backends.linkedin.LinkedinOAuth2'
+
+
+class LinkedinMobileOAuth2Test(BaseLinkedinTest, OAuth2Test):
+    backend_path = 'social_core.backends.linkedin.LinkedinMobileOAuth2'
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_live.py b/rootfs/api/apps_extra/social_core/tests/backends/test_live.py
new file mode 100644
index 000000000..42c44039b
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_live.py
@@ -0,0 +1,35 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class LiveOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.live.LiveOAuth2'
+    user_data_url = 'https://apis.live.net/v5.0/me'
+    expected_username = 'FooBar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    user_data_body = json.dumps({
+        'first_name': 'Foo',
+        'last_name': 'Bar',
+        'name': 'Foo Bar',
+        'locale': 'en_US',
+        'gender': None,
+        'emails': {
+            'personal': None,
+            'account': 'foobar@live.com',
+            'business': None,
+            'preferred': 'foobar@live.com'
+        },
+        'link': 'https://profile.live.com/',
+        'updated_time': '2013-03-17T05:51:30+0000',
+        'id': '1010101010101010'
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_livejournal.py b/rootfs/api/apps_extra/social_core/tests/backends/test_livejournal.py
new file mode 100644
index 000000000..33859a54f
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_livejournal.py
@@ -0,0 +1,99 @@
+import datetime
+from urllib.parse import urlencode
+
+from httpretty import HTTPretty
+
+from ...exceptions import AuthMissingParameter
+
+from .open_id import OpenIdTest
+
+
+JANRAIN_NONCE = datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ')
+
+
+class LiveJournalOpenIdTest(OpenIdTest):
+    backend_path = 'social_core.backends.livejournal.LiveJournalOpenId'
+    expected_username = 'foobar'
+    discovery_body = ''.join([
+        '<xrds:XRDS xmlns:xrds="xri://$xrds" xmlns="xri://$xrd*($v*2.0)">',
+        '<XRD>',
+        '<Service priority="0">',
+        '<Type>http://specs.openid.net/auth/2.0/signon</Type>',
+        '<URI>http://www.livejournal.com/openid/server.bml</URI>',
+        '<LocalID>http://foobar.livejournal.com/</LocalID>',
+        '</Service>',
+        '</XRD>',
+        '</xrds:XRDS>'
+    ])
+    server_response = urlencode({
+        'janrain_nonce': JANRAIN_NONCE,
+        'openid.mode': 'id_res',
+        'openid.claimed_id': 'http://foobar.livejournal.com/',
+        'openid.identity': 'http://foobar.livejournal.com/',
+        'openid.op_endpoint': 'http://www.livejournal.com/openid/server.bml',
+        'openid.return_to': 'http://myapp.com/complete/livejournal/?'
+                            'janrain_nonce=' + JANRAIN_NONCE,
+        'openid.response_nonce': JANRAIN_NONCE + 'wGp2rj',
+        'openid.assoc_handle': '1364932966:ZTiur8sem3r2jzZougMZ:4d1cc3b44e',
+        'openid.ns': 'http://specs.openid.net/auth/2.0',
+        'openid.signed': 'mode,claimed_id,identity,op_endpoint,return_to,'
+                         'response_nonce,assoc_handle',
+        'openid.sig': 'Z8MOozVPTOBhHG5ZS1NeGofxs1Q=',
+    })
+    server_bml_body = '\n'.join([
+        'assoc_handle:1364935340:ZhruPQ7DJ9eGgUkeUA9A:27f8c32464',
+        'assoc_type:HMAC-SHA1',
+        'dh_server_public:WzsRyLomvAV3vwvGUrfzXDgfqnTF+m1l3JWb55fyHO7visPT4tmQ'
+        'iTjqFFnSVAtAOvQzoViMiZQisxNwnqSK4lYexoez1z6pP5ry3pqxJAEYj60vFGvRztict'
+        'Eo0brjhmO1SNfjK1ppjOymdykqLpZeaL5fsuLtMCwTnR/JQZVA=',
+        'enc_mac_key:LiOEVlLJSVUqfNvb5zPd76nEfvc=',
+        'expires_in:1207060',
+        'ns:http://specs.openid.net/auth/2.0',
+        'session_type:DH-SHA1',
+        ''
+    ])
+
+    def openid_url(self):
+        return super().openid_url() + '/data/yadis'
+
+    def post_start(self):
+        self.strategy.remove_from_request_data('openid_lj_user')
+
+    def _setup_handlers(self):
+        HTTPretty.register_uri(
+            HTTPretty.POST,
+            'http://www.livejournal.com/openid/server.bml',
+            headers={'Accept-Encoding': 'identity',
+                     'Content-Type': 'application/x-www-form-urlencoded'},
+            status=200,
+            body=self.server_bml_body
+        )
+        HTTPretty.register_uri(
+            HTTPretty.GET,
+            'http://foobar.livejournal.com/',
+            headers={
+                'Accept-Encoding': 'identity',
+                'Accept': 'text/html; q=0.3,'
+                          'application/xhtml+xml; q=0.5,'
+                          'application/xrds+xml'
+            },
+            status=200,
+            body=self.discovery_body
+        )
+
+    def test_login(self):
+        self.strategy.set_request_data({'openid_lj_user': 'foobar'},
+                                       self.backend)
+        self._setup_handlers()
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.strategy.set_request_data({'openid_lj_user': 'foobar'},
+                                       self.backend)
+        self._setup_handlers()
+        self.do_partial_pipeline()
+
+    def test_failed_login(self):
+        self._setup_handlers()
+        with self.assertRaises(AuthMissingParameter):
+            self.do_login()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_lyft.py b/rootfs/api/apps_extra/social_core/tests/backends/test_lyft.py
new file mode 100644
index 000000000..e6e338bca
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_lyft.py
@@ -0,0 +1,26 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class LyftOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.lyft.LyftOAuth2'
+    user_data_url = 'https://api.lyft.com/v1/profile'
+    access_token_body = json.dumps({
+        'access_token': 'atoken_foo',
+        'refresh_token': 'rtoken_bar',
+        'token_type': 'bearer',
+        'expires_in': 3600,
+        'scope': 'public profile rides.read rides.request',
+        'id': 'user_foobar'
+    })
+    user_data_body = json.dumps({
+        'id': 'user_foobar'
+    })
+    expected_username = 'user_foobar'
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_mailru.py b/rootfs/api/apps_extra/social_core/tests/backends/test_mailru.py
new file mode 100644
index 000000000..9daa47d2d
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_mailru.py
@@ -0,0 +1,30 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class MRGOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.mailru.MRGOAuth2'
+    user_data_url = 'https://oauth.mail.ru/userinfo'
+    expected_username = 'FooBar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    user_data_body = json.dumps({
+        'first_name': 'Foo',
+        'last_name': 'Bar',
+        'name': 'Foo Bar',
+        'locale': 'ru_RU',
+        'email': 'foobar@example.com',
+        'birthday': '11.07.1970',
+        'gender': 'm',
+        'image': 'http://cs7003.vk.me/v7003815/22a1/xgG9fb-IJ3Y.jpg',
+
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_mapmyfitness.py b/rootfs/api/apps_extra/social_core/tests/backends/test_mapmyfitness.py
new file mode 100644
index 000000000..b44780d5c
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_mapmyfitness.py
@@ -0,0 +1,158 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class MapMyFitnessOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.mapmyfitness.MapMyFitnessOAuth2'
+    user_data_url = 'https://oauth2-api.mapmyapi.com/v7.0/user/self/'
+    expected_username = 'FredFlinstone'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'Bearer',
+        'expires_in': 4000000,
+        'refresh_token': 'bambaz',
+        'scope': 'read'
+    })
+    user_data_body = json.dumps({
+        'last_name': 'Flinstone',
+        'weight': 91.17206637,
+        'communication': {
+            'promotions': True,
+            'newsletter': True,
+            'system_messages': True
+        },
+        'height': 1.778,
+        'token_type': 'Bearer',
+        'id': 112233,
+        'date_joined': '2011-08-26T06:06:19+00:00',
+        'first_name': 'Fred',
+        'display_name': 'Fred Flinstone',
+        'display_measurement_system': 'imperial',
+        'expires_in': 4000000,
+        '_links': {
+            'stats': [
+                {
+                    'href': '/v7.0/user_stats/112233/?'
+                            'aggregate_by_period=month',
+                    'id': '112233',
+                    'name': 'month'
+                },
+                {
+                    'href': '/v7.0/user_stats/112233/?'
+                            'aggregate_by_period=year',
+                    'id': '112233',
+                    'name': 'year'
+                },
+                {
+                    'href': '/v7.0/user_stats/112233/?aggregate_by_period=day',
+                    'id': '112233',
+                    'name': 'day'
+                },
+                {
+                    'href': '/v7.0/user_stats/112233/?'
+                            'aggregate_by_period=week',
+                    'id': '112233',
+                    'name': 'week'
+                },
+                {
+                    'href': '/v7.0/user_stats/112233/?'
+                            'aggregate_by_period=lifetime',
+                    'id': '112233',
+                    'name': 'lifetime'
+                }
+            ],
+            'friendships': [
+                {
+                    'href': '/v7.0/friendship/?from_user=112233'
+                }
+            ],
+            'privacy': [
+                {
+                    'href': '/v7.0/privacy_option/3/',
+                    'id': '3',
+                    'name': 'profile'
+                },
+                {
+                    'href': '/v7.0/privacy_option/3/',
+                    'id': '3',
+                    'name': 'workout'
+                },
+                {
+                    'href': '/v7.0/privacy_option/3/',
+                    'id': '3',
+                    'name': 'activity_feed'
+                },
+                {
+                    'href': '/v7.0/privacy_option/1/',
+                    'id': '1',
+                    'name': 'food_log'
+                },
+                {
+                    'href': '/v7.0/privacy_option/3/',
+                    'id': '3',
+                    'name': 'email_search'
+                },
+                {
+                    'href': '/v7.0/privacy_option/3/',
+                    'id': '3',
+                    'name': 'route'
+                }
+            ],
+            'image': [
+                {
+                    'href': '/v7.0/user_profile_photo/112233/',
+                    'id': '112233',
+                    'name': 'user_profile_photo'
+                }
+            ],
+            'documentation': [
+                {
+                    'href': 'https://www.mapmyapi.com/docs/User'
+                }
+            ],
+            'workouts': [
+                {
+                    'href': '/v7.0/workout/?user=112233&'
+                            'order_by=-start_datetime'
+                }
+            ],
+            'deactivation': [
+                {
+                    'href': '/v7.0/user_deactivation/'
+                }
+            ],
+            'self': [
+                {
+                    'href': '/v7.0/user/112233/',
+                    'id': '112233'
+                }
+            ]
+        },
+        'location': {
+            'country': 'US',
+            'region': 'NC',
+            'locality': 'Bedrock',
+            'address': '150 Dinosaur Ln'
+        },
+        'last_login': '2014-02-23T22:36:52+00:00',
+        'email': 'fredflinstone@gmail.com',
+        'username': 'FredFlinstone',
+        'sharing': {
+            'twitter': False,
+            'facebook': False
+        },
+        'scope': 'read',
+        'refresh_token': 'bambaz',
+        'last_initial': 'S.',
+        'access_token': 'foobar',
+        'gender': 'M',
+        'time_zone': 'America/Denver',
+        'birthdate': '1983-04-15'
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_mineid.py b/rootfs/api/apps_extra/social_core/tests/backends/test_mineid.py
new file mode 100644
index 000000000..bd56490af
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_mineid.py
@@ -0,0 +1,23 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class MineIDOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.mineid.MineIDOAuth2'
+    user_data_url = 'https://www.mineid.org/api/user'
+    expected_username = 'foo@bar.com'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    user_data_body = json.dumps({
+        'email': 'foo@bar.com',
+        'primary_profile': None,
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_mixcloud.py b/rootfs/api/apps_extra/social_core/tests/backends/test_mixcloud.py
new file mode 100644
index 000000000..fe81e9152
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_mixcloud.py
@@ -0,0 +1,58 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class MixcloudOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.mixcloud.MixcloudOAuth2'
+    user_data_url = 'https://api.mixcloud.com/me/'
+    expected_username = 'foobar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    user_data_body = json.dumps({
+        'username': 'foobar',
+        'cloudcast_count': 0,
+        'following_count': 0,
+        'url': 'http://www.mixcloud.com/foobar/',
+        'pictures': {
+            'medium': 'http://images-mix.netdna-ssl.com/w/100/h/100/q/85/'
+                      'images/graphics/33_Profile/default_user_600x600-v4.png',
+            '320wx320h': 'http://images-mix.netdna-ssl.com/w/320/h/320/q/85/'
+                         'images/graphics/33_Profile/'
+                         'default_user_600x600-v4.png',
+            'extra_large': 'http://images-mix.netdna-ssl.com/w/600/h/600/q/85/'
+                           'images/graphics/33_Profile/'
+                           'default_user_600x600-v4.png',
+            'large': 'http://images-mix.netdna-ssl.com/w/300/h/300/q/85/'
+                     'images/graphics/33_Profile/default_user_600x600-v4.png',
+            '640wx640h': 'http://images-mix.netdna-ssl.com/w/640/h/640/q/85/'
+                         'images/graphics/33_Profile/'
+                         'default_user_600x600-v4.png',
+            'medium_mobile': 'http://images-mix.netdna-ssl.com/w/80/h/80/q/75/'
+                             'images/graphics/33_Profile/'
+                             'default_user_600x600-v4.png',
+            'small': 'http://images-mix.netdna-ssl.com/w/25/h/25/q/85/images/'
+                     'graphics/33_Profile/default_user_600x600-v4.png',
+            'thumbnail': 'http://images-mix.netdna-ssl.com/w/50/h/50/q/85/'
+                         'images/graphics/33_Profile/'
+                         'default_user_600x600-v4.png'
+        },
+        'is_current_user': True,
+        'listen_count': 0,
+        'updated_time': '2013-03-17T06:26:31Z',
+        'following': False,
+        'follower': False,
+        'key': '/foobar/',
+        'created_time': '2013-03-17T06:26:31Z',
+        'follower_count': 0,
+        'favorite_count': 0,
+        'name': 'foobar'
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_nationbuilder.py b/rootfs/api/apps_extra/social_core/tests/backends/test_nationbuilder.py
new file mode 100644
index 000000000..fa311d4cd
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_nationbuilder.py
@@ -0,0 +1,232 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class NationBuilderOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.nationbuilder.NationBuilderOAuth2'
+    user_data_url = 'https://foobar.nationbuilder.com/api/v1/people/me'
+    expected_username = 'foobar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer',
+        'created_at': 1422937981,
+        'expires_in': 2592000
+    })
+    user_data_body = json.dumps({
+        'person': {
+            'twitter_followers_count': None,
+            'last_name': 'Bar',
+            'rule_violations_count': 0,
+            'linkedin_id': None,
+            'recruiter_id': None,
+            'membership_expires_at': None,
+            'donations_raised_count': 0,
+            'last_contacted_at': None,
+            'prefix': None,
+            'profile_content_html': None,
+            'email4': None,
+            'email2': None,
+            'availability': None,
+            'occupation': None,
+            'user_submitted_address': None,
+            'could_vote_status': None,
+            'state_upper_district': None,
+            'salesforce_id': None,
+            'van_id': None,
+            'phone_time': None,
+            'profile_content': None,
+            'auto_import_id': None,
+            'parent_id': None,
+            'email4_is_bad': False,
+            'twitter_updated_at': None,
+            'email3_is_bad': False,
+            'bio': None,
+            'party_member': None,
+            'unsubscribed_at': None,
+            'fax_number': None,
+            'last_contacted_by': None,
+            'active_customer_expires_at': None,
+            'federal_donotcall': False,
+            'warnings_count': 0,
+            'first_supporter_at': '2015-02-02T19:30:28-08:00',
+            'previous_party': None,
+            'donations_raised_amount_this_cycle_in_cents': 0,
+            'call_status_name': None,
+            'marital_status': None,
+            'facebook_updated_at': None,
+            'donations_count': 0,
+            'note_updated_at': None,
+            'closed_invoices_count': None,
+            'profile_headline': None,
+            'fire_district': None,
+            'mobile_normalized': None,
+            'import_id': None,
+            'last_call_id': None,
+            'donations_raised_amount_in_cents': 0,
+            'facebook_address': None,
+            'is_profile_private': False,
+            'last_rule_violation_at': None,
+            'sex': None,
+            'full_name': 'Foo Bar',
+            'last_donated_at': None,
+            'donations_pledged_amount_in_cents': 0,
+            'primary_email_id': 1,
+            'media_market_name': None,
+            'capital_amount_in_cents': 500,
+            'datatrust_id': None,
+            'precinct_code': None,
+            'email3': None,
+            'religion': None,
+            'first_prospect_at': None,
+            'judicial_district': None,
+            'donations_count_this_cycle': 0,
+            'work_address': None,
+            'is_twitter_follower': False,
+            'email1': 'foobar@gmail.com',
+            'email': 'foobar@gmail.com',
+            'contact_status_name': None,
+            'mobile_opt_in': True,
+            'twitter_description': None,
+            'parent': None,
+            'tags': [],
+            'first_volunteer_at': None,
+            'inferred_support_level': None,
+            'banned_at': None,
+            'first_invoice_at': None,
+            'donations_raised_count_this_cycle': 0,
+            'is_donor': False,
+            'twitter_location': None,
+            'email1_is_bad': False,
+            'legal_name': None,
+            'language': None,
+            'registered_at': None,
+            'call_status_id': None,
+            'last_invoice_at': None,
+            'school_sub_district': None,
+            'village_district': None,
+            'twitter_name': None,
+            'membership_started_at': None,
+            'subnations': [],
+            'meetup_address': None,
+            'author_id': None,
+            'registered_address': None,
+            'external_id': None,
+            'twitter_login': None,
+            'inferred_party': None,
+            'spent_capital_amount_in_cents': 0,
+            'suffix': None,
+            'mailing_address': None,
+            'is_leaderboardable': True,
+            'twitter_website': None,
+            'nbec_guid': None,
+            'city_district': None,
+            'church': None,
+            'is_profile_searchable': True,
+            'employer': None,
+            'is_fundraiser': False,
+            'email_opt_in': True,
+            'recruits_count': 0,
+            'email2_is_bad': False,
+            'county_district': None,
+            'recruiter': None,
+            'twitter_friends_count': None,
+            'facebook_username': None,
+            'active_customer_started_at': None,
+            'pf_strat_id': None,
+            'locale': None,
+            'twitter_address': None,
+            'is_supporter': True,
+            'do_not_call': False,
+            'profile_image_url_ssl': 'https://d3n8a8pro7vhmx.cloudfront.net'
+                                     '/assets/icons/buddy.png',
+            'invoices_amount_in_cents': None,
+            'username': None,
+            'donations_amount_in_cents': 0,
+            'is_volunteer': False,
+            'civicrm_id': None,
+            'supranational_district': None,
+            'precinct_name': None,
+            'invoice_payments_amount_in_cents': None,
+            'work_phone_number': None,
+            'phone': '213.394.4623',
+            'received_capital_amount_in_cents': 500,
+            'primary_address': None,
+            'is_possible_duplicate': False,
+            'invoice_payments_referred_amount_in_cents': None,
+            'donations_amount_this_cycle_in_cents': 0,
+            'priority_level': None,
+            'first_fundraised_at': None,
+            'phone_normalized': '2133944623',
+            'rnc_regid': None,
+            'twitter_id': None,
+            'birthdate': None,
+            'mobile': None,
+            'federal_district': None,
+            'donations_to_raise_amount_in_cents': 0,
+            'support_probability_score': None,
+            'invoices_count': None,
+            'nbec_precinct_code': None,
+            'website': None,
+            'closed_invoices_amount_in_cents': None,
+            'home_address': None,
+            'school_district': None,
+            'support_level': None,
+            'demo': None,
+            'children_count': 0,
+            'updated_at': '2015-02-02T19:30:28-08:00',
+            'membership_level_name': None,
+            'billing_address': None,
+            'is_ignore_donation_limits': False,
+            'signup_type': 0,
+            'precinct_id': None,
+            'rnc_id': None,
+            'id': 2,
+            'ethnicity': None,
+            'is_survey_question_private': False,
+            'middle_name': None,
+            'author': None,
+            'last_fundraised_at': None,
+            'state_file_id': None,
+            'note': None,
+            'submitted_address': None,
+            'support_level_changed_at': None,
+            'party': None,
+            'contact_status_id': None,
+            'outstanding_invoices_amount_in_cents': None,
+            'page_slug': None,
+            'outstanding_invoices_count': None,
+            'first_recruited_at': None,
+            'county_file_id': None,
+            'first_name': 'Foo',
+            'facebook_profile_url': None,
+            'city_sub_district': None,
+            'has_facebook': False,
+            'is_deceased': False,
+            'labour_region': None,
+            'state_lower_district': None,
+            'dw_id': None,
+            'created_at': '2015-02-02T19:30:28-08:00',
+            'is_prospect': False,
+            'priority_level_changed_at': None,
+            'is_mobile_bad': False,
+            'overdue_invoices_count': None,
+            'ngp_id': None,
+            'do_not_contact': False,
+            'first_donated_at': None,
+            'turnout_probability_score': None
+        },
+        'precinct': None
+    })
+
+    def test_login(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_NATIONBUILDER_SLUG': 'foobar'
+        })
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_NATIONBUILDER_SLUG': 'foobar'
+        })
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_naver.py b/rootfs/api/apps_extra/social_core/tests/backends/test_naver.py
new file mode 100644
index 000000000..7e1b6e61b
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_naver.py
@@ -0,0 +1,35 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class NaverOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.naver.NaverOAuth2'
+    user_data_url = 'https://openapi.naver.com/v1/nid/me'
+    expected_username = 'foobar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer',
+    })
+
+    user_data_content_type = 'text/json'
+    user_data_body = json.dumps({
+        'resultcode': '00',
+        'message': 'success',
+        'response': {
+            'email': 'openapi@naver.com',
+            'nickname': 'foobar',
+            'profile_image': 'https://ssl.pstatic.net/static/pwe/address/nodata_33x33.gif',
+            'age': '40-49',
+            'gender': 'F',
+            'id': '32742776',
+            'name': 'foobar',
+            'birthday': '10-01',
+        }
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_ngpvan.py b/rootfs/api/apps_extra/social_core/tests/backends/test_ngpvan.py
new file mode 100644
index 000000000..e5f58bbc5
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_ngpvan.py
@@ -0,0 +1,196 @@
+"""Tests for NGP VAN ActionID Backend"""
+import datetime
+from urllib.parse import urlencode
+
+from httpretty import HTTPretty
+
+from .open_id import OpenIdTest
+
+
+JANRAIN_NONCE = datetime.datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')
+
+
+class NGPVANActionIDOpenIDTest(OpenIdTest):
+    """Test the NGP VAN ActionID OpenID 1.1 Backend"""
+    backend_path = 'social_core.backends.ngpvan.ActionIDOpenID'
+    expected_username = 'testuser@user.local'
+    discovery_body = ' '.join([
+        '<?xml version="1.0" encoding="UTF-8"?>',
+        '<xrds:XRDS',
+        'xmlns:xrds="xri://$xrds"',
+        'xmlns:openid="http://openid.net/xmlns/1.0"',
+        'xmlns="xri://$xrd*($v*2.0)">',
+        '<XRD>',
+        '<Service priority="10">',
+        '<Type>http://specs.openid.net/auth/2.0/signon</Type>',
+        '<Type>http://openid.net/extensions/sreg/1.1</Type>',
+        '<Type>http://axschema.org/contact/email</Type>',
+        '<URI>https://accounts.ngpvan.com/OpenId/Provider</URI>',
+        '</Service>',
+        '<Service priority="20">',
+        '<Type>http://openid.net/signon/1.0</Type>',
+        '<Type>http://openid.net/extensions/sreg/1.1</Type>',
+        '<Type>http://axschema.org/contact/email</Type>',
+        '<URI>https://accounts.ngpvan.com/OpenId/Provider</URI>',
+        '</Service>',
+        '</XRD>',
+        '</xrds:XRDS>'
+    ])
+    server_response = urlencode({
+        'openid.claimed_id': 'https://accounts.ngpvan.com/user/abcd123',
+        'openid.identity': 'https://accounts.ngpvan.com/user/abcd123',
+        'openid.sig': 'Midw8F/rCDwW7vMz3y+vK6rjz6s=',
+        'openid.signed': 'claimed_id,identity,assoc_handle,op_endpoint,return_'
+                         'to,response_nonce,ns.alias3,alias3.mode,alias3.type.'
+                         'alias1,alias3.value.alias1,alias3.type.alias2,alias3'
+                         '.value.alias2,alias3.type.alias3,alias3.value.alias3'
+                         ',alias3.type.alias4,alias3.value.alias4,alias3.type.'
+                         'alias5,alias3.value.alias5,alias3.type.alias6,alias3'
+                         '.value.alias6,alias3.type.alias7,alias3.value.alias7'
+                         ',alias3.type.alias8,alias3.value.alias8,ns.sreg,sreg'
+                         '.fullname',
+        'openid.assoc_handle': '{635790678917902781}{GdSyFA==}{20}',
+        'openid.op_endpoint': 'https://accounts.ngpvan.com/OpenId/Provider',
+        'openid.return_to': 'http://myapp.com/complete/actionid-openid/',
+        'openid.response_nonce': JANRAIN_NONCE + 'MMgBGEre',
+        'openid.mode': 'id_res',
+        'openid.ns': 'http://specs.openid.net/auth/2.0',
+        'openid.ns.alias3': 'http://openid.net/srv/ax/1.0',
+        'openid.alias3.mode': 'fetch_response',
+        'openid.alias3.type.alias1': 'http://openid.net/schema/contact/phone/b'
+                                     'usiness',
+        'openid.alias3.value.alias1': '+12015555555',
+        'openid.alias3.type.alias2': 'http://openid.net/schema/contact/interne'
+                                     't/email',
+        'openid.alias3.value.alias2': 'testuser@user.local',
+        'openid.alias3.type.alias3': 'http://openid.net/schema/namePerson/firs'
+                                     't',
+        'openid.alias3.value.alias3': 'John',
+        'openid.alias3.type.alias4': 'http://openid.net/schema/namePerson/las'
+                                     't',
+        'openid.alias3.value.alias4': 'Smith',
+        'openid.alias3.type.alias5': 'http://axschema.org/namePerson/first',
+        'openid.alias3.value.alias5': 'John',
+        'openid.alias3.type.alias6': 'http://axschema.org/namePerson/last',
+        'openid.alias3.value.alias6': 'Smith',
+        'openid.alias3.type.alias7': 'http://axschema.org/namePerson',
+        'openid.alias3.value.alias7': 'John Smith',
+        'openid.alias3.type.alias8': 'http://openid.net/schema/namePerson',
+        'openid.alias3.value.alias8': 'John Smith',
+        'openid.ns.sreg': 'http://openid.net/extensions/sreg/1.1',
+        'openid.sreg.fullname': 'John Smith',
+    })
+
+    def setUp(self):
+        """Setup the test"""
+        super().setUp()
+
+        # Mock out the NGP VAN endpoints
+        HTTPretty.register_uri(
+            HTTPretty.POST,
+            'https://accounts.ngpvan.com/Home/Xrds',
+            status=200,
+            body=self.discovery_body
+        )
+        HTTPretty.register_uri(
+            HTTPretty.GET,
+            'https://accounts.ngpvan.com/user/abcd123',
+            status=200,
+            body=self.discovery_body
+        )
+        HTTPretty.register_uri(
+            HTTPretty.GET,
+            'https://accounts.ngpvan.com/OpenId/Provider',
+            status=200,
+            body=self.discovery_body
+        )
+
+    def test_login(self):
+        """Test the login flow using python-social-auth's built in test"""
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        """Test the partial flow using python-social-auth's built in test"""
+        self.do_partial_pipeline()
+
+    def test_get_ax_attributes(self):
+        """Test that the AX attributes that NGP VAN responds with are present"""
+        records = self.backend.get_ax_attributes()
+
+        self.assertEqual(records, [
+            ('http://openid.net/schema/contact/internet/email', 'email'),
+            ('http://openid.net/schema/contact/phone/business', 'phone'),
+            ('http://openid.net/schema/namePerson/first', 'first_name'),
+            ('http://openid.net/schema/namePerson/last', 'last_name'),
+            ('http://openid.net/schema/namePerson', 'fullname'),
+        ])
+
+    def test_setup_request(self):
+        """Test the setup_request functionality in the NGP VAN backend"""
+        # We can grab the requested attributes by grabbing the HTML of the
+        # OpenID auth form and pulling out the hidden fields
+        _, inputs = self.get_form_data(self.backend.auth_html())
+
+        # Confirm that the only required attribute is email
+        self.assertEqual(inputs['openid.ax.required'], 'ngpvanemail')
+
+        # Confirm that the 3 optional attributes are requested "if available"
+        self.assertIn('ngpvanphone', inputs['openid.ax.if_available'])
+        self.assertIn('ngpvanfirstname', inputs['openid.ax.if_available'])
+        self.assertIn('ngpvanlastname', inputs['openid.ax.if_available'])
+
+        # Verify the individual attribute properties
+        self.assertEqual(
+            inputs['openid.ax.type.ngpvanemail'],
+            'http://openid.net/schema/contact/internet/email'
+        )
+        self.assertEqual(
+            inputs['openid.ax.type.ngpvanfirstname'],
+            'http://openid.net/schema/namePerson/first'
+        )
+        self.assertEqual(
+            inputs['openid.ax.type.ngpvanlastname'],
+            'http://openid.net/schema/namePerson/last'
+        )
+        self.assertEqual(
+            inputs['openid.ax.type.ngpvanphone'],
+            'http://openid.net/schema/contact/phone/business'
+        )
+
+    def test_user_data(self):
+        """Ensure that the correct user data is being passed to create_user"""
+        self.strategy.set_settings({
+            'USER_FIELDS': [
+                'email',
+                'first_name',
+                'last_name',
+                'username',
+                'phone',
+                'fullname'
+            ]
+        })
+        user = self.do_start()
+        self.assertEqual(user.username, u'testuser@user.local')
+        self.assertEqual(user.email, u'testuser@user.local')
+        self.assertEqual(user.extra_user_fields['phone'], u'+12015555555')
+        self.assertEqual(user.extra_user_fields['first_name'], u'John')
+        self.assertEqual(user.extra_user_fields['last_name'], u'Smith')
+        self.assertEqual(user.extra_user_fields['fullname'], u'John Smith')
+
+    def test_extra_data_phone(self):
+        """Confirm that you can get a phone number via the relevant setting"""
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_ACTIONID_OPENID_AX_EXTRA_DATA': [
+                ('http://openid.net/schema/contact/phone/business', 'phone')
+            ]
+        })
+        user = self.do_start()
+        self.assertEqual(user.social_user.extra_data['phone'], u'+12015555555')
+
+    def test_association_uid(self):
+        """Test that the correct association uid is stored in the database"""
+        user = self.do_start()
+        self.assertEqual(
+            user.social_user.uid,
+            'https://accounts.ngpvan.com/user/abcd123'
+        )
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_okta.py b/rootfs/api/apps_extra/social_core/tests/backends/test_okta.py
new file mode 100644
index 000000000..4762851c7
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_okta.py
@@ -0,0 +1,147 @@
+import json
+from httpretty import HTTPretty
+
+from social_core.tests.backends.oauth import OAuth2Test
+from social_core.tests.backends.open_id_connect import OpenIdConnectTestMixin
+
+JWK_KEY = {
+    'kty': 'RSA',
+    'd': 'ZmswNokEvBcxW_Kvcy8mWUQOQCBdGbnM0xR7nhvGHC-Q24z3XAQWlMWbsmGc_R1o'
+         '_F3zK7DBlc3BokdRaO1KJirNmnHCw5TlnBlJrXiWpFBtVglUg98-4sRRO0VWnGXK'
+         'JPOkBQ6b_DYRO3b0o8CSpWowpiV6HB71cjXTqKPZf-aXU9WjCCAtxVjfIxgQFu5I'
+         '-G1Qah8mZeY8HK_y99L4f0siZcbUoaIcfeWBhxi14ODyuSAHt0sNEkhiIVBZE7QZ'
+         'm-SEP1ryT9VAaljbwHHPmg7NC26vtLZhvaBGbTTJnEH0ZubbN2PMzsfeNyoCIHy4'
+         '4QDSpQDCHfgcGOlHY_t5gQ',
+    'e': 'AQAB',
+    'use': 'sig',
+    'kid': 'testkey',
+    'alg': 'RS256',
+    'n': 'pUfcJ8WFrVue98Ygzb6KEQXHBzi8HavCu8VENB2As943--bHPcQ-nScXnrRFAUg8'
+         'H5ZltuOcHWvsGw_AQifSLmOCSWJAPkdNb0w0QzY7Re8NrPjCsP58Tytp5LicF0Ao'
+         'Ag28UK3JioY9hXHGvdZsWR1Rp3I-Z3nRBP6HyO18pEgcZ91c9aAzsqu80An9X4DA'
+         'b1lExtZorvcd5yTBzZgr-MUeytVRni2lDNEpa6OFuopHXmg27Hn3oWAaQlbymd4g'
+         'ifc01oahcwl3ze2tMK6gJxa_TdCf1y99Yq6oilmVvZJ8kwWWnbPE-oDmOVPVnEyT'
+         'vYVCvN4rBT1DQ-x0F1mo2Q',
+}
+
+JWK_PUBLIC_KEY = {key: value for key, value in JWK_KEY.items() if key != 'd'}
+
+
+class OktaOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.okta.OktaOAuth2'
+    user_data_url = 'https://dev-000000.oktapreview.com/oauth2/v1/userinfo'
+    expected_username = 'foo'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    user_data_body = json.dumps({
+        'family_name': 'Bar',
+        'sub': '101010101010101010101',
+        'locale': 'en',
+        'email_verified': True,
+        'given_name': 'Foo',
+        'email': 'foo@bar.com',
+        'name': 'Foo Bar',
+        'nickname': 'foobar',
+        'middle_name': '',
+        'profile': 'https://example.com/foo.bar',
+        'zoneinfo': 'America/Los_Angeles',
+        'updated_at': 1311280970,
+        'preferred_username': 'foo',
+    })
+
+    def test_login(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_OKTA_OAUTH2_API_URL': 'https://dev-000000.oktapreview.com/oauth2'
+        })
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_OKTA_OAUTH2_API_URL': 'https://dev-000000.oktapreview.com/oauth2'
+        })
+        self.do_partial_pipeline()
+
+
+class OktaOpenIdConnectTest(OpenIdConnectTestMixin, OAuth2Test):
+    backend_path = \
+        'social_core.backends.okta_openidconnect.OktaOpenIdConnect'
+    user_data_url = \
+        'https://dev-000000.oktapreview.com/oauth2/v1/userinfo'
+    issuer = 'https://dev-000000.oktapreview.com/oauth2'
+    openid_config_body = json.dumps({
+        'issuer': 'https://dev-000000.oktapreview.com/oauth2',
+        'authorization_endpoint': 'https://dev-000000.oktapreview.com/oauth2/v1/authorize',
+        'token_endpoint': 'https://dev-000000.oktapreview.com/oauth2/v1/token',
+        'userinfo_endpoint': 'https://dev-000000.oktapreview.com/oauth2/v1/userinfo',
+        'jwks_uri': 'https://dev-000000.oktapreview.com/oauth2/v1/keys',
+        'response_types_supported': [
+            'code',
+            'token',
+            'id_token',
+            'code token',
+            'code id_token',
+            'token id_token',
+            'code token id_token',
+            'none',
+        ],
+        'subject_types_supported': [
+            'public',
+        ],
+        'id_token_signing_alg_values_supported': [
+            'RS256',
+        ],
+        'scopes_supported': [
+            'openid',
+            'email',
+            'profile',
+        ],
+        'token_endpoint_auth_methods_supported': [
+            'client_secret_post',
+            'client_secret_basic',
+        ],
+        'claims_supported': [
+            'aud',
+            'email',
+            'email_verified',
+            'exp',
+            'family_name',
+            'given_name',
+            'iat',
+            'iss',
+            'locale',
+            'name',
+            'picture',
+            'sub',
+        ],
+    })
+    expected_username = 'foo'
+
+    def setUp(self):
+        super(OpenIdConnectTestMixin, self).setUp()
+        # Settings for Okta
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_OKTA_OPENIDCONNECT_API_URL': 'https://dev-000000.oktapreview.com/oauth2'
+        })
+        self.backend.OIDC_ENDPOINT = 'https://dev-000000.oktapreview.com/oauth2'
+
+        self.key = JWK_KEY.copy()
+        self.public_key = JWK_PUBLIC_KEY.copy()
+
+        HTTPretty.register_uri(HTTPretty.GET,
+                               self.backend.OIDC_ENDPOINT + '/.well-known/openid-configuration',
+                               status=200,
+                               body=self.openid_config_body)
+        oidc_config = json.loads(self.openid_config_body)
+
+        def jwks(_request, _uri, headers):
+            return 200, headers, json.dumps({'keys': [self.key]})
+
+        HTTPretty.register_uri(HTTPretty.GET,
+                               oidc_config.get('jwks_uri'),
+                               status=200,
+                               body=json.dumps({'keys': [self.public_key]}))
+
+        self.backend.JWKS_URI = oidc_config.get('jwks_uri')
+        self.backend.ID_TOKEN_ISSUER = oidc_config.get('issuer')
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_orbi.py b/rootfs/api/apps_extra/social_core/tests/backends/test_orbi.py
new file mode 100644
index 000000000..9105b8b6f
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_orbi.py
@@ -0,0 +1,30 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class OrbiOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.orbi.OrbiOAuth2'
+    user_data_url = 'https://login.orbi.kr/oauth/user/get'
+    expected_username = 'foobar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+    })
+    user_data_body = json.dumps({
+        'username': 'foobar',
+        'first_name': 'Foo',
+        'last_name': 'Bar',
+        'name': 'Foo Bar',
+
+        'imin': '100000',
+        'nick': 'foobar',
+        'photo': 'http://s3.orbi.kr/data/member/wi/wizetdev_132894975780.jpeg',
+        'sex': 'M',
+        'birth': '1973-08-03',
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_orcid.py b/rootfs/api/apps_extra/social_core/tests/backends/test_orcid.py
new file mode 100644
index 000000000..e7fa9e31a
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_orcid.py
@@ -0,0 +1,83 @@
+import json
+
+from .oauth import OAuth2Test
+
+from social_core.backends.orcid import ORCIDOAuth2
+
+class ORCIDOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.orcid.ORCIDOAuth2'
+    user_data_url = ORCIDOAuth2.USER_ID_URL
+    expected_username = '0000-0002-2601-8132'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer',
+        'orcid-identifier': {
+            'path': '0000-0002-2601-8132'
+        },
+    })
+    user_data_body = json.dumps({
+        'orcid-identifier': {
+            'uri': 'http://orcid.org/0000-0002-2601-8132',
+            'path': '0000-0002-2601-8132',
+            'host': 'orcid.org'
+        },
+        'person': {
+            'last-modified-date': None,
+            'name': {
+                'created-date': {
+                    'value': 1578249746904
+                },
+                'last-modified-date': {
+                    'value': 1578249746904
+                },
+                'given-names': {
+                    'value': 'Janani Kantharooban'
+                },
+                'family-name': {
+                    'value': 'Umachanger'
+                },
+                'credit-name': None,
+                'source': None,
+                'visibility': 'PUBLIC',
+                'path': '0000-0002-2601-8132'
+            },
+            'other-names': {
+                'last-modified-date': None,
+                'other-name': [],
+                'path': '/0000-0002-2601-8132/other-names'
+            },
+            'biography': None,
+            'researcher-urls': {
+                'last-modified-date': None,
+                'researcher-url': [],
+                'path': '/0000-0002-2601-8132/researcher-urls'
+            },
+            'emails': {
+                'last-modified-date': None,
+                'email': [],
+                'path': '/0000-0002-2601-8132/email'
+            },
+            'addresses': {
+                'last-modified-date': None,
+                'address': [],
+                'path': '/0000-0002-2601-8132/address'
+            },
+            'keywords': {
+                'last-modified-date': None,
+                'keyword': [],
+                'path': '/0000-0002-2601-8132/keywords'
+            },
+            'external-identifiers': {
+                'last-modified-date': None,
+                'external-identifier': [],
+                'path': '/0000-0002-2601-8132/external-identifiers'
+            },
+            'path': '/0000-0002-2601-8132/person'
+        }
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_osso.py b/rootfs/api/apps_extra/social_core/tests/backends/test_osso.py
new file mode 100644
index 000000000..8b93bfe48
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_osso.py
@@ -0,0 +1,35 @@
+import json
+from urllib.parse import urlencode
+from httpretty import HTTPretty
+
+from .oauth import OAuth2Test
+from social_core.backends.osso import OssoOAuth2
+
+class OssoOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.osso.OssoOAuth2'
+    user_data_url = 'https://demo.ossoapp.com/oauth/me'
+    expected_username = 'user@example.com'
+    access_token_body = json.dumps(
+        {
+            'access_token': '3633395cffe739bb87089235c152155ae73b6794f7af353b2aa189aeeacee1ec',
+            'token_type': 'bearer',
+            'expires_in': 600
+        }
+    )
+    user_data_body = json.dumps(
+        {
+            'email': 'user@example.com',
+            'id': 'f23611a5-2817-43e2-94b7-99b25235ad2d',
+            'idp': 'Okta',
+            'requested': {
+                'email': None,
+                'domain': 'example.com'
+            }
+        }
+    )
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
\ No newline at end of file
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_patreon.py b/rootfs/api/apps_extra/social_core/tests/backends/test_patreon.py
new file mode 100644
index 000000000..e5c1e1f40
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_patreon.py
@@ -0,0 +1,67 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class PatreonOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.patreon.PatreonOAuth2'
+    user_data_url = 'https://www.patreon.com/api/oauth2/v2/identity?' + \
+        'fields%5Buser%5D=about,created,email,first_name,full_name,' + \
+        'image_url,last_name,social_connections,thumb_url,url,vanity'
+    expected_username = 'JohnInterwebs'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer',
+    })
+    user_data_body = json.dumps({
+        'data': {
+            'relationships': {
+                'pledges': {
+                    'data': [{
+                        'type': 'pledge', 'id': '123456'
+                    }]
+                }
+            },
+            'attributes': {
+                'last_name': 'Interwebs',
+                'is_suspended': False,
+                'has_password': True,
+                'full_name': 'John Interwebs',
+                'is_nuked': False,
+                'first_name': 'John',
+                'social_connections': {
+                    'spotify': None,
+                    'discord': None,
+                    'twitter': None,
+                    'youtube': None,
+                    'facebook': None,
+                    'deviantart': None,
+                    'twitch': None
+                },
+                'twitter': None,
+                'is_email_verified': True,
+                'facebook_id': None,
+                'email': 'john@example.com',
+                'facebook': None,
+                'thumb_url': 'https://c8.patreon.com/100/123456',
+                'vanity': None,
+                'about': None,
+                'is_deleted': False,
+                'created': '2017-05-05T05:16:34+00:00',
+                'url': 'https://www.patreon.com/user?u=123456',
+                'gender': 0,
+                'youtube': None,
+                'discord_id': None,
+                'image_url': 'https://c8.patreon.com/400/123456',
+                'twitch': None
+            },
+            'type': 'user',
+            'id': '123456'
+        }
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_paypal.py b/rootfs/api/apps_extra/social_core/tests/backends/test_paypal.py
new file mode 100644
index 000000000..90c01ee3d
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_paypal.py
@@ -0,0 +1,80 @@
+import json
+
+from .oauth import OAuth2Test
+from social_core.backends.paypal import PayPalOAuth2
+
+
+class PayPalOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.paypal.PayPalOAuth2'
+    user_data_url = (
+        'https://api.paypal.com/v1/identity/oauth2/userinfo?schema=paypalv1.1'
+    )
+    expected_username = 'mWq6_1sU85v5EG9yHdPxJRrhGHrnMJ-1PQKtX6pcsmA'
+    access_token_body = json.dumps(
+        {
+            'token_type': 'Bearer',
+            'expires_in': 28800,
+            'refresh_token': 'foobar-refresh-token',
+            'access_token': 'foobar-token',
+        }
+    )
+    user_data_body = json.dumps(
+        {
+            'user_id': 'https://www.paypal.com/webapps/auth/identity/user/mWq6_1sU85v5EG9yHdPxJRrhGHrnMJ-1PQKtX6pcsmA',
+            'name': 'identity test',
+            'given_name': 'identity',
+            'family_name': 'test',
+            'payer_id': 'WDJJHEBZ4X2LY',
+            'address': {
+                'street_address': '1 Main St',
+                'locality': 'San Jose',
+                'region': 'CA',
+                'postal_code': '95131',
+                'country': 'US',
+            },
+            'verified_account': True,
+            'emails': [{'value': 'user1@example.com', 'primary': True}],
+        }
+    )
+    refresh_token_body = json.dumps(
+        {
+            'access_token': 'foobar-new-token',
+            'token_type': 'Bearer',
+            'refresh_token': 'foobar-new-refresh-token',
+            'expires_in': 28800,
+        }
+    )
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
+
+    def test_refresh_token(self):
+        user, social = self.do_refresh_token()
+        self.assertEqual(user.username, self.expected_username)
+        self.assertEqual(social.extra_data['access_token'], 'foobar-new-token')
+
+    def test_get_email_no_emails(self):
+        emails = []
+        email = PayPalOAuth2.get_email(emails)
+        self.assertEqual(email, '')
+
+    def test_get_email_multiple_emails(self):
+        expected_email = 'mail2@example.com'
+        emails = [
+            {'value': 'mail1@example.com', 'primary': False},
+            {'value': expected_email, 'primary': True},
+        ]
+        email = PayPalOAuth2.get_email(emails)
+        self.assertEqual(email, expected_email)
+
+    def test_get_email_multiple_emails_no_primary(self):
+        expected_email = 'mail1@example.com'
+        emails = [
+            {'value': expected_email, 'primary': False},
+            {'value': 'mail2@example.com', 'primary': False},
+        ]
+        email = PayPalOAuth2.get_email(emails)
+        self.assertEqual(email, expected_email)
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_phabricator.py b/rootfs/api/apps_extra/social_core/tests/backends/test_phabricator.py
new file mode 100644
index 000000000..56cf233aa
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_phabricator.py
@@ -0,0 +1,69 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class PhabricatorOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.phabricator.PhabricatorOAuth2'
+    user_data_url = 'https://secure.phabricator.com/api/user.whoami'
+    expected_username = 'user'
+    access_token_body = json.dumps({
+        'access_token': 'loremipsumdolorsitametenim',
+        'token_type': 'Bearer',
+        'expires_in': 7200,
+        'refresh_token': 'foobar',
+    })
+
+    user_data_body = json.dumps({
+        'phid': 'PHID-USER-jbfcsj7c6nkt2tv3trb6',
+        'userName': 'user',
+        'realName': 'FirstName LastName',
+        'image': 'https://secure.phabricator.com/file/data/4qjdpmvca4wwkfw2wevc'
+                 '/PHID-FILE-t37vxezr54fjuvbrblkp/alphanumeric_lato-white_U.png'
+                 '-_3f674d-255%2C255%2C255%2C0.7.png',
+        'uri': 'https://secure.phabricator.com/p/user/',
+        'roles': ['admin', 'verified', 'approved', 'activated'],
+        'primaryEmail': 'user@example.com',
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
+
+
+class PhabricatorCustomDomainOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.phabricator.PhabricatorOAuth2'
+    user_data_url = 'https://example.com/api/user.whoami'
+    expected_username = 'user'
+    access_token_body = json.dumps({
+        'access_token': 'loremipsumdolorsitametenim',
+        'token_type': 'Bearer',
+        'expires_in': 7200,
+        'refresh_token': 'foobar',
+    })
+
+    user_data_body = json.dumps({
+        'phid': 'PHID-USER-jbfcsj7c6nkt2tv3trb6',
+        'userName': 'user',
+        'realName': 'FirstName LastName',
+        'image': 'https://example.com/file/data/4qjdpmvca4wwkfw2wevc/PHID-FILE-'
+                 't37vxezr54fjuvbrblkp/alphanumeric_lato-white_U.png-_3f674d-25'
+                 '5%2C255%2C255%2C0.7.png',
+        'uri': 'https://example.com/p/user/',
+        'roles': ['admin', 'verified', 'approved', 'activated'],
+        'primaryEmail': 'user@example.com',
+    })
+
+    def test_login(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_PHABRICATOR_API_URL': 'https://example.com',
+        })
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_PHABRICATOR_API_URL': 'https://example.com',
+        })
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_pinterest.py b/rootfs/api/apps_extra/social_core/tests/backends/test_pinterest.py
new file mode 100644
index 000000000..681161c86
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_pinterest.py
@@ -0,0 +1,49 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class PinterestOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.pinterest.PinterestOAuth2'
+    user_data_url = 'https://api.pinterest.com/v1/me/'
+    expected_username = 'foobar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    user_data_body = json.dumps({
+        'id': '4788400174839062',
+        'first_name': 'Foo',
+        'last_name': 'Bar',
+        'username': 'foobar',
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
+
+
+class PinterestOAuth2BrokenServerResponseTest(OAuth2Test):
+    backend_path = 'social_core.backends.pinterest.PinterestOAuth2'
+    user_data_url = 'https://api.pinterest.com/v1/me/'
+    expected_username = 'foobar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    user_data_body = json.dumps({
+        'data': {
+            'id': '4788400174839062',
+            'first_name': 'Foo',
+            'last_name': 'Bar',
+            'url': 'https://www.pinterest.com/foobar/',
+        }
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_podio.py b/rootfs/api/apps_extra/social_core/tests/backends/test_podio.py
new file mode 100644
index 000000000..c5b956c44
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_podio.py
@@ -0,0 +1,56 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class PodioOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.podio.PodioOAuth2'
+    user_data_url = 'https://api.podio.com/user/status'
+    expected_username = 'user_1010101010'
+    access_token_body = json.dumps({
+        'token_type': 'bearer',
+        'access_token': '11309ea9016a4ad99f1a3bcb9bc7a9d1',
+        'refresh_token': '52d01df8b9ac46a4a6be1333d9f81ef2',
+        'expires_in': 28800,
+        'ref': {
+            'type': 'user',
+            'id': 1010101010,
+        }
+    })
+    user_data_body = json.dumps({
+        'user': {
+            'user_id': 1010101010,
+            'activated_on': '2012-11-22 09:37:21',
+            'created_on': '2012-11-21 12:23:47',
+            'locale': 'en_GB',
+            'timezone': 'Europe/Copenhagen',
+            'mail': 'foo@bar.com',
+            'mails': [
+                {
+                    'disabled': False,
+                    'mail': 'foobar@example.com',
+                    'primary': False,
+                    'verified': True
+                }, {
+                    'disabled': False,
+                    'mail': 'foo@bar.com',
+                    'primary': True,
+                    'verified': True
+                }
+            ],
+            # more properties ...
+        },
+        'profile': {
+            'last_seen_on': '2013-05-16 12:21:13',
+            'link': 'https://podio.com/users/1010101010',
+            'name': 'Foo Bar',
+            # more properties ...
+        }
+        # more properties ...
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_qiita.py b/rootfs/api/apps_extra/social_core/tests/backends/test_qiita.py
new file mode 100644
index 000000000..b3ec39334
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_qiita.py
@@ -0,0 +1,25 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class QiitaOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.qiita.QiitaOAuth2'
+    user_data_url = 'https://qiita.com/api/v2/authenticated_user'
+    expected_username = 'foobar'
+
+    access_token_body = json.dumps({
+        'token': 'foobar',
+        'token_type': 'bearer'
+    })
+
+    user_data_body = json.dumps({
+        'id': 'foobar',
+        'name': 'Foo Bar'
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_quizlet.py b/rootfs/api/apps_extra/social_core/tests/backends/test_quizlet.py
new file mode 100644
index 000000000..77a3dfd04
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_quizlet.py
@@ -0,0 +1,22 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class QuizletOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.quizlet.QuizletOAuth2'
+    expected_username = 'foo_bar'
+
+    access_token_body = json.dumps({
+        'access_token': 'EE1IDxytP04tJ767GbjH7ED9PpGmYvL',
+        'token_type': 'Bearer',
+        'expires_in': 3600,
+        'scope': 'read',
+        'user_id': 'foo_bar'
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_readability.py b/rootfs/api/apps_extra/social_core/tests/backends/test_readability.py
new file mode 100644
index 000000000..302803258
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_readability.py
@@ -0,0 +1,40 @@
+import json
+from urllib.parse import urlencode
+
+from .oauth import OAuth1Test
+
+
+class ReadabilityOAuth1Test(OAuth1Test):
+    backend_path = 'social_core.backends.readability.ReadabilityOAuth'
+    user_data_url = 'https://www.readability.com/api/rest/v1/users/_current'
+    expected_username = 'foobar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    request_token_body = urlencode({
+        'oauth_token_secret': 'foobar-secret',
+        'oauth_token': 'foobar',
+        'oauth_callback_confirmed': 'true'
+    })
+    user_data_body = json.dumps({
+        'username': 'foobar',
+        'first_name': 'Foo',
+        'last_name': 'Bar',
+        'has_active_subscription': False,
+        'tags': [],
+        'is_publisher': False,
+        'email_into_address': 'foobar+sharp@inbox.readability.com',
+        'kindle_email_address': None,
+        'avatar_url': 'https://secure.gravatar.com/avatar/'
+                      '5280f15cedf540b544eecc30fcf3027c?d='
+                      'https://www.readability.com/media/images/'
+                      'avatar.png&s=36',
+        'date_joined': '2013-03-18 02:51:02'
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_reddit.py b/rootfs/api/apps_extra/social_core/tests/backends/test_reddit.py
new file mode 100644
index 000000000..a499a28f6
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_reddit.py
@@ -0,0 +1,59 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class RedditOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.reddit.RedditOAuth2'
+    user_data_url = 'https://oauth.reddit.com/api/v1/me.json'
+    expected_username = 'foobar'
+    access_token_body = json.dumps({
+        'name': 'foobar',
+        'created': 1203420772.0,
+        'access_token': 'foobar-token',
+        'created_utc': 1203420772.0,
+        'expires_in': 3600.0,
+        'link_karma': 34,
+        'token_type': 'bearer',
+        'comment_karma': 167,
+        'over_18': True,
+        'is_gold': False,
+        'is_mod': True,
+        'scope': 'identity',
+        'has_verified_email': False,
+        'id': '33bma',
+        'refresh_token': 'foobar-refresh-token'
+    })
+    user_data_body = json.dumps({
+        'name': 'foobar',
+        'created': 1203420772.0,
+        'created_utc': 1203420772.0,
+        'link_karma': 34,
+        'comment_karma': 167,
+        'over_18': True,
+        'is_gold': False,
+        'is_mod': True,
+        'has_verified_email': False,
+        'id': '33bma'
+    })
+    refresh_token_body = json.dumps({
+        'access_token': 'foobar-new-token',
+        'token_type': 'bearer',
+        'expires_in': 3600.0,
+        'refresh_token': 'foobar-new-refresh-token',
+        'scope': 'identity'
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
+
+    def refresh_token_arguments(self):
+        uri = self.strategy.build_absolute_uri('/complete/reddit/')
+        return {'redirect_uri': uri}
+
+    def test_refresh_token(self):
+        user, social = self.do_refresh_token()
+        self.assertEqual(social.extra_data['access_token'], 'foobar-new-token')
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_saml.py b/rootfs/api/apps_extra/social_core/tests/backends/test_saml.py
new file mode 100644
index 000000000..85e579498
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_saml.py
@@ -0,0 +1,128 @@
+
+import json
+import os
+import re
+import sys
+import unittest
+from os import path
+from urllib.parse import urlparse, urlunparse, urlencode, parse_qs
+
+import requests
+
+from unittest.mock import patch
+from httpretty import HTTPretty
+
+try:
+    from onelogin.saml2.utils import OneLogin_Saml2_Utils
+except ImportError:
+    # Only available for python 2.7 at the moment, so don't worry if this fails
+    pass
+
+from .base import BaseBackendTest
+from ...exceptions import AuthMissingParameter
+
+
+DATA_DIR = path.join(path.dirname(__file__), 'data')
+
+
+@unittest.skipIf('TRAVIS' in os.environ,
+                  'Travis-ci segfaults probably due to a bad '
+                  'dependencies build')
+@unittest.skipIf('__pypy__' in sys.builtin_module_names,
+                  'dm.xmlsec not compatible with pypy')
+class SAMLTest(BaseBackendTest):
+    backend_path = 'social_core.backends.saml.SAMLAuth'
+    expected_username = 'myself'
+
+    def extra_settings(self):
+        name = path.join(DATA_DIR, 'saml_config.json')
+        with open(name, 'r') as config_file:
+            config_str = config_file.read()
+        return json.loads(config_str)
+
+    def setUp(self):
+        """Patch the time so that we can replay canned
+        request/response pairs"""
+        super().setUp()
+
+        @staticmethod
+        def fixed_time():
+            return OneLogin_Saml2_Utils.parse_SAML_to_time(
+                '2015-05-09T03:57:22Z'
+            )
+        now_patch = patch.object(OneLogin_Saml2_Utils, 'now', fixed_time)
+        now_patch.start()
+        self.addCleanup(now_patch.stop)
+
+    def install_http_intercepts(self, start_url, return_url):
+        # When we request start_url
+        # (https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO...)
+        # we will eventually get a redirect back, with SAML assertion
+        # data in the query string.  A pre-recorded correct response
+        # is kept in this .txt file:
+        name = path.join(DATA_DIR, 'saml_response.txt')
+        with open(name, 'r') as response_file:
+            response_url = response_file.read()
+        HTTPretty.register_uri(HTTPretty.GET, start_url, status=301,
+                               location=response_url)
+        HTTPretty.register_uri(HTTPretty.GET, return_url, status=200,
+                               body='foobar')
+
+    def do_start(self):
+        start_url = self.backend.start().url
+        # Modify the start URL to make the SAML request consistent
+        # from test to test:
+        start_url = self.modify_start_url(start_url)
+        # If the SAML Identity Provider recognizes the user, we will
+        # be redirected back to:
+        return_url = self.backend.redirect_uri
+        self.install_http_intercepts(start_url, return_url)
+        response = requests.get(start_url)
+        self.assertTrue(response.url.startswith(return_url))
+        self.assertEqual(response.text, 'foobar')
+        query_values = dict((k, v[0]) for k, v in
+                            parse_qs(urlparse(response.url).query).items())
+        self.assertNotIn(' ', query_values['SAMLResponse'])
+        self.strategy.set_request_data(query_values, self.backend)
+        return self.backend.complete()
+
+    def test_metadata_generation(self):
+        """Test that we can generate the metadata without error"""
+        xml, errors = self.backend.generate_metadata_xml()
+        self.assertEqual(len(errors), 0)
+        self.assertEqual(xml.decode()[0], '<')
+
+    def test_login(self):
+        """Test that we can authenticate with a SAML IdP (TestShib)"""
+        # pretend we've started with a URL like /login/saml/?idp=testshib:
+        self.strategy.set_request_data({'idp': 'testshib'}, self.backend)
+        self.do_login()
+
+    def test_login_no_idp(self):
+        """Logging in without an idp param should raise AuthMissingParameter"""
+        with self.assertRaises(AuthMissingParameter):
+            self.do_start()
+
+    def modify_start_url(self, start_url):
+        """
+        Given a SAML redirect URL, parse it and change the ID to
+        a consistent value, so the request is always identical.
+        """
+        # Parse the SAML Request URL to get the XML being sent to TestShib
+        url_parts = urlparse(start_url)
+        query = dict((k, v[0]) for (k, v) in
+                     parse_qs(url_parts.query).items())
+        xml = OneLogin_Saml2_Utils.decode_base64_and_inflate(
+            query['SAMLRequest']
+        )
+        # Modify the XML:
+        xml = xml.decode()
+        xml, changed = re.subn(r'ID="[^"]+"', 'ID="TEST_ID"', xml)
+        self.assertEqual(changed, 1)
+        # Update the URL to use the modified query string:
+        query['SAMLRequest'] = OneLogin_Saml2_Utils.deflate_and_base64_encode(
+            xml
+        )
+        url_parts = list(url_parts)
+        url_parts[4] = urlencode(query)
+        return urlunparse(url_parts)
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_scistarter.py b/rootfs/api/apps_extra/social_core/tests/backends/test_scistarter.py
new file mode 100644
index 000000000..a1f7fb956
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_scistarter.py
@@ -0,0 +1,29 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class ScistarterOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.scistarter.SciStarterOAuth2'
+    user_data_url = 'https://scistarter.com/api/user_info'
+    expected_username = 'foobar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    user_data_body = json.dumps({
+        'profile_id': 42006,
+        'user_id': 5,
+        'url': 'https://scistarter.com/user/foobar',
+        'result': 'success',
+        'handle': 'foobar',
+        'email': 'foo@bar.com',
+        'first_name': 'foo',
+        'last_name': 'bar'
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_simplelogin.py b/rootfs/api/apps_extra/social_core/tests/backends/test_simplelogin.py
new file mode 100644
index 000000000..27ba044ab
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_simplelogin.py
@@ -0,0 +1,27 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class SimpleLoginOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.simplelogin.SimpleLoginOAuth2'
+    user_data_url = 'https://app.simplelogin.io/oauth2/userinfo'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    user_data_body = json.dumps({
+        'client': 'Continental',
+        'email': 'john@wick.com',
+        'email_verified': True,
+        'id': 1,
+        'name': 'John Wick',
+        'avatar_url': 'http://wick.com/john.png'
+    })
+    expected_username = 'john@wick.com'
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_sketchfab.py b/rootfs/api/apps_extra/social_core/tests/backends/test_sketchfab.py
new file mode 100644
index 000000000..55c8eee32
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_sketchfab.py
@@ -0,0 +1,26 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class SketchfabOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.sketchfab.SketchfabOAuth2'
+    user_data_url = 'https://sketchfab.com/v2/users/me'
+    expected_username = 'foobar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    user_data_body = json.dumps({
+        'uid': '42',
+        'email': 'foo@bar.com',
+        'displayName': 'foo bar',
+        'username': 'foobar',
+        'apiToken': 'XXX'
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_skyrock.py b/rootfs/api/apps_extra/social_core/tests/backends/test_skyrock.py
new file mode 100644
index 000000000..0d1c8e447
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_skyrock.py
@@ -0,0 +1,48 @@
+import json
+from urllib.parse import urlencode
+
+from .oauth import OAuth1Test
+
+
+class SkyrockOAuth1Test(OAuth1Test):
+    backend_path = 'social_core.backends.skyrock.SkyrockOAuth'
+    user_data_url = 'https://api.skyrock.com/v2/user/get.json'
+    expected_username = 'foobar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    request_token_body = urlencode({
+        'oauth_token_secret': 'foobar-secret',
+        'oauth_token': 'foobar',
+    })
+    user_data_body = json.dumps({
+        'locale': 'en_US',
+        'city': '',
+        'has_blog': False,
+        'web_messager_enabled': True,
+        'email': 'foo@bar.com',
+        'username': 'foobar',
+        'firstname': 'Foo',
+        'user_url': '',
+        'address1': '',
+        'address2': '',
+        'has_profile': False,
+        'allow_messages_from': 'everybody',
+        'is_online': False,
+        'postalcode': '',
+        'lang': 'en',
+        'id_user': 10101010,
+        'name': 'Bar',
+        'gender': 0,
+        'avatar_url': 'http://www.skyrock.com/img/avatars/default-0.jpg',
+        'nb_friends': 0,
+        'country': 'US',
+        'birth_date': '1980-06-10'
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_slack.py b/rootfs/api/apps_extra/social_core/tests/backends/test_slack.py
new file mode 100644
index 000000000..7055f574a
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_slack.py
@@ -0,0 +1,76 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class SlackOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.slack.SlackOAuth2'
+    user_data_url = 'https://slack.com/api/users.identity'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    user_data_body = json.dumps({
+        'ok': True,
+        'user': {
+            'email': 'foobar@example.com',
+            'name': 'Foo Bar',
+            'id': u'123456'
+        },
+        'team': {
+            'id': u'456789'
+        },
+        'scope': u'identity.basic,identity.email'
+    })
+    expected_username = 'foobar'
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
+
+
+class SlackOAuth2TestTeamName(SlackOAuth2Test):
+    expected_username = 'foobar@Square'
+    user_data_body = json.dumps({
+        'ok': True,
+        'user': {
+            'email': 'foobar@example.com',
+            'name': 'Foo Bar',
+            'id': u'123456'
+        },
+        'team': {
+            'id': u'456789',
+            'name': u'Square',
+        },
+        'scope': u'identity.basic,identity.email,identity.team'
+    })
+
+
+class SlackOAuth2TestUnicodeTeamName(SlackOAuth2Test):
+    user_data_body = json.dumps({
+        'ok': True,
+        'user': {
+            'email': 'foobar@example.com',
+            'name': 'Foo Bar',
+            'id': u'123456'
+        },
+        'team': {
+            'id': u'456789',
+            'name': u'Square \u221a team',
+        },
+        'scope': u'identity.basic,identity.email,identity.team'
+    })
+
+    def test_login(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_SLACK_USERNAME_WITH_TEAM': False
+        })
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_SLACK_USERNAME_WITH_TEAM': False
+        })
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_soundcloud.py b/rootfs/api/apps_extra/social_core/tests/backends/test_soundcloud.py
new file mode 100644
index 000000000..68f42959a
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_soundcloud.py
@@ -0,0 +1,54 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class SoundcloudOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.soundcloud.SoundcloudOAuth2'
+    user_data_url = 'https://api.soundcloud.com/me.json'
+    expected_username = 'foobar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    user_data_body = json.dumps({
+        'website': None,
+        'myspace_name': None,
+        'public_favorites_count': 0,
+        'followings_count': 0,
+        'full_name': 'Foo Bar',
+        'id': 10101010,
+        'city': None,
+        'track_count': 0,
+        'playlist_count': 0,
+        'discogs_name': None,
+        'private_tracks_count': 0,
+        'followers_count': 0,
+        'online': True,
+        'username': 'foobar',
+        'description': None,
+        'subscriptions': [],
+        'kind': 'user',
+        'quota': {
+            'unlimited_upload_quota': False,
+            'upload_seconds_left': 7200,
+            'upload_seconds_used': 0
+        },
+        'website_title': None,
+        'primary_email_confirmed': False,
+        'permalink_url': 'http://soundcloud.com/foobar',
+        'private_playlists_count': 0,
+        'permalink': 'foobar',
+        'upload_seconds_left': 7200,
+        'country': None,
+        'uri': 'https://api.soundcloud.com/users/10101010',
+        'avatar_url': 'https://a1.sndcdn.com/images/'
+                      'default_avatar_large.png?ca77017',
+        'plan': 'Free'
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_spotify.py b/rootfs/api/apps_extra/social_core/tests/backends/test_spotify.py
new file mode 100644
index 000000000..c8ee1e95b
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_spotify.py
@@ -0,0 +1,34 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class SpotifyOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.spotify.SpotifyOAuth2'
+    user_data_url = 'https://api.spotify.com/v1/me'
+    expected_username = 'foobar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    user_data_body = json.dumps({
+        'display_name': None,
+        'external_urls': {
+            'spotify': 'https://open.spotify.com/user/foobar'
+        },
+        'followers': {
+            'href': None,
+            'total': 0
+        },
+        'href': 'https://api.spotify.com/v1/users/foobar',
+        'id': 'foobar',
+        'images': [],
+        'type': 'user',
+        'uri': 'spotify:user:foobar'
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_stackoverflow.py b/rootfs/api/apps_extra/social_core/tests/backends/test_stackoverflow.py
new file mode 100644
index 000000000..711a29d54
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_stackoverflow.py
@@ -0,0 +1,52 @@
+import json
+from urllib.parse import urlencode
+
+from .oauth import OAuth2Test
+
+
+class StackoverflowOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.stackoverflow.StackoverflowOAuth2'
+    user_data_url = 'https://api.stackexchange.com/2.1/me'
+    expected_username = 'foobar'
+    access_token_body = urlencode({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    user_data_body = json.dumps({
+        'items': [{
+            'user_id': 101010,
+            'user_type': 'registered',
+            'creation_date': 1278525551,
+            'display_name': 'foobar',
+            'profile_image': 'http: //www.gravatar.com/avatar/'
+                             '5280f15cedf540b544eecc30fcf3027c?'
+                             'd=identicon&r=PG',
+            'reputation': 547,
+            'reputation_change_day': 0,
+            'reputation_change_week': 0,
+            'reputation_change_month': 0,
+            'reputation_change_quarter': 65,
+            'reputation_change_year': 65,
+            'age': 22,
+            'last_access_date': 1363544705,
+            'last_modified_date': 1354035327,
+            'is_employee': False,
+            'link': 'http: //stackoverflow.com/users/101010/foobar',
+            'location': 'Fooland',
+            'account_id': 101010,
+            'badge_counts': {
+                'gold': 0,
+                'silver': 3,
+                'bronze': 6
+            }
+        }],
+        'quota_remaining': 9997,
+        'quota_max': 10000,
+        'has_more': False
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_steam.py b/rootfs/api/apps_extra/social_core/tests/backends/test_steam.py
new file mode 100644
index 000000000..c40dd2879
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_steam.py
@@ -0,0 +1,135 @@
+import datetime
+import json
+from urllib.parse import urlencode
+
+from httpretty import HTTPretty
+
+from ...exceptions import AuthFailed
+
+from .open_id import OpenIdTest
+
+
+INFO_URL = 'http://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?'
+JANRAIN_NONCE = datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ')
+
+
+class SteamOpenIdTest(OpenIdTest):
+    backend_path = 'social_core.backends.steam.SteamOpenId'
+    expected_username = 'foobar'
+    discovery_body = ''.join([
+        '<?xml version="1.0" encoding="UTF-8"?>',
+        '<xrds:XRDS xmlns:xrds="xri://$xrds" xmlns="xri://$xrd*($v*2.0)">',
+        '<XRD>',
+        '<Service priority="0">',
+        '<Type>http://specs.openid.net/auth/2.0/server</Type>',
+        '<URI>https://steamcommunity.com/openid/login</URI>',
+        '</Service>',
+        '</XRD>',
+        '</xrds:XRDS>'
+    ])
+    user_discovery_body = ''.join([
+        '<?xml version="1.0" encoding="UTF-8"?>',
+        '<xrds:XRDS xmlns:xrds="xri://$xrds" xmlns="xri://$xrd*($v*2.0)">',
+        '<XRD>',
+        '<Service priority="0">',
+        '<Type>http://specs.openid.net/auth/2.0/signon</Type>		',
+        '<URI>https://steamcommunity.com/openid/login</URI>',
+        '</Service>',
+        '</XRD>',
+        '</xrds:XRDS>'
+    ])
+    server_response = urlencode({
+        'janrain_nonce': JANRAIN_NONCE,
+        'openid.ns': 'http://specs.openid.net/auth/2.0',
+        'openid.mode': 'id_res',
+        'openid.op_endpoint': 'https://steamcommunity.com/openid/login',
+        'openid.claimed_id': 'https://steamcommunity.com/openid/id/123',
+        'openid.identity': 'https://steamcommunity.com/openid/id/123',
+        'openid.return_to': 'http://myapp.com/complete/steam/?'
+                            'janrain_nonce=' + JANRAIN_NONCE,
+        'openid.response_nonce':
+            JANRAIN_NONCE + 'oD4UZ3w9chOAiQXk0AqDipqFYRA=',
+        'openid.assoc_handle': '1234567890',
+        'openid.signed': 'signed,op_endpoint,claimed_id,identity,return_to,'
+                         'response_nonce,assoc_handle',
+        'openid.sig': '1az53vj9SVdiBwhk8%2BFQ68R2plo=',
+    })
+    player_details = json.dumps({
+        'response': {
+            'players': [{
+                'steamid': '123',
+                'primaryclanid': '1234',
+                'timecreated': 1360768416,
+                'personaname': 'foobar',
+                'personastate': 0,
+                'communityvisibilitystate': 3,
+                'profileurl': 'http://steamcommunity.com/profiles/123/',
+                'avatar': 'http://media.steampowered.com/steamcommunity/'
+                          'public/images/avatars/fe/fef49e7fa7e1997310d7'
+                          '05b2a6158ff8dc1cdfeb.jpg',
+                'avatarfull': 'http://media.steampowered.com/steamcommunity/'
+                              'public/images/avatars/fe/fef49e7fa7e1997310d7'
+                              '05b2a6158ff8dc1cdfeb_full.jpg',
+                'avatarmedium': 'http://media.steampowered.com/steamcommunity/'
+                                'public/images/avatars/fe/fef49e7fa7e1997310d7'
+                                '05b2a6158ff8dc1cdfeb_medium.jpg',
+                'lastlogoff': 1360790014
+            }]
+        }
+    })
+
+    def _login_setup(self, user_url=None):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_STEAM_API_KEY': '123abc'
+        })
+        HTTPretty.register_uri(HTTPretty.POST,
+                               'https://steamcommunity.com/openid/login',
+                               status=200,
+                               body=self.server_response)
+        HTTPretty.register_uri(
+            HTTPretty.GET,
+            user_url or 'https://steamcommunity.com/openid/id/123',
+            status=200,
+            body=self.user_discovery_body
+        )
+        HTTPretty.register_uri(HTTPretty.GET,
+                               INFO_URL,
+                               status=200,
+                               body=self.player_details)
+
+    def test_login(self):
+        self._login_setup()
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self._login_setup()
+        self.do_partial_pipeline()
+
+
+class SteamOpenIdMissingSteamIdTest(SteamOpenIdTest):
+    server_response = urlencode({
+        'janrain_nonce': JANRAIN_NONCE,
+        'openid.ns': 'http://specs.openid.net/auth/2.0',
+        'openid.mode': 'id_res',
+        'openid.op_endpoint': 'https://steamcommunity.com/openid/login',
+        'openid.claimed_id': 'https://steamcommunity.com/openid/BROKEN',
+        'openid.identity': 'https://steamcommunity.com/openid/BROKEN',
+        'openid.return_to': 'http://myapp.com/complete/steam/?'
+                            'janrain_nonce=' + JANRAIN_NONCE,
+        'openid.response_nonce':
+            JANRAIN_NONCE + 'oD4UZ3w9chOAiQXk0AqDipqFYRA=',
+        'openid.assoc_handle': '1234567890',
+        'openid.signed': 'signed,op_endpoint,claimed_id,identity,return_to,'
+                         'response_nonce,assoc_handle',
+        'openid.sig': '1az53vj9SVdiBwhk8%2BFQ68R2plo=',
+    })
+
+    def test_login(self):
+        self._login_setup(user_url='https://steamcommunity.com/openid/BROKEN')
+        with self.assertRaises(AuthFailed):
+            self.do_login()
+
+    def test_partial_pipeline(self):
+        self._login_setup(user_url='https://steamcommunity.com/openid/BROKEN')
+        with self.assertRaises(AuthFailed):
+            self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_stocktwits.py b/rootfs/api/apps_extra/social_core/tests/backends/test_stocktwits.py
new file mode 100644
index 000000000..e7a5b047c
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_stocktwits.py
@@ -0,0 +1,54 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class StocktwitsOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.stocktwits.StocktwitsOAuth2'
+    user_data_url = 'https://api.stocktwits.com/api/2/account/verify.json'
+    expected_username = 'foobar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    user_data_body = json.dumps({
+        'response': {
+            'status': 200
+        },
+        'user': {
+            'username': 'foobar',
+            'name': 'Foo Bar',
+            'classification': [],
+            'avatar_url': 'http://avatars.stocktwits.net/images/'
+                          'default_avatar_thumb.jpg',
+            'avatar_url_ssl': 'https://s3.amazonaws.com/st-avatars/images/'
+                              'default_avatar_thumb.jpg',
+            'id': 101010,
+            'identity': 'User'
+        }
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
+
+
+class StocktwitsOAuth2UsernameAlternativeTest(StocktwitsOAuth2Test):
+    user_data_body = json.dumps({
+        'response': {
+            'status': 200
+        },
+        'user': {
+            'username': 'foobar',
+            'name': 'Foobar',
+            'classification': [],
+            'avatar_url': 'http://avatars.stocktwits.net/images/'
+                          'default_avatar_thumb.jpg',
+            'avatar_url_ssl': 'https://s3.amazonaws.com/st-avatars/images/'
+                              'default_avatar_thumb.jpg',
+            'id': 101010,
+            'identity': 'User'
+        }
+    })
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_strava.py b/rootfs/api/apps_extra/social_core/tests/backends/test_strava.py
new file mode 100644
index 000000000..10dde5c07
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_strava.py
@@ -0,0 +1,72 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class StravaOAuthTest(OAuth2Test):
+    backend_path = 'social_core.backends.strava.StravaOAuth'
+    user_data_url = 'https://www.strava.com/api/v3/athlete'
+    expected_username = 'marianne_v'
+    access_token_body = json.dumps({
+        'token_type': 'Bearer',
+        'expires_at': 1572805000,
+        'expires_in': 227615,
+        'refresh_token': 'f51defab4632d27255dd0d106504dfd7568fd1df6',
+        'access_token': '83ebeabdec09f6670863766f792ead24d61fe3f9',
+        'athlete': {
+            'id': 1234567890987654321,
+            'username': 'marianne_v',
+            'resource_state': 2,
+            'firstname': 'Marianne',
+            'lastname': 'V.',
+            'city': 'Francisco',
+            'state': 'California',
+            'country': 'United States',
+            'sex': 'F',
+            'premium': 'true',
+            'summit': 'true',
+            'created_at': '2017-11-14T02:30:05Z',
+            'updated_at': '2018-02-06T19:32:20Z',
+            'badge_type_id': 4,
+            'profile_medium': 'https://xxxxxx.cloudfront.net/pictures/athletes/123456789/123456789/2/medium.jpg',
+            'profile': 'https://xxxxx.cloudfront.net/pictures/athletes/123456789/123456789/2/large.jpg',
+            'friend': 'null',
+            'follower': 'null'
+        }
+    })
+    user_data_body = json.dumps({
+        'id': 1234567890987654321,
+        'username': 'marianne_v',
+        'resource_state': 3,
+        'firstname': 'Marianne',
+        'lastname': 'V.',
+        'city': 'San Francisco',
+        'state': 'CA',
+        'country': 'US',
+        'sex': 'F',
+        'premium': 'true',
+        'created_at': '2017-11-14T02:30:05Z',
+        'updated_at': '2018-02-06T19:32:20Z',
+        'badge_type_id': 4,
+        'profile_medium': 'https://xxxxxx.cloudfront.net/pictures/athletes/123456789/123456789/2/medium.jpg',
+        'profile': 'https://xxxxx.cloudfront.net/pictures/athletes/123456789/123456789/2/large.jpg',
+        'friend': 'null',
+        'follower': 'null',
+        'follower_count': 5,
+        'friend_count': 5,
+        'mutual_friend_count': 0,
+        'athlete_type': 1,
+        'date_preference': '%m/%d/%Y',
+        'measurement_preference': 'feet',
+        'clubs': [],
+        'ftp': 'null',
+        'weight': 0,
+        'bikes': [],
+        'shoes': []
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_stripe.py b/rootfs/api/apps_extra/social_core/tests/backends/test_stripe.py
new file mode 100644
index 000000000..4297f962f
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_stripe.py
@@ -0,0 +1,23 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class StripeOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.stripe.StripeOAuth2'
+    access_token_body = json.dumps({
+        'stripe_publishable_key': 'pk_test_foobar',
+        'access_token': 'foobar',
+        'livemode': False,
+        'token_type': 'bearer',
+        'scope': 'read_only',
+        'refresh_token': 'rt_foobar',
+        'stripe_user_id': 'acct_foobar'
+    })
+    expected_username = 'acct_foobar'
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_taobao.py b/rootfs/api/apps_extra/social_core/tests/backends/test_taobao.py
new file mode 100644
index 000000000..ba0f67788
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_taobao.py
@@ -0,0 +1,29 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class TaobaoOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.taobao.TAOBAOAuth'
+    user_data_url = 'https://eco.taobao.com/router/rest'
+    expected_username = 'foobar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    user_data_body = json.dumps({
+        'w2_expires_in': 0,
+        'taobao_user_id': '1',
+        'taobao_user_nick': 'foobar',
+        'w1_expires_in': 1800,
+        're_expires_in': 0,
+        'r2_expires_in': 0,
+        'expires_in': 86400,
+        'r1_expires_in': 1800
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_thisismyjam.py b/rootfs/api/apps_extra/social_core/tests/backends/test_thisismyjam.py
new file mode 100644
index 000000000..aecdfe5eb
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_thisismyjam.py
@@ -0,0 +1,32 @@
+import json
+from urllib.parse import urlencode
+
+from .oauth import OAuth1Test
+
+
+class ThisIsMyJameOAuth1Test(OAuth1Test):
+    backend_path = 'social_core.backends.thisismyjam.ThisIsMyJamOAuth1'
+    user_data_url = 'http://api.thisismyjam.com/1/verify.json'
+    expected_username = 'foobar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    request_token_body = urlencode({
+        'oauth_token_secret': 'foobar-secret',
+        'oauth_token': 'foobar',
+        'oauth_callback_confirmed': 'true'
+    })
+    user_data_body = json.dumps({
+        'id': 10101010,
+        'person': {
+            'name': 'foobar',
+            'fullname': 'Foo Bar'
+        }
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_tripit.py b/rootfs/api/apps_extra/social_core/tests/backends/test_tripit.py
new file mode 100644
index 000000000..e44d1befa
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_tripit.py
@@ -0,0 +1,101 @@
+import json
+from urllib.parse import urlencode
+
+from .oauth import OAuth1Test
+
+
+class TripitOAuth1Test(OAuth1Test):
+    backend_path = 'social_core.backends.tripit.TripItOAuth'
+    user_data_url = 'https://api.tripit.com/v1/get/profile'
+    expected_username = 'foobar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    request_token_body = urlencode({
+        'oauth_token_secret': 'foobar-secret',
+        'oauth_token': 'foobar',
+        'oauth_callback_confirmed': 'true'
+    })
+    user_data_content_type = 'text/xml'
+    user_data_body = \
+        '<Response>' \
+        '<timestamp>1363590451</timestamp>' \
+        '<num_bytes>1040</num_bytes>' \
+        '<Profile ref="ignore-me">' \
+        '<ProfileEmailAddresses>' \
+        '<ProfileEmailAddress>' \
+        '<address>foobar@gmail.com</address>' \
+        '<is_auto_import>false</is_auto_import>' \
+        '<is_confirmed>true</is_confirmed>' \
+        '<is_primary>true</is_primary>' \
+        '<is_auto_inbox_eligible>' \
+        'true' \
+        '</is_auto_inbox_eligible>' \
+        '</ProfileEmailAddress>' \
+        '</ProfileEmailAddresses>' \
+        '<is_client>true</is_client>' \
+        '<is_pro>false</is_pro>' \
+        '<screen_name>foobar</screen_name>' \
+        '<public_display_name>Foo Bar</public_display_name>' \
+        '<profile_url>people/foobar</profile_url>' \
+        '<home_city>Foo, Barland</home_city>' \
+        '<activity_feed_url>' \
+        'https://www.tripit.com/feed/activities/private/' \
+        'ignore-this/activities.atom' \
+        '</activity_feed_url>' \
+        '<alerts_feed_url>' \
+        'https://www.tripit.com/feed/alerts/private/' \
+        'ignore-this/alerts.atom' \
+        '</alerts_feed_url>' \
+        '<ical_url>' \
+        'webcal://www.tripit.com/feed/ical/private/' \
+        'ignore-this/tripit.ics' \
+        '</ical_url>' \
+        '</Profile>' \
+        '</Response>'
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
+
+
+class TripitOAuth1UsernameAlternativesTest(TripitOAuth1Test):
+    user_data_body = \
+        '<Response>' \
+        '<timestamp>1363590451</timestamp>' \
+        '<num_bytes>1040</num_bytes>' \
+        '<Profile ref="ignore-me">' \
+        '<ProfileEmailAddresses>' \
+        '<ProfileEmailAddress>' \
+        '<address>foobar@gmail.com</address>' \
+        '<is_auto_import>false</is_auto_import>' \
+        '<is_confirmed>true</is_confirmed>' \
+        '<is_primary>true</is_primary>' \
+        '<is_auto_inbox_eligible>' \
+        'true' \
+        '</is_auto_inbox_eligible>' \
+        '</ProfileEmailAddress>' \
+        '</ProfileEmailAddresses>' \
+        '<is_client>true</is_client>' \
+        '<is_pro>false</is_pro>' \
+        '<screen_name>foobar</screen_name>' \
+        '<public_display_name>Foobar</public_display_name>' \
+        '<profile_url>people/foobar</profile_url>' \
+        '<home_city>Foo, Barland</home_city>' \
+        '<activity_feed_url>' \
+        'https://www.tripit.com/feed/activities/private/' \
+        'ignore-this/activities.atom' \
+        '</activity_feed_url>' \
+        '<alerts_feed_url>' \
+        'https://www.tripit.com/feed/alerts/private/' \
+        'ignore-this/alerts.atom' \
+        '</alerts_feed_url>' \
+        '<ical_url>' \
+        'webcal://www.tripit.com/feed/ical/private/' \
+        'ignore-this/tripit.ics' \
+        '</ical_url>' \
+        '</Profile>' \
+        '</Response>'
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_tumblr.py b/rootfs/api/apps_extra/social_core/tests/backends/test_tumblr.py
new file mode 100644
index 000000000..3d2e2a250
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_tumblr.py
@@ -0,0 +1,59 @@
+import json
+from urllib.parse import urlencode
+
+from .oauth import OAuth1Test
+
+
+class TumblrOAuth1Test(OAuth1Test):
+    backend_path = 'social_core.backends.tumblr.TumblrOAuth'
+    user_data_url = 'http://api.tumblr.com/v2/user/info'
+    expected_username = 'foobar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    request_token_body = urlencode({
+        'oauth_token_secret': 'foobar-secret',
+        'oauth_token': 'foobar',
+        'oauth_callback_confirmed': 'true'
+    })
+    user_data_body = json.dumps({
+        'meta': {
+            'status': 200,
+            'msg': 'OK'
+        },
+        'response': {
+            'user': {
+                'following': 1,
+                'blogs': [{
+                    'updated': 0,
+                    'description': '',
+                    'drafts': 0,
+                    'title': 'Untitled',
+                    'url': 'http://foobar.tumblr.com/',
+                    'messages': 0,
+                    'tweet': 'N',
+                    'share_likes': True,
+                    'posts': 0,
+                    'primary': True,
+                    'queue': 0,
+                    'admin': True,
+                    'followers': 0,
+                    'ask': False,
+                    'facebook': 'N',
+                    'type': 'public',
+                    'facebook_opengraph_enabled': 'N',
+                    'name': 'foobar'
+                }],
+                'default_post_format': 'html',
+                'name': 'foobar',
+                'likes': 0
+            }
+        }
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_twitch.py b/rootfs/api/apps_extra/social_core/tests/backends/test_twitch.py
new file mode 100644
index 000000000..67b1e3756
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_twitch.py
@@ -0,0 +1,36 @@
+import json
+from .oauth import OAuth2Test
+
+
+class TwitchOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.twitch.TwitchOAuth2'
+    user_data_url = 'https://api.twitch.tv/kraken/user/'
+    expected_username = 'test_user1'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+    })
+    user_data_body = json.dumps({
+        'type': 'user',
+        'name': 'test_user1',
+        'created_at': '2011-06-03T17:49:19Z',
+        'updated_at': '2012-06-18T17:19:57Z',
+        'logo': 'http://static-cdn.jtvnw.net/jtv_user_pictures/'
+                'test_user1-profile_image-62e8318af864d6d7-300x300.jpeg',
+        '_id': 22761313,
+        'display_name': 'test_user1',
+        'bio': 'test bio woo I\'m a test user',
+        'email': 'asdf@asdf.com',
+        'email_verified': True,
+        'partnered': True,
+        'twitter_connected': False,
+        'notifications': {
+            'push': True,
+            'email': True
+        }
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_twitter.py b/rootfs/api/apps_extra/social_core/tests/backends/test_twitter.py
new file mode 100644
index 000000000..ffc2d2c37
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_twitter.py
@@ -0,0 +1,260 @@
+import json
+from urllib.parse import urlencode
+
+from .oauth import OAuth1Test
+
+
+class TwitterOAuth1Test(OAuth1Test):
+    backend_path = 'social_core.backends.twitter.TwitterOAuth'
+    user_data_url = 'https://api.twitter.com/1.1/account/' \
+        'verify_credentials.json'
+    expected_username = 'foobar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    request_token_body = urlencode({
+        'oauth_token_secret': 'foobar-secret',
+        'oauth_token': 'foobar',
+        'oauth_callback_confirmed': 'true'
+    })
+    user_data_body = json.dumps({
+        'follow_request_sent': False,
+        'profile_use_background_image': True,
+        'id': 10101010,
+        'description': 'Foo bar baz qux',
+        'verified': False,
+        'entities': {
+            'description': {
+                'urls': []
+            }
+        },
+        'profile_image_url_https': 'https://twimg0-a.akamaihd.net/'
+                                   'profile_images/532018826/'
+                                   'n587119531_1939735_9305_normal.jpg',
+        'profile_sidebar_fill_color': '252429',
+        'profile_text_color': '666666',
+        'followers_count': 77,
+        'profile_sidebar_border_color': '181A1E',
+        'location': 'Fooland',
+        'default_profile_image': False,
+        'listed_count': 4,
+        'status': {
+            'favorited': False,
+            'contributors': None,
+            'retweeted_status': {
+                'favorited': False,
+                'contributors': None,
+                'truncated': False,
+                'source': 'web',
+                'text': '"Foo foo foo foo',
+                'created_at': 'Fri Dec 21 18:12:00 +0000 2012',
+                'retweeted': True,
+                'in_reply_to_status_id': None,
+                'coordinates': None,
+                'id': 101010101010101010,
+                'entities': {
+                    'user_mentions': [],
+                    'hashtags': [],
+                    'urls': []
+                },
+                'in_reply_to_status_id_str': None,
+                'place': None,
+                'id_str': '101010101010101010',
+                'in_reply_to_screen_name': None,
+                'retweet_count': 8,
+                'geo': None,
+                'in_reply_to_user_id_str': None,
+                'in_reply_to_user_id': None
+            },
+            'truncated': False,
+            'source': 'web',
+            'text': 'RT @foo: "Foo foo foo foo',
+            'created_at': 'Fri Dec 21 18:24:10 +0000 2012',
+            'retweeted': True,
+            'in_reply_to_status_id': None,
+            'coordinates': None,
+            'id': 101010101010101010,
+            'entities': {
+                'user_mentions': [{
+                    'indices': [3, 10],
+                    'id': 10101010,
+                    'screen_name': 'foo',
+                    'id_str': '10101010',
+                    'name': 'Foo'
+                }],
+                'hashtags': [],
+                'urls': []
+            },
+            'in_reply_to_status_id_str': None,
+            'place': None,
+            'id_str': '101010101010101010',
+            'in_reply_to_screen_name': None,
+            'retweet_count': 8,
+            'geo': None,
+            'in_reply_to_user_id_str': None,
+            'in_reply_to_user_id': None
+        },
+        'utc_offset': -10800,
+        'statuses_count': 191,
+        'profile_background_color': '1A1B1F',
+        'friends_count': 151,
+        'profile_background_image_url_https': 'https://twimg0-a.akamaihd.net/'
+                                              'images/themes/theme9/bg.gif',
+        'profile_link_color': '2FC2EF',
+        'profile_image_url': 'http://a0.twimg.com/profile_images/532018826/'
+                             'n587119531_1939735_9305_normal.jpg',
+        'is_translator': False,
+        'geo_enabled': False,
+        'id_str': '74313638',
+        'profile_background_image_url': 'http://a0.twimg.com/images/themes/'
+                                        'theme9/bg.gif',
+        'screen_name': 'foobar',
+        'lang': 'en',
+        'profile_background_tile': False,
+        'favourites_count': 2,
+        'name': 'Foo',
+        'notifications': False,
+        'url': None,
+        'created_at': 'Tue Sep 15 00:26:17 +0000 2009',
+        'contributors_enabled': False,
+        'time_zone': 'Buenos Aires',
+        'protected': False,
+        'default_profile': False,
+        'following': False
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
+
+
+class TwitterOAuth1IncludeEmailTest(OAuth1Test):
+    backend_path = 'social_core.backends.twitter.TwitterOAuth'
+    user_data_url = 'https://api.twitter.com/1.1/account/' \
+        'verify_credentials.json?include_email=true'
+    expected_username = 'foobar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    request_token_body = urlencode({
+        'oauth_token_secret': 'foobar-secret',
+        'oauth_token': 'foobar',
+        'oauth_callback_confirmed': 'true'
+    })
+    user_data_body = json.dumps({
+        'follow_request_sent': False,
+        'profile_use_background_image': True,
+        'id': 10101010,
+        'description': 'Foo bar baz qux',
+        'verified': False,
+        'entities': {
+            'description': {
+                'urls': []
+            }
+        },
+        'profile_image_url_https': 'https://twimg0-a.akamaihd.net/'
+                                   'profile_images/532018826/'
+                                   'n587119531_1939735_9305_normal.jpg',
+        'profile_sidebar_fill_color': '252429',
+        'profile_text_color': '666666',
+        'followers_count': 77,
+        'profile_sidebar_border_color': '181A1E',
+        'location': 'Fooland',
+        'default_profile_image': False,
+        'listed_count': 4,
+        'status': {
+            'favorited': False,
+            'contributors': None,
+            'retweeted_status': {
+                'favorited': False,
+                'contributors': None,
+                'truncated': False,
+                'source': 'web',
+                'text': '"Foo foo foo foo',
+                'created_at': 'Fri Dec 21 18:12:00 +0000 2012',
+                'retweeted': True,
+                'in_reply_to_status_id': None,
+                'coordinates': None,
+                'id': 101010101010101010,
+                'entities': {
+                    'user_mentions': [],
+                    'hashtags': [],
+                    'urls': []
+                },
+                'in_reply_to_status_id_str': None,
+                'place': None,
+                'id_str': '101010101010101010',
+                'in_reply_to_screen_name': None,
+                'retweet_count': 8,
+                'geo': None,
+                'in_reply_to_user_id_str': None,
+                'in_reply_to_user_id': None
+            },
+            'truncated': False,
+            'source': 'web',
+            'text': 'RT @foo: "Foo foo foo foo',
+            'created_at': 'Fri Dec 21 18:24:10 +0000 2012',
+            'retweeted': True,
+            'in_reply_to_status_id': None,
+            'coordinates': None,
+            'id': 101010101010101010,
+            'entities': {
+                'user_mentions': [{
+                    'indices': [3, 10],
+                    'id': 10101010,
+                    'screen_name': 'foo',
+                    'id_str': '10101010',
+                    'name': 'Foo'
+                }],
+                'hashtags': [],
+                'urls': []
+            },
+            'in_reply_to_status_id_str': None,
+            'place': None,
+            'id_str': '101010101010101010',
+            'in_reply_to_screen_name': None,
+            'retweet_count': 8,
+            'geo': None,
+            'in_reply_to_user_id_str': None,
+            'in_reply_to_user_id': None
+        },
+        'utc_offset': -10800,
+        'statuses_count': 191,
+        'profile_background_color': '1A1B1F',
+        'friends_count': 151,
+        'profile_background_image_url_https': 'https://twimg0-a.akamaihd.net/'
+                                              'images/themes/theme9/bg.gif',
+        'profile_link_color': '2FC2EF',
+        'profile_image_url': 'http://a0.twimg.com/profile_images/532018826/'
+                             'n587119531_1939735_9305_normal.jpg',
+        'is_translator': False,
+        'geo_enabled': False,
+        'id_str': '74313638',
+        'profile_background_image_url': 'http://a0.twimg.com/images/themes/'
+                                        'theme9/bg.gif',
+        'screen_name': 'foobar',
+        'lang': 'en',
+        'profile_background_tile': False,
+        'favourites_count': 2,
+        'name': 'Foo',
+        'notifications': False,
+        'url': None,
+        'created_at': 'Tue Sep 15 00:26:17 +0000 2009',
+        'contributors_enabled': False,
+        'time_zone': 'Buenos Aires',
+        'protected': False,
+        'default_profile': False,
+        'following': False,
+        'email': 'foo@bar.bas'
+    })
+
+    def test_login(self):
+        user = self.do_login()
+        self.assertEqual(user.email, 'foo@bar.bas')
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_uber.py b/rootfs/api/apps_extra/social_core/tests/backends/test_uber.py
new file mode 100644
index 000000000..fb236648b
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_uber.py
@@ -0,0 +1,32 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class UberOAuth2Test(OAuth2Test):
+    user_data_url = 'https://api.uber.com/v1/me'
+    backend_path = 'social_core.backends.uber.UberOAuth2'
+    expected_username = 'foo@bar.com'
+
+    user_data_body = json.dumps({
+        'first_name': 'Foo',
+        'last_name': 'Bar',
+        'email': 'foo@bar.com',
+        'picture': 'https://',
+        'promo_code': 'barfoo',
+        'uuid': '91d81273-45c2-4b57-8124-d0165f8240c0'
+    })
+
+    access_token_body = json.dumps({
+        'access_token': 'EE1IDxytP04tJ767GbjH7ED9PpGmYvL',
+        'token_type': 'Bearer',
+        'expires_in': 2592000,
+        'refresh_token': 'Zx8fJ8qdSRRseIVlsGgtgQ4wnZBehr',
+        'scope': 'profile history request'
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_udata.py b/rootfs/api/apps_extra/social_core/tests/backends/test_udata.py
new file mode 100644
index 000000000..e3cb6b86f
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_udata.py
@@ -0,0 +1,28 @@
+import json
+from urllib.parse import urlencode
+
+from .oauth import OAuth2Test
+
+
+class DatagouvfrOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.udata.DatagouvfrOAuth2'
+    user_data_url = 'https://www.data.gouv.fr/api/1/me/'
+    expected_username = 'foobar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer',
+        'first_name': 'foobar',
+        'email': 'foobar@example.com'
+    })
+    request_token_body = urlencode({
+        'oauth_token_secret': 'foobar-secret',
+        'oauth_token': 'foobar',
+        'oauth_callback_confirmed': 'true'
+    })
+    user_data_body = json.dumps({})
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_universe.py b/rootfs/api/apps_extra/social_core/tests/backends/test_universe.py
new file mode 100644
index 000000000..27cef36c3
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_universe.py
@@ -0,0 +1,32 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class UniverseAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.universe.UniverseOAuth2'
+    user_data_url = 'https://www.universe.com/api/v2/current_user'
+    expected_username = 'scott+awesome@universe.com'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    user_data_body = json.dumps(
+        {
+            'current_user': {
+                'id': '123456',
+                'slug': 'foo-bar',
+                'first_name': 'Scott',
+                'last_name': 'Vitale',
+                'created_at': '2019-01-08T15:49:42.514Z',
+                'updated_at': '2019-01-17T19:41:39.711Z',
+                'email': 'scott+awesome@universe.com',
+            }
+        }
+    )
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_upwork.py b/rootfs/api/apps_extra/social_core/tests/backends/test_upwork.py
new file mode 100644
index 000000000..7069d877b
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_upwork.py
@@ -0,0 +1,53 @@
+import json
+from urllib.parse import urlencode
+
+from .oauth import OAuth1Test
+
+
+class UpworkOAuth1Test(OAuth1Test):
+    backend_path = 'social_core.backends.upwork.UpworkOAuth'
+    user_data_url = 'https://www.upwork.com/api/auth/v1/info.json'
+    expected_username = '10101010'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    request_token_body = urlencode({
+        'oauth_token_secret': 'foobar-secret',
+        'oauth_token': 'foobar',
+        'oauth_callback_confirmed': 'true'
+    })
+    user_data_body = json.dumps({
+        'info': {
+            'portrait_32_img': '',
+            'capacity': {
+                'buyer': 'no',
+                'affiliate_manager': 'no',
+                'provider': 'yes'
+            },
+            'company_url': '',
+            'has_agency': '1',
+            'portrait_50_img': '',
+            'portrait_100_img': '',
+            'location': {
+                'city': 'New York',
+                'state': '',
+                'country': 'USA'
+            },
+            'ref': '9755314',
+            'profile_url': 'https://www.upwork.com/users/~10101010'
+        },
+        'auth_user': {
+            'timezone': 'USA/New York',
+            'first_name': 'Foo',
+            'last_name': 'Bar',
+            'timezone_offset': '10000'
+        },
+        'server_time': '1111111111'
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_username.py b/rootfs/api/apps_extra/social_core/tests/backends/test_username.py
new file mode 100644
index 000000000..bc79e5f1b
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_username.py
@@ -0,0 +1,19 @@
+from .legacy import BaseLegacyTest
+
+
+class UsernameTest(BaseLegacyTest):
+    backend_path = 'social_core.backends.username.UsernameAuth'
+    expected_username = 'foobar'
+    response_body = 'username=foobar'
+    form = """
+    <form method="post" action="{0}">
+        <input name="username" type="text">
+        <button>Submit</button>
+    </form>
+    """
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_utils.py b/rootfs/api/apps_extra/social_core/tests/backends/test_utils.py
new file mode 100644
index 000000000..3fbfeff36
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_utils.py
@@ -0,0 +1,48 @@
+import unittest as unittest
+
+from ..models import TestStorage
+from ..strategy import TestStrategy
+from ...backends.utils import load_backends, get_backend
+from ...backends.github import GithubOAuth2
+from ...exceptions import MissingBackend
+
+
+class BaseBackendUtilsTest(unittest.TestCase):
+    def setUp(self):
+        self.strategy = TestStrategy(storage=TestStorage)
+
+    def tearDown(self):
+        self.strategy = None
+
+
+class LoadBackendsTest(BaseBackendUtilsTest):
+    def test_load_backends(self):
+        loaded_backends = load_backends((
+            'social_core.backends.github.GithubOAuth2',
+            'social_core.backends.facebook.FacebookOAuth2',
+            'social_core.backends.flickr.FlickrOAuth'
+        ), force_load=True)
+        keys = list(loaded_backends.keys())
+        self.assertEqual(keys, ['github', 'facebook', 'flickr'])
+
+        backends = ()
+        loaded_backends = load_backends(backends, force_load=True)
+        self.assertEqual(len(list(loaded_backends.keys())), 0)
+
+
+class GetBackendTest(BaseBackendUtilsTest):
+    def test_get_backend(self):
+        backend = get_backend((
+            'social_core.backends.github.GithubOAuth2',
+            'social_core.backends.facebook.FacebookOAuth2',
+            'social_core.backends.flickr.FlickrOAuth'
+        ), 'github')
+        self.assertEqual(backend, GithubOAuth2)
+
+    def test_get_missing_backend(self):
+        with self.assertRaisesRegex(MissingBackend,
+                                    'Missing backend "foobar" entry'):
+            get_backend(('social_core.backends.github.GithubOAuth2',
+                         'social_core.backends.facebook.FacebookOAuth2',
+                         'social_core.backends.flickr.FlickrOAuth'),
+                        'foobar')
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_vk.py b/rootfs/api/apps_extra/social_core/tests/backends/test_vk.py
new file mode 100644
index 000000000..606b899d3
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_vk.py
@@ -0,0 +1,32 @@
+# coding: utf-8
+from __future__ import unicode_literals
+
+import json
+
+from .oauth import OAuth2Test
+
+
+class VKOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.vk.VKOAuth2'
+    user_data_url = 'https://api.vk.com/method/users.get'
+    expected_username = 'durov'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    user_data_body = json.dumps({
+        'response': [{
+            'uid': '1',
+            'first_name': 'Павел',
+            'last_name': 'Дуров',
+            'screen_name': 'durov',
+            'nickname': '',
+            'photo': r'http:\/\/cs7003.vk.me\/v7003815\/22a1\/xgG9fb-IJ3Y.jpg'
+        }]
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_wunderlist.py b/rootfs/api/apps_extra/social_core/tests/backends/test_wunderlist.py
new file mode 100644
index 000000000..58974d56d
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_wunderlist.py
@@ -0,0 +1,26 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class WunderlistOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.wunderlist.WunderlistOAuth2'
+    user_data_url = 'https://a.wunderlist.com/api/v1/user'
+    expected_username = '12345'
+    access_token_body = json.dumps({
+        'access_token': 'foobar-token',
+        'token_type': 'foobar'})
+    user_data_body = json.dumps({
+        'created_at': '2015-01-21T00:56:51.442Z',
+        'email': 'foo@bar.com',
+        'id': 12345,
+        'name': 'foobar',
+        'revision': 1,
+        'type': 'user',
+        'updated_at': '2015-01-21T00:56:51.442Z'})
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_xing.py b/rootfs/api/apps_extra/social_core/tests/backends/test_xing.py
new file mode 100644
index 000000000..575fb496b
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_xing.py
@@ -0,0 +1,169 @@
+import json
+from urllib.parse import urlencode
+
+from .oauth import OAuth1Test
+
+
+class XingOAuth1Test(OAuth1Test):
+    backend_path = 'social_core.backends.xing.XingOAuth'
+    user_data_url = 'https://api.xing.com/v1/users/me.json'
+    expected_username = 'FooBar'
+    access_token_body = urlencode({
+        'access_token': 'foobar',
+        'token_type': 'bearer',
+        'user_id': '123456_abcdef',
+        'oauth_token_secret': 'foobar-secret',
+        'oauth_token': 'foobar',
+    })
+    request_token_body = urlencode({
+        'oauth_token_secret': 'foobar-secret',
+        'oauth_token': 'foobar',
+        'oauth_callback_confirmed': 'true'
+    })
+    user_data_body = json.dumps({
+        'users': [{
+            'id': '123456_abcdef',
+            'first_name': 'Foo',
+            'last_name': 'Bar',
+            'display_name': 'Foo Bar',
+            'page_name': 'Foo_Bar',
+            'permalink': 'https://www.xing.com/profile/Foo_Bar',
+            'gender': 'm',
+            'birth_date': {
+                'day': 12,
+                'month': 8,
+                'year': 1963
+            },
+            'active_email': 'foo@bar.com',
+            'time_zone': {
+                'name': 'Europe/Copenhagen',
+                'utc_offset': 2.0
+            },
+            'premium_services': ['SEARCH', 'PRIVATEMESSAGES'],
+            'badges': ['PREMIUM', 'MODERATOR'],
+            'wants': 'Nothing',
+            'haves': 'Skills',
+            'interests': 'Foo Foo',
+            'organisation_member': 'ACM, GI',
+            'languages': {
+                'de': 'NATIVE',
+                'en': 'FLUENT',
+                'fr': None,
+                'zh': 'BASIC'
+            },
+            'private_address': {
+                'city': 'Foo',
+                'country': 'DE',
+                'zip_code': '20357',
+                'street': 'Bar',
+                'phone': '12|34|1234560',
+                'fax': '||',
+                'province': 'Foo',
+                'email': 'foo@bar.com',
+                'mobile_phone': '12|3456|1234567'
+            },
+            'business_address': {
+                'city': 'Foo',
+                'country': 'DE',
+                'zip_code': '20357',
+                'street': 'Bar',
+                'phone': '12|34|1234569',
+                'fax': '12|34|1234561',
+                'province': 'Foo',
+                'email': 'foo@bar.com',
+                'mobile_phone': '12|345|12345678'
+            },
+            'web_profiles': {
+                'qype': ['http://qype.de/users/foo'],
+                'google_plus': ['http://plus.google.com/foo'],
+                'blog': ['http://blog.example.org'],
+                'homepage': ['http://example.org', 'http://other-example.org']
+            },
+            'instant_messaging_accounts': {
+                'skype': 'foobar',
+                'googletalk': 'foobar'
+            },
+            'professional_experience': {
+                'primary_company': {
+                    'name': 'XING AG',
+                    'title': 'Softwareentwickler',
+                    'company_size': '201-500',
+                    'tag': None,
+                    'url': 'http://www.xing.com',
+                    'career_level': 'PROFESSIONAL_EXPERIENCED',
+                    'begin_date': '2010-01',
+                    'description': None,
+                    'end_date': None,
+                    'industry': 'AEROSPACE'
+                },
+                'non_primary_companies': [{
+                    'name': 'Ninja Ltd.',
+                    'title': 'DevOps',
+                    'company_size': None,
+                    'tag': 'NINJA',
+                    'url': 'http://www.ninja-ltd.co.uk',
+                    'career_level': None,
+                    'begin_date': '2009-04',
+                    'description': None,
+                    'end_date': '2010-07',
+                    'industry': 'ALTERNATIVE_MEDICINE'
+                }, {
+                    'name': None,
+                    'title': 'Wiss. Mitarbeiter',
+                    'company_size': None,
+                    'tag': 'OFFIS',
+                    'url': 'http://www.uni.de',
+                    'career_level': None,
+                    'begin_date': '2007',
+                    'description': None,
+                    'end_date': '2008',
+                    'industry': 'APPAREL_AND_FASHION'
+                }, {
+                    'name': None,
+                    'title': 'TEST NINJA',
+                    'company_size': '201-500',
+                    'tag': 'TESTCOMPANY',
+                    'url': None,
+                    'career_level': 'ENTRY_LEVEL',
+                    'begin_date': '1998-12',
+                    'description': None,
+                    'end_date': '1999-05',
+                    'industry': 'ARTS_AND_CRAFTS'
+                }],
+                'awards': [{
+                    'name': 'Awesome Dude Of The Year',
+                    'date_awarded': 2007,
+                    'url': None
+                }]
+            },
+            'educational_background': {
+                'schools': [{
+                    'name': 'Foo University',
+                    'degree': 'MSc CE/CS',
+                    'notes': None,
+                    'subject': None,
+                    'begin_date': '1998-08',
+                    'end_date': '2005-02'
+                }],
+                'qualifications': ['TOEFLS', 'PADI AOWD']
+            },
+            'photo_urls': {
+                'large': 'http://www.xing.com/img/users/e/3/d/'
+                         'f94ef165a.123456,1.140x185.jpg',
+                'mini_thumb': 'http://www.xing.com/img/users/e/3/d/'
+                              'f94ef165a.123456,1.18x24.jpg',
+                'thumb': 'http://www.xing.com/img/users/e/3/d/'
+                         'f94ef165a.123456,1.30x40.jpg',
+                'medium_thumb': 'http://www.xing.com/img/users/e/3/d/'
+                                'f94ef165a.123456,1.57x75.jpg',
+                'maxi_thumb': 'http://www.xing.com/img/users/e/3/d/'
+                              'f94ef165a.123456,1.70x93.jpg'
+            }
+        }]
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_yahoo.py b/rootfs/api/apps_extra/social_core/tests/backends/test_yahoo.py
new file mode 100644
index 000000000..cd7c37789
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_yahoo.py
@@ -0,0 +1,83 @@
+import json
+from urllib.parse import urlencode
+
+import requests
+from httpretty import HTTPretty
+
+from .oauth import OAuth1Test
+
+
+class YahooOAuth1Test(OAuth1Test):
+    backend_path = 'social_core.backends.yahoo.YahooOAuth'
+    user_data_url = 'https://social.yahooapis.com/v1/user/a-guid/profile?' \
+                    'format=json'
+    expected_username = 'foobar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    request_token_body = urlencode({
+        'oauth_token_secret': 'foobar-secret',
+        'oauth_token': 'foobar',
+        'oauth_callback_confirmed': 'true'
+    })
+    guid_body = json.dumps({
+        'guid': {
+            'uri': 'https://social.yahooapis.com/v1/me/guid',
+            'value': 'a-guid'
+        }
+    })
+    user_data_body = json.dumps({
+        'profile': {
+            'bdRestricted': True,
+            'memberSince': '2007-12-11T14:40:30Z',
+            'image': {
+                'width': 192,
+                'imageUrl': 'http://l.yimg.com/dh/ap/social/profile/'
+                            'profile_b192.png',
+                'size': '192x192',
+                'height': 192
+            },
+            'created': '2013-03-18T04:15:08Z',
+            'uri': 'https://social.yahooapis.com/v1/user/a-guid/profile',
+            'isConnected': False,
+            'profileUrl': 'http://profile.yahoo.com/a-guid',
+            'guid': 'a-guid',
+            'nickname': 'foobar',
+            'emails': [{
+                'handle': 'foobar@yahoo.com',
+                'id': 1,
+                'primary': True,
+                'type': 'HOME',
+            }, {
+                'handle': 'foobar@email.com',
+                'id': 2,
+                'type': 'HOME',
+            }],
+        }
+    })
+
+    def test_login(self):
+        HTTPretty.register_uri(
+            HTTPretty.GET,
+            'https://social.yahooapis.com/v1/me/guid?format=json',
+            status=200,
+            body=self.guid_body
+        )
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
+
+    def test_get_user_details(self):
+        HTTPretty.register_uri(
+            HTTPretty.GET,
+            self.user_data_url,
+            status=200,
+            body=self.user_data_body
+        )
+        response = requests.get(self.user_data_url)
+        user_details = self.backend.get_user_details(
+            response.json()['profile']
+        )
+        self.assertEqual(user_details['email'], 'foobar@yahoo.com')
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_yammer.py b/rootfs/api/apps_extra/social_core/tests/backends/test_yammer.py
new file mode 100644
index 000000000..88214a885
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_yammer.py
@@ -0,0 +1,115 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class YammerOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.yammer.YammerOAuth2'
+    expected_username = 'foobar'
+    access_token_body = json.dumps({
+        'access_token': {
+            'user_id': 1010101010,
+            'view_groups': True,
+            'modify_messages': True,
+            'network_id': 101010,
+            'created_at': '2013/03/17 16:39:56 +0000',
+            'view_members': True,
+            'authorized_at': '2013/03/17 16:39:56 +0000',
+            'view_subscriptions': True,
+            'view_messages': True,
+            'modify_subscriptions': True,
+            'token': 'foobar',
+            'expires_at': None,
+            'network_permalink': 'foobar.com',
+            'view_tags': True,
+            'network_name': 'foobar.com'
+        },
+        'user': {
+            'last_name': 'Bar',
+            'web_url': 'https://www.yammer.com/foobar/users/foobar',
+            'expertise': None,
+            'full_name': 'Foo Bar',
+            'timezone': 'Pacific Time (US & Canada)',
+            'mugshot_url': 'https://mug0.assets-yammer.com/mugshot/images/'
+                           '48x48/no_photo.png',
+            'guid': None,
+            'network_name': 'foobar',
+            'id': 1010101010,
+            'previous_companies': [],
+            'first_name': 'Foo',
+            'stats': {
+                'following': 0,
+                'followers': 0,
+                'updates': 1
+            },
+            'hire_date': None,
+            'state': 'active',
+            'location': None,
+            'department': 'Software Development',
+            'type': 'user',
+            'show_ask_for_photo': True,
+            'job_title': 'Software Developer',
+            'interests': None,
+            'kids_names': None,
+            'activated_at': '2013/03/17 16:27:50 +0000',
+            'verified_admin': 'false',
+            'can_broadcast': 'false',
+            'schools': [],
+            'admin': 'false',
+            'network_domains': ['foobar.com'],
+            'name': 'foobar',
+            'external_urls': [],
+            'url': 'https://www.yammer.com/api/v1/users/1010101010',
+            'settings': {
+                'xdr_proxy': 'https://xdrproxy.yammer.com'
+            },
+            'summary': None,
+            'network_id': 101010,
+            'contact': {
+                'phone_numbers': [],
+                'im': {
+                    'username': '',
+                    'provider': ''
+                },
+                'email_addresses': [{
+                    'type': 'primary',
+                    'address': 'foo@bar.com'
+                }],
+                'has_fake_email': False
+            },
+            'birth_date': '',
+            'mugshot_url_template': 'https://mug0.assets-yammer.com/mugshot/'
+                                    'images/{width}x{height}/no_photo.png',
+            'significant_other': None
+        },
+        'network': {
+            'show_upgrade_banner': False,
+            'header_text_color': '#FFFFFF',
+            'is_org_chart_enabled': True,
+            'name': 'foobar.com',
+            'is_group_enabled': True,
+            'header_background_color': '#396B9A',
+            'created_at': '2012/12/26 16:52:35 +0000',
+            'profile_fields_config': {
+                'enable_work_phone': True,
+                'enable_mobile_phone': True,
+                'enable_job_title': True
+            },
+            'permalink': 'foobar.com',
+            'paid': False,
+            'id': 101010,
+            'is_chat_enabled': True,
+            'web_url': 'https://www.yammer.com/foobar.com',
+            'moderated': False,
+            'community': False,
+            'type': 'network',
+            'navigation_background_color': '#38699F',
+            'navigation_text_color': '#FFFFFF'
+        }
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_yandex.py b/rootfs/api/apps_extra/social_core/tests/backends/test_yandex.py
new file mode 100644
index 000000000..753beac01
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_yandex.py
@@ -0,0 +1,55 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class YandexOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.yandex.YandexOAuth2'
+    user_data_url = 'https://login.yandex.ru/info'
+    expected_username = 'foobar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    user_data_body = json.dumps({
+        'display_name': 'foobar',
+        'real_name': 'Foo Bar',
+        'sex': None,
+        'id': '101010101',
+        'default_email': 'foobar@yandex.com',
+        'emails': ['foobar@yandex.com']
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
+
+
+class YandexOAuth2TestEmptyEmail(OAuth2Test):
+    """
+    When user log in to yandex service with social network account (e.g.
+    vk.com), they `default_email` could be empty.
+    """
+    backend_path = 'social_core.backends.yandex.YandexOAuth2'
+    user_data_url = 'https://login.yandex.ru/info'
+    expected_username = 'foobar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar',
+        'token_type': 'bearer'
+    })
+    user_data_body = json.dumps({
+        'display_name': 'foobar',
+        'real_name': 'Foo Bar',
+        'sex': None,
+        'id': '101010101',
+        'default_email': '',
+        'emails': []
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_zoom.py b/rootfs/api/apps_extra/social_core/tests/backends/test_zoom.py
new file mode 100644
index 000000000..0036d80dc
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_zoom.py
@@ -0,0 +1,64 @@
+import json
+
+from .oauth import OAuth2Test
+
+
+class ZoomOAuth2Test(OAuth2Test):
+    backend_path = 'social_core.backends.zoom.ZoomOAuth2'
+    user_data_url = 'https://api.zoom.us/v2/users/me'
+    expected_username = 'foobar'
+    access_token_body = json.dumps({
+        'access_token': 'foobar-token',
+        'token_type': 'bearer',
+        'refresh_token': 'foobar-refresh-token',
+        'expires_in': 3599,
+        'scope': 'identity'
+    })
+    user_data_body = json.dumps({
+        'id': 'foobar',
+        'first_name': 'Foo',
+        'last_name': 'Bar',
+        'email': 'foobar@email.com',
+        'type': 2,
+        'role_name': 'Foobar',
+        'pmi': 1234567890,
+        'use_pmi': False,
+        'vanity_url': 'https://foobar.zoom.us/my/foobar',
+        'personal_meeting_url': 'https://foobar.zoom.us/j/1234567890',
+        'timezone': 'America/Denver',
+        'verified': 1,
+        'dept': '',
+        'created_at': '2019-04-05T15:24:32Z',
+        'last_login_time': '2019-12-16T18:02:48Z',
+        'last_client_version': 'version',
+        'pic_url': 'https://foobar.zoom.us/p/123456789',
+        'host_key': '123456',
+        'jid': 'foobar@xmpp.zoom.us',
+        'group_ids': [],
+        'im_group_ids': [
+            'foobar-group-id'
+        ],
+        'account_id': 'foobar-account-id',
+        'language': 'en-US',
+        'phone_country': 'US',
+        'phone_number': '+1 1234567891',
+        'status': 'active'
+    })
+    refresh_token_body = json.dumps({
+        'access_token': 'foobar-new-token',
+        'token_type': 'bearer',
+        'refresh_token': 'foobar-new-refresh-token',
+        'expires_in': 3599,
+        'scope': 'identity'
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
+
+    def test_refresh_token(self):
+        user, social = self.do_refresh_token()
+        self.assertEqual(user.username, self.expected_username)
+        self.assertEqual(social.extra_data['access_token'], 'foobar-new-token')
diff --git a/rootfs/api/apps_extra/social_core/tests/backends/test_zotero.py b/rootfs/api/apps_extra/social_core/tests/backends/test_zotero.py
new file mode 100644
index 000000000..b7f10ccdf
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/backends/test_zotero.py
@@ -0,0 +1,25 @@
+from urllib.parse import urlencode
+
+from .oauth import OAuth1Test
+
+
+class ZoteroOAuth1Test(OAuth1Test):
+    backend_path = 'social_core.backends.zotero.ZoteroOAuth'
+    expected_username = 'FooBar'
+    access_token_body = urlencode({
+        'oauth_token': 'foobar',
+        'oauth_token_secret': 'rodgsNDK4hLJU1504Atk131G',
+        'userID': '123456_abcdef',
+        'username': 'FooBar'
+    })
+    request_token_body = urlencode({
+        'oauth_token_secret': 'foobar-secret',
+        'oauth_token': 'foobar',
+        'oauth_callback_confirmed': 'true'
+    })
+
+    def test_login(self):
+        self.do_login()
+
+    def test_partial_pipeline(self):
+        self.do_partial_pipeline()
diff --git a/rootfs/api/apps_extra/social_core/tests/models.py b/rootfs/api/apps_extra/social_core/tests/models.py
new file mode 100644
index 000000000..4bc95729b
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/models.py
@@ -0,0 +1,239 @@
+import base64
+
+from ..storage import UserMixin, NonceMixin, AssociationMixin, \
+    CodeMixin, PartialMixin, BaseStorage
+
+
+class BaseModel:
+    @classmethod
+    def next_id(cls):
+        cls.NEXT_ID += 1
+        return cls.NEXT_ID - 1
+
+    @classmethod
+    def get(cls, key):
+        return cls.cache.get(key)
+
+    @classmethod
+    def reset_cache(cls):
+        cls.cache = {}
+
+
+class User(BaseModel):
+    NEXT_ID = 1
+    cache = {}
+    _is_active = True
+
+    def __init__(self, username, email=None, **extra_user_fields):
+        self.id = User.next_id()
+        self.username = username
+        self.email = email
+        self.password = None
+        self.slug = None
+        self.social = []
+        self.extra_data = {}
+        self.extra_user_fields = extra_user_fields
+        self.save()
+
+    def is_active(self):
+        return self._is_active
+
+    @classmethod
+    def set_active(cls, is_active=True):
+        cls._is_active = is_active
+
+    def set_password(self, password):
+        self.password = password
+
+    def save(self):
+        User.cache[self.username] = self
+
+
+class TestUserSocialAuth(UserMixin, BaseModel):
+    NEXT_ID = 1
+    cache = {}
+    cache_by_uid = {}
+
+    def __init__(self, user, provider, uid, extra_data=None):
+        self.id = TestUserSocialAuth.next_id()
+        self.user = user
+        self.provider = provider
+        self.uid = uid
+        self.extra_data = extra_data or {}
+        self.user.social.append(self)
+        TestUserSocialAuth.cache_by_uid[uid] = self
+
+    def save(self):
+        pass
+
+    @classmethod
+    def reset_cache(cls):
+        cls.cache = {}
+        cls.cache_by_uid = {}
+
+    @classmethod
+    def changed(cls, user):
+        pass
+
+    @classmethod
+    def get_username(cls, user):
+        return user.username
+
+    @classmethod
+    def user_model(cls):
+        return User
+
+    @classmethod
+    def username_max_length(cls):
+        return 1024
+
+    @classmethod
+    def allowed_to_disconnect(cls, user, backend_name, association_id=None):
+        return user.password or len(user.social) > 1
+
+    @classmethod
+    def disconnect(cls, entry):
+        cls.cache.pop(entry.id, None)
+        entry.user.social = [s for s in entry.user.social if entry != s]
+
+    @classmethod
+    def user_exists(cls, username):
+        return User.cache.get(username) is not None
+
+    @classmethod
+    def create_user(cls, username, email=None, **extra_user_fields):
+        return User(username=username, email=email, **extra_user_fields)
+
+    @classmethod
+    def get_user(cls, pk):
+        for username, user in User.cache.items():
+            if user.id == pk:
+                return user
+
+    @classmethod
+    def get_social_auth(cls, provider, uid):
+        social_user = cls.cache_by_uid.get(uid)
+        if social_user and social_user.provider == provider:
+            return social_user
+
+    @classmethod
+    def get_social_auth_for_user(cls, user, provider=None, id=None):
+        return [usa for usa in user.social
+                if provider in (None, usa.provider) and
+                id in (None, usa.id)]
+
+    @classmethod
+    def create_social_auth(cls, user, uid, provider):
+        return cls(user=user, provider=provider, uid=uid)
+
+    @classmethod
+    def get_users_by_email(cls, email):
+        return [user for user in User.cache.values() if user.email == email]
+
+
+class TestNonce(NonceMixin, BaseModel):
+    NEXT_ID = 1
+    cache = {}
+
+    def __init__(self, server_url, timestamp, salt):
+        self.id = TestNonce.next_id()
+        self.server_url = server_url
+        self.timestamp = timestamp
+        self.salt = salt
+
+    @classmethod
+    def use(cls, server_url, timestamp, salt):
+        nonce = TestNonce(server_url, timestamp, salt)
+        TestNonce.cache[server_url] = nonce
+        return nonce
+
+    @classmethod
+    def get(cls, server_url, salt):
+        return TestNonce.cache[server_url]
+
+    @classmethod
+    def delete(cls, nonce):
+        server_url = nonce.server_url
+        del TestNonce.cache[server_url]
+
+
+class TestAssociation(AssociationMixin, BaseModel):
+    NEXT_ID = 1
+    cache = {}
+
+    def __init__(self, server_url, handle):
+        self.id = TestAssociation.next_id()
+        self.server_url = server_url
+        self.handle = handle
+
+    def save(self):
+        TestAssociation.cache[(self.server_url, self.handle)] = self
+
+    @classmethod
+    def store(cls, server_url, association):
+        assoc = TestAssociation.cache.get((server_url, association.handle))
+        if assoc is None:
+            assoc = TestAssociation(server_url=server_url,
+                                    handle=association.handle)
+        assoc.secret = base64.encodebytes(association.secret)
+        assoc.issued = association.issued
+        assoc.lifetime = association.lifetime
+        assoc.assoc_type = association.assoc_type
+        assoc.save()
+
+    @classmethod
+    def get(cls, server_url=None, handle=None):
+        result = []
+        for assoc in TestAssociation.cache.values():
+            if server_url and assoc.server_url != server_url:
+                continue
+            if handle and assoc.handle != handle:
+                continue
+            result.append(assoc)
+        return result
+
+    @classmethod
+    def remove(cls, ids_to_delete):
+        assoc = filter(lambda a: a.id in ids_to_delete,
+                       TestAssociation.cache.values())
+        for a in list(assoc):
+            TestAssociation.cache.pop((a.server_url, a.handle), None)
+
+
+class TestCode(CodeMixin, BaseModel):
+    NEXT_ID = 1
+    cache = {}
+
+    @classmethod
+    def get_code(cls, code):
+        for c in cls.cache.values():
+            if c.code == code:
+                return c
+
+
+class TestPartial(PartialMixin, BaseModel):
+    NEXT_ID = 1
+    cache = {}
+
+    def save(self):
+        TestPartial.cache[self.token] = self
+
+    @classmethod
+    def load(cls, token):
+        return cls.cache.get(token)
+
+    @classmethod
+    def destroy(cls, token):
+        cls.cache.pop(token)
+
+
+class TestStorage(BaseStorage):
+    user = TestUserSocialAuth
+    nonce = TestNonce
+    association = TestAssociation
+    code = TestCode
+    partial = TestPartial
+
+    @classmethod
+    def is_integrity_error(cls, exception):
+        pass
diff --git a/rootfs/api/apps_extra/social_core/tests/pipeline.py b/rootfs/api/apps_extra/social_core/tests/pipeline.py
new file mode 100644
index 000000000..e61b0bdee
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/pipeline.py
@@ -0,0 +1,45 @@
+from ..pipeline.partial import partial
+
+
+@partial
+def ask_for_password(strategy, *args, **kwargs):
+    if strategy.session_get('password'):
+        return {'password': strategy.session_get('password')}
+    else:
+        return strategy.redirect(strategy.build_absolute_uri('/password'))
+
+
+@partial
+def ask_for_slug(strategy, *args, **kwargs):
+    if strategy.session_get('slug'):
+        return {'slug': strategy.session_get('slug')}
+    else:
+        return strategy.redirect(strategy.build_absolute_uri('/slug'))
+
+
+def set_password(strategy, user, *args, **kwargs):
+    user.set_password(kwargs['password'])
+
+
+def set_slug(strategy, user, *args, **kwargs):
+    user.slug = kwargs['slug']
+
+
+def remove_user(strategy, user, *args, **kwargs):
+    return {'user': None}
+
+
+@partial
+def set_user_from_kwargs(strategy, *args, **kwargs):
+    if strategy.session_get('attribute'):
+        kwargs['user'].id
+    else:
+        return strategy.redirect(strategy.build_absolute_uri('/attribute'))
+
+
+@partial
+def set_user_from_args(strategy, user, *args, **kwargs):
+    if strategy.session_get('attribute'):
+        user.id
+    else:
+        return strategy.redirect(strategy.build_absolute_uri('/attribute'))
diff --git a/rootfs/api/apps_extra/social_core/tests/requirements.txt b/rootfs/api/apps_extra/social_core/tests/requirements.txt
new file mode 100644
index 000000000..c21a1b7e5
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/requirements.txt
@@ -0,0 +1,4 @@
+pytest>=4.5
+httpretty>=0.9.6
+coverage>=3.6
+pytest-cov>=2.7.1
diff --git a/rootfs/api/apps_extra/social_core/tests/strategy.py b/rootfs/api/apps_extra/social_core/tests/strategy.py
new file mode 100644
index 000000000..143ff6261
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/strategy.py
@@ -0,0 +1,124 @@
+from ..strategy import BaseStrategy, BaseTemplateStrategy
+
+
+TEST_URI = 'http://myapp.com'
+TEST_HOST = 'myapp.com'
+
+
+class Redirect:
+    def __init__(self, url):
+        self.url = url
+
+
+class TestTemplateStrategy(BaseTemplateStrategy):
+    def render_template(self, tpl, context):
+        return tpl
+
+    def render_string(self, html, context):
+        return html
+
+
+class TestStrategy(BaseStrategy):
+    DEFAULT_TEMPLATE_STRATEGY = TestTemplateStrategy
+
+    def __init__(self, storage, tpl=None):
+        self._request_data = {}
+        self._settings = {}
+        self._session = {}
+        super().__init__(storage, tpl)
+
+    def redirect(self, url):
+        return Redirect(url)
+
+    def get_setting(self, name):
+        """Return value for given setting name"""
+        return self._settings[name]
+
+    def html(self, content):
+        """Return HTTP response with given content"""
+        return content
+
+    def render_html(self, tpl=None, html=None, context=None):
+        """Render given template or raw html with given context"""
+        return tpl or html
+
+    def request_data(self, merge=True):
+        """Return current request data (POST or GET)"""
+        return self._request_data
+
+    def request_host(self):
+        """Return current host value"""
+        return TEST_HOST
+
+    def request_is_secure(self):
+        """ Is the request using HTTPS? """
+        return False
+
+    def request_path(self):
+        """ path of the current request """
+        return ''
+
+    def request_port(self):
+        """ Port in use for this request """
+        return 80
+
+    def request_get(self):
+        """ Request GET data """
+        return self._request_data.copy()
+
+    def request_post(self):
+        """ Request POST data """
+        return self._request_data.copy()
+
+    def session_get(self, name, default=None):
+        """Return session value for given key"""
+        return self._session.get(name, default)
+
+    def session_set(self, name, value):
+        """Set session value for given key"""
+        self._session[name] = value
+
+    def session_pop(self, name):
+        """Pop session value for given key"""
+        return self._session.pop(name, None)
+
+    def build_absolute_uri(self, path=None):
+        """Build absolute URI with given (optional) path"""
+        path = path or ''
+        if path.startswith('http://') or path.startswith('https://'):
+            return path
+        return TEST_URI + path
+
+    def set_settings(self, values):
+        self._settings.update(values)
+
+    def set_request_data(self, values, backend):
+        self._request_data.update(values)
+        backend.data = self._request_data
+
+    def remove_from_request_data(self, name):
+        self._request_data.pop(name, None)
+
+    def authenticate(self, *args, **kwargs):
+        user = super().authenticate(*args, **kwargs)
+        if isinstance(user, self.storage.user.user_model()):
+            self.session_set('username', user.username)
+        return user
+
+    def get_pipeline(self, backend=None):
+        return self.setting(
+            'PIPELINE',
+            (
+                'social_core.pipeline.social_auth.social_details',
+                'social_core.pipeline.social_auth.social_uid',
+                'social_core.pipeline.social_auth.auth_allowed',
+                'social_core.pipeline.social_auth.social_user',
+                'social_core.pipeline.user.get_username',
+                'social_core.pipeline.social_auth.associate_by_email',
+                'social_core.pipeline.user.create_user',
+                'social_core.pipeline.social_auth.associate_user',
+                'social_core.pipeline.social_auth.load_extra_data',
+                'social_core.pipeline.user.user_details'
+            ),
+            backend
+        )
diff --git a/rootfs/api/apps_extra/social_core/tests/test_exceptions.py b/rootfs/api/apps_extra/social_core/tests/test_exceptions.py
new file mode 100644
index 000000000..6f838fa82
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/test_exceptions.py
@@ -0,0 +1,111 @@
+import unittest
+
+from ..exceptions import SocialAuthBaseException, WrongBackend, \
+    AuthFailed, AuthTokenError, AuthMissingParameter, AuthStateMissing, \
+    NotAllowedToDisconnect, AuthException, AuthCanceled, AuthUnknownError, \
+    AuthStateForbidden, AuthAlreadyAssociated, AuthTokenRevoked, \
+    AuthForbidden, AuthUnreachableProvider, InvalidEmail, MissingBackend
+
+
+class BaseExceptionTestCase(unittest.TestCase):
+    exception = None
+    expected_message = ''
+
+    def test_exception_message(self):
+        if self.exception is None and self.expected_message == '':
+            return
+        try:
+            raise self.exception
+        except SocialAuthBaseException as err:
+            self.assertEqual(str(err), self.expected_message)
+
+
+class WrongBackendTest(BaseExceptionTestCase):
+    exception = WrongBackend('foobar')
+    expected_message = 'Incorrect authentication service "foobar"'
+
+
+class AuthFailedTest(BaseExceptionTestCase):
+    exception = AuthFailed('foobar', 'wrong_user')
+    expected_message = 'Authentication failed: wrong_user'
+
+
+class AuthFailedDeniedTest(BaseExceptionTestCase):
+    exception = AuthFailed('foobar', 'access_denied')
+    expected_message = 'Authentication process was canceled'
+
+
+class AuthTokenErrorTest(BaseExceptionTestCase):
+    exception = AuthTokenError('foobar', 'Incorrect tokens')
+    expected_message = 'Token error: Incorrect tokens'
+
+
+class AuthMissingParameterTest(BaseExceptionTestCase):
+    exception = AuthMissingParameter('foobar', 'username')
+    expected_message = 'Missing needed parameter username'
+
+
+class AuthStateMissingTest(BaseExceptionTestCase):
+    exception = AuthStateMissing('foobar')
+    expected_message = 'Session value state missing.'
+
+
+class NotAllowedToDisconnectTest(BaseExceptionTestCase):
+    exception = NotAllowedToDisconnect()
+    expected_message = ''
+
+
+class AuthExceptionTest(BaseExceptionTestCase):
+    exception = AuthException('foobar', 'message')
+    expected_message = 'message'
+
+
+class AuthCanceledTest(BaseExceptionTestCase):
+    exception = AuthCanceled('foobar')
+    expected_message = 'Authentication process canceled'
+
+
+class AuthCanceledWithExtraMessageTest(BaseExceptionTestCase):
+    exception = AuthCanceled('foobar', 'error_message')
+    expected_message = 'Authentication process canceled: error_message'
+
+
+class AuthUnknownErrorTest(BaseExceptionTestCase):
+    exception = AuthUnknownError('foobar', 'some error')
+    expected_message = 'An unknown error happened while ' \
+                       'authenticating some error'
+
+
+class AuthStateForbiddenTest(BaseExceptionTestCase):
+    exception = AuthStateForbidden('foobar')
+    expected_message = 'Wrong state parameter given.'
+
+
+class AuthAlreadyAssociatedTest(BaseExceptionTestCase):
+    exception = AuthAlreadyAssociated('foobar')
+    expected_message = ''
+
+
+class AuthTokenRevokedTest(BaseExceptionTestCase):
+    exception = AuthTokenRevoked('foobar')
+    expected_message = 'User revoke access to the token'
+
+
+class AuthForbiddenTest(BaseExceptionTestCase):
+    exception = AuthForbidden('foobar')
+    expected_message = 'Your credentials aren\'t allowed'
+
+
+class AuthUnreachableProviderTest(BaseExceptionTestCase):
+    exception = AuthUnreachableProvider('foobar')
+    expected_message = 'The authentication provider could not be reached'
+
+
+class InvalidEmailTest(BaseExceptionTestCase):
+    exception = InvalidEmail('foobar')
+    expected_message = 'Email couldn\'t be validated'
+
+
+class MissingBackendTest(BaseExceptionTestCase):
+    exception = MissingBackend('backend')
+    expected_message = 'Missing backend "backend" entry'
diff --git a/rootfs/api/apps_extra/social_core/tests/test_partial.py b/rootfs/api/apps_extra/social_core/tests/test_partial.py
new file mode 100644
index 000000000..b5ae3c499
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/test_partial.py
@@ -0,0 +1,117 @@
+import unittest
+from unittest.mock import Mock, patch
+
+from ..utils import PARTIAL_TOKEN_SESSION_NAME
+from ..pipeline.partial import partial, partial_step
+
+
+class PartialDecoratorTestCase(unittest.TestCase):
+    def setUp(self):
+        super().setUp()
+        self.mock_current_partial_token = Mock()
+        self.mock_current_partial = Mock(token=self.mock_current_partial_token)
+
+        self.mock_strategy = Mock()
+        self.mock_backend = Mock()
+        self.mock_pipeline_index = Mock()
+        self.mock_partial_store = Mock()
+        self.mock_strategy.storage.partial.store = self.mock_partial_store
+
+        self.mock_sesstion_set = Mock()
+        self.mock_strategy.session_set = self.mock_sesstion_set
+
+    def test_save_to_session(self):
+        # GIVEN
+        expected_response = Mock()
+
+        @partial_step(save_to_session=True)
+        def decorated_func(*args, **kwargs):
+            return expected_response
+
+        # WHEN
+        with patch('social_core.pipeline.partial.partial_prepare',
+                   return_value=self.mock_current_partial):
+            response = decorated_func(self.mock_strategy,
+                                      self.mock_backend,
+                                      self.mock_pipeline_index)
+
+            # THEN
+            self.assertEqual(expected_response, response)
+
+            self.assertEqual(1, self.mock_partial_store.call_count)
+            self.assertEqual((self.mock_current_partial,),
+                             self.mock_partial_store.call_args[0])
+
+            self.assertEqual(1, self.mock_sesstion_set.call_count)
+            self.assertEqual((PARTIAL_TOKEN_SESSION_NAME,
+                              self.mock_current_partial_token),
+                             self.mock_sesstion_set.call_args[0])
+
+    def test_not_to_save_to_session(self):
+        # GIVEN
+        expected_response = Mock()
+
+        @partial_step(save_to_session=False)
+        def decorated_func(*args, **kwargs):
+            return expected_response
+
+        # WHEN
+        with patch('social_core.pipeline.partial.partial_prepare',
+                   return_value=self.mock_current_partial):
+            response = decorated_func(self.mock_strategy,
+                                      self.mock_backend,
+                                      self.mock_pipeline_index)
+
+            # THEN
+            self.assertEqual(expected_response, response)
+
+            self.assertEqual(1, self.mock_partial_store.call_count)
+            self.assertEqual((self.mock_current_partial,),
+                             self.mock_partial_store.call_args[0])
+
+            self.assertEqual(0, self.mock_sesstion_set.call_count)
+
+    def test_save_to_session_by_backward_compatible_decorator(self):
+        # GIVEN
+        expected_response = Mock()
+
+        @partial
+        def decorated_func(*args, **kwargs):
+            return expected_response
+
+        # WHEN
+        with patch('social_core.pipeline.partial.partial_prepare',
+                   return_value=self.mock_current_partial):
+            response = decorated_func(self.mock_strategy,
+                                      self.mock_backend,
+                                      self.mock_pipeline_index)
+
+            # THEN
+            self.assertEqual(expected_response, response)
+
+            self.assertEqual(1, self.mock_partial_store.call_count)
+            self.assertEqual((self.mock_current_partial,),
+                             self.mock_partial_store.call_args[0])
+
+            self.assertEqual(1, self.mock_sesstion_set.call_count)
+            self.assertEqual((PARTIAL_TOKEN_SESSION_NAME,
+                              self.mock_current_partial_token),
+                             self.mock_sesstion_set.call_args[0])
+
+    def test_not_to_save_to_session_when_the_response_is_a_dict(self):
+        # GIVEN
+        expected_response = {'test_key': 'test_value'}
+
+        @partial_step(save_to_session=True)
+        def decorated_func(*args, **kwargs):
+            return expected_response
+
+        # WHEN
+        response = decorated_func(self.mock_strategy,
+                                  self.mock_backend,
+                                  self.mock_pipeline_index)
+
+        # THEN
+        self.assertEqual(expected_response, response)
+        self.assertEqual(0, self.mock_partial_store.call_count)
+        self.assertEqual(0, self.mock_sesstion_set.call_count)
diff --git a/rootfs/api/apps_extra/social_core/tests/test_pipeline.py b/rootfs/api/apps_extra/social_core/tests/test_pipeline.py
new file mode 100644
index 000000000..4102450f4
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/test_pipeline.py
@@ -0,0 +1,232 @@
+import json
+
+from ..utils import PARTIAL_TOKEN_SESSION_NAME
+from ..exceptions import AuthException
+
+from .models import TestUserSocialAuth, TestStorage, User
+from .strategy import TestStrategy
+from .actions.actions import BaseActionTest
+
+
+class IntegrityError(Exception):
+    pass
+
+
+class UnknownError(Exception):
+    pass
+
+
+class IntegrityErrorUserSocialAuth(TestUserSocialAuth):
+    @classmethod
+    def create_social_auth(cls, user, uid, provider):
+        raise IntegrityError()
+
+    @classmethod
+    def get_social_auth(cls, provider, uid):
+        if not hasattr(cls, '_called_times'):
+            cls._called_times = 0
+        cls._called_times += 1
+        if cls._called_times == 2:
+            user = list(User.cache.values())[0]
+            return IntegrityErrorUserSocialAuth(user, provider, uid)
+        else:
+            return super().get_social_auth(provider, uid)
+
+
+class IntegrityErrorStorage(TestStorage):
+    user = IntegrityErrorUserSocialAuth
+
+    @classmethod
+    def is_integrity_error(cls, exception):
+        """Check if given exception flags an integrity error in the DB"""
+        return isinstance(exception, IntegrityError)
+
+
+class UnknownErrorUserSocialAuth(TestUserSocialAuth):
+    @classmethod
+    def create_social_auth(cls, user, uid, provider):
+        raise UnknownError()
+
+
+class UnknownErrorStorage(IntegrityErrorStorage):
+    user = UnknownErrorUserSocialAuth
+
+
+class IntegrityErrorOnLoginTest(BaseActionTest):
+    def setUp(self):
+        self.strategy = TestStrategy(IntegrityErrorStorage)
+        super().setUp()
+
+    def test_integrity_error(self):
+        self.do_login()
+
+
+class UnknownErrorOnLoginTest(BaseActionTest):
+    def setUp(self):
+        self.strategy = TestStrategy(UnknownErrorStorage)
+        super().setUp()
+
+    def test_unknown_error(self):
+        with self.assertRaises(UnknownError):
+            self.do_login()
+
+
+class EmailAsUsernameTest(BaseActionTest):
+    expected_username = 'foo@bar.com'
+
+    def test_email_as_username(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_USERNAME_IS_FULL_EMAIL': True
+        })
+        self.do_login()
+
+
+class RandomUsernameTest(BaseActionTest):
+    user_data_body = json.dumps({
+        'id': 1,
+        'avatar_url': 'https://github.com/images/error/foobar_happy.gif',
+        'gravatar_id': 'somehexcode',
+        'url': 'https://api.github.com/users/foobar',
+        'name': 'monalisa foobar',
+        'company': 'GitHub',
+        'blog': 'https://github.com/blog',
+        'location': 'San Francisco',
+        'email': 'foo@bar.com',
+        'hireable': False,
+        'bio': 'There once was...',
+        'public_repos': 2,
+        'public_gists': 1,
+        'followers': 20,
+        'following': 0,
+        'html_url': 'https://github.com/foobar',
+        'created_at': '2008-01-14T04:33:35Z',
+        'type': 'User',
+        'total_private_repos': 100,
+        'owned_private_repos': 100,
+        'private_gists': 81,
+        'disk_usage': 10000,
+        'collaborators': 8,
+        'plan': {
+            'name': 'Medium',
+            'space': 400,
+            'collaborators': 10,
+            'private_repos': 20
+        }
+    })
+
+    def test_random_username(self):
+        self.do_login(after_complete_checks=False)
+
+
+class SluggedUsernameTest(BaseActionTest):
+    expected_username = 'foo-bar'
+    user_data_body = json.dumps({
+        'login': 'Foo Bar',
+        'id': 1,
+        'avatar_url': 'https://github.com/images/error/foobar_happy.gif',
+        'gravatar_id': 'somehexcode',
+        'url': 'https://api.github.com/users/foobar',
+        'name': 'monalisa foobar',
+        'company': 'GitHub',
+        'blog': 'https://github.com/blog',
+        'location': 'San Francisco',
+        'email': 'foo@bar.com',
+        'hireable': False,
+        'bio': 'There once was...',
+        'public_repos': 2,
+        'public_gists': 1,
+        'followers': 20,
+        'following': 0,
+        'html_url': 'https://github.com/foobar',
+        'created_at': '2008-01-14T04:33:35Z',
+        'type': 'User',
+        'total_private_repos': 100,
+        'owned_private_repos': 100,
+        'private_gists': 81,
+        'disk_usage': 10000,
+        'collaborators': 8,
+        'plan': {
+            'name': 'Medium',
+            'space': 400,
+            'collaborators': 10,
+            'private_repos': 20
+        }
+    })
+
+    def test_random_username(self):
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_CLEAN_USERNAMES': False,
+            'SOCIAL_AUTH_SLUGIFY_USERNAMES': True
+        })
+        self.do_login()
+
+
+class RepeatedUsernameTest(BaseActionTest):
+    def test_random_username(self):
+        User(username='foobar')
+        self.do_login(after_complete_checks=False)
+        self.assertTrue(self.strategy.session_get('username')
+                                     .startswith('foobar'))
+
+
+class AssociateByEmailTest(BaseActionTest):
+    def test_multiple_accounts_with_same_email(self):
+        user = User(username='foobar1')
+        user.email = 'foo@bar.com'
+        self.do_login(after_complete_checks=False)
+        self.assertTrue(self.strategy.session_get('username')
+                                     .startswith('foobar'))
+
+
+class MultipleAccountsWithSameEmailTest(BaseActionTest):
+    def test_multiple_accounts_with_same_email(self):
+        user1 = User(username='foobar1')
+        user2 = User(username='foobar2')
+        user1.email = 'foo@bar.com'
+        user2.email = 'foo@bar.com'
+        with self.assertRaises(AuthException):
+            self.do_login(after_complete_checks=False)
+
+
+class UserPersistsInPartialPipeline(BaseActionTest):
+    def test_user_persists_in_partial_pipeline_kwargs(self):
+        user = User(username='foobar1')
+        user.email = 'foo@bar.com'
+
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_PIPELINE': (
+                'social_core.pipeline.social_auth.social_details',
+                'social_core.pipeline.social_auth.social_uid',
+                'social_core.pipeline.social_auth.associate_by_email',
+                'social_core.tests.pipeline.set_user_from_kwargs'
+            )
+        })
+
+        self.do_login(after_complete_checks=False)
+
+        # Handle the partial pipeline
+        self.strategy.session_set('attribute', 'testing')
+        token = self.strategy.session_pop(PARTIAL_TOKEN_SESSION_NAME)
+        partial = self.strategy.partial_load(token)
+        self.backend.continue_pipeline(partial)
+
+    def test_user_persists_in_partial_pipeline(self):
+        user = User(username='foobar1')
+        user.email = 'foo@bar.com'
+
+        self.strategy.set_settings({
+            'SOCIAL_AUTH_PIPELINE': (
+                'social_core.pipeline.social_auth.social_details',
+                'social_core.pipeline.social_auth.social_uid',
+                'social_core.pipeline.social_auth.associate_by_email',
+                'social_core.tests.pipeline.set_user_from_args'
+            )
+        })
+
+        self.do_login(after_complete_checks=False)
+
+        # Handle the partial pipeline
+        self.strategy.session_set('attribute', 'testing')
+        token = self.strategy.session_pop(PARTIAL_TOKEN_SESSION_NAME)
+        partial = self.strategy.partial_load(token)
+        self.backend.continue_pipeline(partial)
diff --git a/rootfs/api/apps_extra/social_core/tests/test_storage.py b/rootfs/api/apps_extra/social_core/tests/test_storage.py
new file mode 100644
index 000000000..cefd7936f
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/test_storage.py
@@ -0,0 +1,200 @@
+import random
+import unittest
+
+from ..strategy import BaseStrategy
+from ..storage import UserMixin, NonceMixin, AssociationMixin, \
+    CodeMixin, BaseStorage
+
+from .models import User
+
+
+NOT_IMPLEMENTED_MSG = 'Implement in subclass'
+
+
+class BrokenUser(UserMixin):
+    pass
+
+
+class BrokenAssociation(AssociationMixin):
+    pass
+
+
+class BrokenNonce(NonceMixin):
+    pass
+
+
+class BrokenCode(CodeMixin):
+    pass
+
+
+class BrokenStrategy(BaseStrategy):
+    pass
+
+
+class BrokenStrategyWithSettings(BrokenStrategy):
+    def get_setting(self, name):
+        raise AttributeError()
+
+
+class BrokenStorage(BaseStorage):
+    pass
+
+
+class BrokenUserTests(unittest.TestCase):
+
+    def setUp(self):
+        self.user = BrokenUser
+
+    def tearDown(self):
+        self.user = None
+
+    def test_get_username(self):
+        with self.assertRaisesRegex(NotImplementedError, NOT_IMPLEMENTED_MSG):
+            self.user.get_username(User('foobar'))
+
+    def test_user_model(self):
+        with self.assertRaisesRegex(NotImplementedError, NOT_IMPLEMENTED_MSG):
+            self.user.user_model()
+
+    def test_username_max_length(self):
+        with self.assertRaisesRegex(NotImplementedError, NOT_IMPLEMENTED_MSG):
+            self.user.username_max_length()
+
+    def test_get_user(self):
+        with self.assertRaisesRegex(NotImplementedError, NOT_IMPLEMENTED_MSG):
+            self.user.get_user(1)
+
+    def test_get_social_auth(self):
+        with self.assertRaisesRegex(NotImplementedError, NOT_IMPLEMENTED_MSG):
+            self.user.get_social_auth('foo', 1)
+
+    def test_get_social_auth_for_user(self):
+        with self.assertRaisesRegex(NotImplementedError, NOT_IMPLEMENTED_MSG):
+            self.user.get_social_auth_for_user(User('foobar'))
+
+    def test_create_social_auth(self):
+        with self.assertRaisesRegex(NotImplementedError, NOT_IMPLEMENTED_MSG):
+            self.user.create_social_auth(User('foobar'), 1, 'foo')
+
+    def test_disconnect(self):
+        with self.assertRaisesRegex(NotImplementedError, NOT_IMPLEMENTED_MSG):
+            self.user.disconnect(BrokenUser())
+
+
+class BrokenAssociationTests(unittest.TestCase):
+    def setUp(self):
+        self.association = BrokenAssociation
+
+    def tearDown(self):
+        self.association = None
+
+    def test_store(self):
+        with self.assertRaisesRegex(NotImplementedError, NOT_IMPLEMENTED_MSG):
+            self.association.store('http://foobar.com', BrokenAssociation())
+
+    def test_get(self):
+        with self.assertRaisesRegex(NotImplementedError, NOT_IMPLEMENTED_MSG):
+            self.association.get()
+
+    def test_remove(self):
+        with self.assertRaisesRegex(NotImplementedError, NOT_IMPLEMENTED_MSG):
+            self.association.remove([1, 2, 3])
+
+
+class BrokenNonceTests(unittest.TestCase):
+    def setUp(self):
+        self.nonce = BrokenNonce
+
+    def tearDown(self):
+        self.nonce = None
+
+    def test_use(self):
+        with self.assertRaisesRegex(NotImplementedError, NOT_IMPLEMENTED_MSG):
+            self.nonce.use('http://foobar.com', 1364951922, 'foobar123')
+
+
+class BrokenCodeTest(unittest.TestCase):
+    def setUp(self):
+        self.code = BrokenCode
+
+    def tearDown(self):
+        self.code = None
+
+    def test_get_code(self):
+        with self.assertRaisesRegex(NotImplementedError, NOT_IMPLEMENTED_MSG):
+            self.code.get_code('foobar')
+
+
+class BrokenStrategyTests(unittest.TestCase):
+    def setUp(self):
+        self.strategy = BrokenStrategy(storage=BrokenStorage)
+
+    def tearDown(self):
+        self.strategy = None
+
+    def test_redirect(self):
+        with self.assertRaisesRegex(NotImplementedError, NOT_IMPLEMENTED_MSG):
+            self.strategy.redirect('http://foobar.com')
+
+    def test_get_setting(self):
+        with self.assertRaisesRegex(NotImplementedError, NOT_IMPLEMENTED_MSG):
+            self.strategy.get_setting('foobar')
+
+    def test_html(self):
+        with self.assertRaisesRegex(NotImplementedError, NOT_IMPLEMENTED_MSG):
+            self.strategy.html('<p>foobar</p>')
+
+    def test_request_data(self):
+        with self.assertRaisesRegex(NotImplementedError, NOT_IMPLEMENTED_MSG):
+            self.strategy.request_data()
+
+    def test_request_host(self):
+        with self.assertRaisesRegex(NotImplementedError, NOT_IMPLEMENTED_MSG):
+            self.strategy.request_host()
+
+    def test_session_get(self):
+        with self.assertRaisesRegex(NotImplementedError, NOT_IMPLEMENTED_MSG):
+            self.strategy.session_get('foobar')
+
+    def test_session_set(self):
+        with self.assertRaisesRegex(NotImplementedError, NOT_IMPLEMENTED_MSG):
+            self.strategy.session_set('foobar', 123)
+
+    def test_session_pop(self):
+        with self.assertRaisesRegex(NotImplementedError, NOT_IMPLEMENTED_MSG):
+            self.strategy.session_pop('foobar')
+
+    def test_build_absolute_uri(self):
+        with self.assertRaisesRegex(NotImplementedError, NOT_IMPLEMENTED_MSG):
+            self.strategy.build_absolute_uri('/foobar')
+
+    def test_render_html_with_tpl(self):
+        with self.assertRaisesRegex(NotImplementedError, NOT_IMPLEMENTED_MSG):
+            self.strategy.render_html('foobar.html', context={})
+
+    def test_render_html_with_html(self):
+        with self.assertRaisesRegex(NotImplementedError, NOT_IMPLEMENTED_MSG):
+            self.strategy.render_html(html='<p>foobar</p>', context={})
+
+    def test_render_html_with_none(self):
+        with self.assertRaisesRegex(ValueError,
+                                    'Missing template or html parameters'):
+            self.strategy.render_html()
+
+    def test_is_integrity_error(self):
+        with self.assertRaisesRegex(NotImplementedError, NOT_IMPLEMENTED_MSG):
+            self.strategy.storage.is_integrity_error(None)
+
+    def test_random_string(self):
+        self.assertTrue(isinstance(self.strategy.random_string(), str))
+
+    def test_random_string_without_systemrandom(self):
+        def SystemRandom():
+            raise NotImplementedError()
+
+        orig_random = getattr(random, 'SystemRandom', None)
+        random.SystemRandom = SystemRandom
+
+        strategy = BrokenStrategyWithSettings(storage=BrokenStorage)
+        self.assertTrue(isinstance(strategy.random_string(), str))
+        random.SystemRandom = orig_random
diff --git a/rootfs/api/apps_extra/social_core/tests/test_utils.py b/rootfs/api/apps_extra/social_core/tests/test_utils.py
new file mode 100644
index 000000000..19543f197
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/test_utils.py
@@ -0,0 +1,193 @@
+import sys
+import unittest
+from unittest.mock import Mock
+
+from ..utils import sanitize_redirect, user_is_authenticated, \
+    user_is_active, slugify, build_absolute_uri, \
+    partial_pipeline_data
+from .models import TestPartial
+
+
+PY3 = sys.version_info[0] == 3
+
+
+class SanitizeRedirectTest(unittest.TestCase):
+    def test_none_redirect(self):
+        self.assertEqual(sanitize_redirect(['myapp.com'], None), None)
+
+    def test_empty_redirect(self):
+        self.assertEqual(sanitize_redirect(['myapp.com'], ''), None)
+
+    def test_dict_redirect(self):
+        self.assertEqual(sanitize_redirect(['myapp.com'], {}), None)
+
+    def test_invalid_redirect(self):
+        self.assertEqual(sanitize_redirect(['myapp.com'], {'foo': 'bar'}), None)
+
+    def test_wrong_path_redirect(self):
+        self.assertEqual(
+            sanitize_redirect(['myapp.com'], 'http://notmyapp.com/path/'),
+            None
+        )
+
+    def test_invalid_evil_redirect(self):
+        self.assertEqual(sanitize_redirect(['myapp.com'], '///evil.com'), None)
+
+    def test_valid_absolute_redirect(self):
+        self.assertEqual(
+            sanitize_redirect(['myapp.com'], 'http://myapp.com/path/'),
+            'http://myapp.com/path/'
+        )
+
+    def test_valid_relative_redirect(self):
+        self.assertEqual(sanitize_redirect(['myapp.com'], '/path/'), '/path/')
+
+    def test_multiple_hosts(self):
+        allowed_hosts = ['myapp1.com', 'myapp2.com']
+        for host in allowed_hosts:
+            url = 'http://{}/path/'.format(host)
+            self.assertEqual(sanitize_redirect(allowed_hosts, url), url)
+
+    def test_multiple_hosts_wrong_host(self):
+        self.assertEqual(sanitize_redirect(
+            ['myapp1.com', 'myapp2.com'], 'http://notmyapp.com/path/'), None)
+
+
+class UserIsAuthenticatedTest(unittest.TestCase):
+    def test_user_is_none(self):
+        self.assertEqual(user_is_authenticated(None), False)
+
+    def test_user_is_not_none(self):
+        self.assertEqual(user_is_authenticated(object()), True)
+
+    def test_user_has_is_authenticated(self):
+        class User:
+            is_authenticated = True
+        self.assertEqual(user_is_authenticated(User()), True)
+
+    def test_user_has_is_authenticated_callable(self):
+        class User:
+            def is_authenticated(self):
+                return True
+        self.assertEqual(user_is_authenticated(User()), True)
+
+
+class UserIsActiveTest(unittest.TestCase):
+    def test_user_is_none(self):
+        self.assertEqual(user_is_active(None), False)
+
+    def test_user_is_not_none(self):
+        self.assertEqual(user_is_active(object()), True)
+
+    def test_user_has_is_active(self):
+        class User:
+            is_active = True
+        self.assertEqual(user_is_active(User()), True)
+
+    def test_user_has_is_active_callable(self):
+        class User:
+            def is_active(self):
+                return True
+        self.assertEqual(user_is_active(User()), True)
+
+
+class SlugifyTest(unittest.TestCase):
+    def test_slugify_formats(self):
+        if PY3:
+            self.assertEqual(slugify('FooBar'), 'foobar')
+            self.assertEqual(slugify('Foo Bar'), 'foo-bar')
+            self.assertEqual(slugify('Foo (Bar)'), 'foo-bar')
+        else:
+            self.assertEqual(slugify('FooBar'.decode('utf-8')), 'foobar')
+            self.assertEqual(slugify('Foo Bar'.decode('utf-8')), 'foo-bar')
+            self.assertEqual(slugify('Foo (Bar)'.decode('utf-8')), 'foo-bar')
+
+
+class BuildAbsoluteURITest(unittest.TestCase):
+    def setUp(self):
+        self.host = 'http://foobar.com'
+
+    def tearDown(self):
+        self.host = None
+
+    def test_path_none(self):
+        self.assertEqual(build_absolute_uri(self.host), self.host)
+
+    def test_path_empty(self):
+        self.assertEqual(build_absolute_uri(self.host, ''), self.host)
+
+    def test_path_http(self):
+        self.assertEqual(build_absolute_uri(self.host, 'http://barfoo.com'),
+                         'http://barfoo.com')
+
+    def test_path_https(self):
+        self.assertEqual(build_absolute_uri(self.host, 'https://barfoo.com'),
+                         'https://barfoo.com')
+
+    def test_host_ends_with_slash_and_path_starts_with_slash(self):
+        self.assertEqual(build_absolute_uri(self.host + '/', '/foo/bar'),
+                         'http://foobar.com/foo/bar')
+
+    def test_absolute_uri(self):
+        self.assertEqual(build_absolute_uri(self.host, '/foo/bar'),
+                         'http://foobar.com/foo/bar')
+
+
+class PartialPipelineData(unittest.TestCase):
+    def test_returns_partial_when_uid_and_email_do_match(self):
+        email = 'foo@example.com'
+        backend = self._backend({'uid': email})
+        backend.strategy.request_data.return_value = {
+            backend.ID_KEY: email
+        }
+        key, val = ('foo', 'bar')
+        partial = partial_pipeline_data(backend, None,
+                                        *(), **dict([(key, val)]))
+        self.assertTrue(key in partial.kwargs)
+        self.assertEqual(partial.kwargs[key], val)
+        self.assertEqual(backend.strategy.clean_partial_pipeline.call_count, 0)
+
+    def test_clean_pipeline_when_uid_does_not_match(self):
+        backend = self._backend({'uid': 'foo@example.com'})
+        backend.strategy.request_data.return_value = {
+            backend.ID_KEY: 'bar@example.com'
+        }
+        key, val = ('foo', 'bar')
+        partial = partial_pipeline_data(backend, None,
+                                        *(), **dict([(key, val)]))
+        self.assertIsNone(partial)
+        self.assertEqual(backend.strategy.clean_partial_pipeline.call_count, 1)
+
+    def test_kwargs_included_in_result(self):
+        backend = self._backend()
+        key, val = ('foo', 'bar')
+        partial = partial_pipeline_data(backend, None,
+                                        *(), **dict([(key, val)]))
+        self.assertTrue(key in partial.kwargs)
+        self.assertEqual(partial.kwargs[key], val)
+        self.assertEqual(backend.strategy.clean_partial_pipeline.call_count, 0)
+
+    def test_update_user(self):
+        user = object()
+        backend = self._backend(session_kwargs={'user': None})
+        partial = partial_pipeline_data(backend, user)
+        self.assertTrue('user' in partial.kwargs)
+        self.assertEqual(partial.kwargs['user'], user)
+        self.assertEqual(backend.strategy.clean_partial_pipeline.call_count, 0)
+
+    def _backend(self, session_kwargs=None):
+        backend = Mock()
+        backend.ID_KEY = 'email'
+        backend.name = 'mock-backend'
+
+        strategy = Mock()
+        strategy.request = None
+        strategy.request_data.return_value = {}
+        strategy.session_get.return_value = object()
+        strategy.partial_load.return_value = TestPartial.prepare(backend.name, 0, {
+            'args': [],
+            'kwargs': session_kwargs or {}
+        })
+
+        backend.strategy = strategy
+        return backend
diff --git a/rootfs/api/apps_extra/social_core/tests/testkey.pem b/rootfs/api/apps_extra/social_core/tests/testkey.pem
new file mode 100644
index 000000000..bfc31dc89
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/tests/testkey.pem
@@ -0,0 +1,27 @@
+-----BEGIN RSA PRIVATE KEY-----
+MIIEpQIBAAKCAQEAtavb41y83n7xY/sIS23Rv+TEwI85xmML57BT9dyb9JeAFPYG
+RIaN/kWYW8pR0CZDu2nLwU2lUA0d0wKTOMaHQFs6L/rPN1zOx13HYO+TKybgU+e8
+TaOncKmJeyp3MP/OM6KBYp7Cx6eLlyvw3HJh6nT+Y5vj3rwS4PYjX1hUcr51IOcq
+Ls5Sx/UQ4OUVHJLlcUxP6k2n44nGoDYK8zRc5dEBxbTL4yKzCR9unWAlDHVQYXOx
+vzxxFtUID7dy38e0MZ+3RnmIY2wl7/PIRCsbIKrsAiONnLkJQitMVkU8jTEPrXNC
+Lj5Hzgf0kDD+4ukQdUPCXlv9y668anaxabXFIwIDAQABAoIBAQCUAfT+UiKpnoBp
+dS53zXvSO740Jzvpre4YD2cH6njAl+FIsnu+vmTWoLqVJcrrqxEJkhLnZKvnARio
+PfPVkKDGRMyuJEdIEnuAn519ExIyWNTwZt7Z3hffSLByQNGkgPPsy1tgDnogERc/
+zRPJdgLh8fkDDBFk+JLk5oNA/YSnibV5MtriNPoDONxaHsguJ7JvikgLdQs5nyuQ
+loMx4JhqP+fXsGIU0DFwaLPvEEntKodaRZXvAMLOuGi3O83OGkhevVvEVFSzCeS1
+fYnKwySQgNctztoBpf09zATeL9xt8tipVnyBKOIBJyf/BQV7fESXp0R8d+rW1cs0
+21qakP0pAoGBAOUFPT/pLP3w676dGvggK0kDy8gjgDHl2/x3fhbeua/6yfetVpZE
+CuFez/9E24kMS4/GCi3WgPA6DHDYBH5t1S4e0/cgGJ8sjuX8ELLgy4q+WmvgTwDS
+FbXKqY929Hlu65hUvl2xVP2MYUGLHiUE8yKk4RKdZO+3GkWXHhq9A+mvAoGBAMsS
+rBYyAt9XeI8W8w2dWgZnBp+7WQeMUAZfCpq65urycD9on9R3gJ5zbOfq6CG2FXML
+kslY1R4CVYge+HoWITFECvwPdbDYx/QIVvuhRH8omrG5iYY+ljGuF6wlzETgW117
+vYTSf5xXJ4LiuFD6TY4rxZ0UdjKba9bJfsQb45zNAoGBAMm/zM4xVn46LyLS+YAd
+rqP6oRklFPhf+mQ0y7HP8Royvprea0milUcmI+tHOHJJj4MPPcZVkW6OZIk/8u2B
+SewAIlAwSSBnu1akr/00hHor6DHh/xbE+3UTHD4S15jl+stN1Alrf2iAsuhvalXI
+P3uEbfXdC58U2SL21gFOA1VnAoGBAIv93EoUwewrhb3GyVlaXyRIw8U7xv0wGj66
+KDpQnD3dUco0mvJCS5Vv5uTeCJasyo6brN5G8ewVGdeT2iF3vfwTdysakTyPxRAS
+67veCbVLTZE1YXv9C1dGN6WCDRZyQCnq0tSMtFIXtvJAz2VrmClpPXqzD2SFxkq0
+b0JMI5YFAoGAL+1E+JUfPHf69K8XDXY+zrZL/KRPuXHcGk4qm7JXCDxr90zMVJYj
+RWTQZHgGCEZGGlChJD8JwGiWEtsn0zlhn4H4dPWzeS7D1fsJK30lhYpDp3TuOY0x
+lQAatQXzunglAxXwlOOazX4uoVtiFLjY3U4G6gCwKYEtjP1r0GULlk0=
+-----END RSA PRIVATE KEY-----
diff --git a/rootfs/api/apps_extra/social_core/utils.py b/rootfs/api/apps_extra/social_core/utils.py
new file mode 100644
index 000000000..2e2523c38
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/utils.py
@@ -0,0 +1,308 @@
+import re
+import sys
+import time
+import unicodedata
+import functools
+import hmac
+import logging
+from urllib.parse import urlparse, urlunparse, urlencode, \
+                                   parse_qs as battery_parse_qs
+
+import requests
+import social_core
+
+from requests.adapters import HTTPAdapter
+from requests.packages.urllib3.poolmanager import PoolManager
+
+from .exceptions import AuthCanceled, AuthForbidden, AuthUnreachableProvider
+
+
+SETTING_PREFIX = 'SOCIAL_AUTH'
+
+PARTIAL_TOKEN_SESSION_NAME = 'partial_pipeline_token'
+
+
+social_logger = logging.getLogger('social')
+
+
+class SSLHttpAdapter(HTTPAdapter):
+    """"
+    Transport adapter that allows to use any SSL protocol. Based on:
+    http://requests.rtfd.org/latest/user/advanced/#example-specific-ssl-version
+    """
+    def __init__(self, ssl_protocol):
+        self.ssl_protocol = ssl_protocol
+        super().__init__()
+
+    def init_poolmanager(self, connections, maxsize, block=False):
+        self.poolmanager = PoolManager(
+            num_pools=connections,
+            maxsize=maxsize,
+            block=block,
+            ssl_version=self.ssl_protocol
+        )
+
+    @classmethod
+    def ssl_adapter_session(cls, ssl_protocol):
+        session = requests.Session()
+        session.mount('https://', SSLHttpAdapter(ssl_protocol))
+        return session
+
+
+def import_module(name):
+    __import__(name)
+    return sys.modules[name]
+
+
+def module_member(name):
+    mod, member = name.rsplit('.', 1)
+    module = import_module(mod)
+    return getattr(module, member)
+
+
+def user_agent():
+    """Builds a simple User-Agent string to send in requests"""
+    return 'social-auth-' + social_core.__version__
+
+
+def url_add_parameters(url, params):
+    """Adds parameters to URL, parameter will be repeated if already present"""
+    if params:
+        fragments = list(urlparse(url))
+        value = parse_qs(fragments[4])
+        value.update(params)
+        fragments[4] = urlencode(value)
+        url = urlunparse(fragments)
+    return url
+
+
+def to_setting_name(*names):
+    return '_'.join([name.upper().replace('-', '_') for name in names if name])
+
+
+def setting_name(*names):
+    return to_setting_name(*((SETTING_PREFIX,) + names))
+
+
+def sanitize_redirect(hosts, redirect_to):
+    """
+    Given a list of hostnames and an untrusted URL to redirect to,
+    this method tests it to make sure it isn't garbage/harmful
+    and returns it, else returns None, similar as how's it done
+    on django.contrib.auth.views.
+    """
+    # Avoid redirect on evil URLs like ///evil.com
+    if not redirect_to or not hasattr(redirect_to, 'startswith') or \
+       redirect_to.startswith('///'):
+        return None
+
+    try:
+        # Don't redirect to a host that's not in the list
+        netloc = urlparse(redirect_to)[1] or hosts[0]
+    except (TypeError, AttributeError):
+        pass
+    else:
+        if netloc in hosts:
+            return redirect_to
+
+
+def user_is_authenticated(user):
+    if user and hasattr(user, 'is_authenticated'):
+        if callable(user.is_authenticated):
+            authenticated = user.is_authenticated()
+        else:
+            authenticated = user.is_authenticated
+    elif user:
+        authenticated = True
+    else:
+        authenticated = False
+    return authenticated
+
+
+def user_is_active(user):
+    if user and hasattr(user, 'is_active'):
+        if callable(user.is_active):
+            is_active = user.is_active()
+        else:
+            is_active = user.is_active
+    elif user:
+        is_active = True
+    else:
+        is_active = False
+    return is_active
+
+
+# This slugify version was borrowed from django revision a61dbd6
+def slugify(value):
+    """Converts to lowercase, removes non-word characters (alphanumerics
+    and underscores) and converts spaces to hyphens. Also strips leading
+    and trailing whitespace."""
+    value = unicodedata.normalize('NFKD', str(value)) \
+                       .encode('ascii', 'ignore') \
+                       .decode('ascii')
+    value = re.sub(r'[^\w\s-]', '', value).strip().lower()
+    return re.sub(r'[-\s]+', '-', value)
+
+
+def first(func, items):
+    """Return the first item in the list for what func returns True"""
+    for item in items:
+        if func(item):
+            return item
+
+
+def parse_qs(value):
+    """Like urlparse.parse_qs but transform list values to single items"""
+    return drop_lists(battery_parse_qs(value))
+
+
+def drop_lists(value):
+    out = {}
+    for key, val in value.items():
+        val = val[0]
+        if isinstance(key, bytes):
+            key = str(key, 'utf-8')
+        if isinstance(val, bytes):
+            val = str(val, 'utf-8')
+        out[key] = val
+    return out
+
+
+def partial_pipeline_data(backend, user=None, partial_token=None,
+                          *args, **kwargs):
+    request_data = backend.strategy.request_data()
+
+    partial_argument_name = backend.setting('PARTIAL_PIPELINE_TOKEN_NAME',
+                                            'partial_token')
+    partial_token = partial_token or \
+        request_data.get(partial_argument_name) or \
+        backend.strategy.session_get(PARTIAL_TOKEN_SESSION_NAME, None)
+
+    if partial_token:
+        partial = backend.strategy.partial_load(partial_token)
+        partial_matches_request = False
+
+        if partial and partial.backend == backend.name:
+            partial_matches_request = True
+
+            # Normally when resuming a pipeline, request_data will be empty. We
+            # only need to check for a uid match if new data was provided (i.e.
+            # if current request specifies the ID_KEY).
+            if backend.ID_KEY in request_data:
+                id_from_partial = partial.kwargs.get('uid')
+                id_from_request = request_data.get(backend.ID_KEY)
+
+                if id_from_partial != id_from_request:
+                    partial_matches_request = False
+
+        if partial_matches_request:
+            if user:  # don't update user if it's None
+                kwargs.setdefault('user', user)
+            kwargs.setdefault('request', request_data)
+            partial.extend_kwargs(kwargs)
+            return partial
+        else:
+            backend.strategy.clean_partial_pipeline(partial_token)
+
+
+def build_absolute_uri(host_url, path=None):
+    """Build absolute URI with given (optional) path"""
+    path = path or ''
+    if path.startswith('http://') or path.startswith('https://'):
+        return path
+    if host_url.endswith('/') and path.startswith('/'):
+        path = path[1:]
+    return host_url + path
+
+
+def constant_time_compare(val1, val2):
+    """Compare two values and prevent timing attacks for cryptographic use."""
+    if isinstance(val1, str):
+        val1 = val1.encode('utf-8')
+    if isinstance(val2, str):
+        val2 = val2.encode('utf-8')
+    return hmac.compare_digest(val1, val2)
+
+
+def is_url(value):
+    return value and \
+           (value.startswith('http://') or
+            value.startswith('https://') or
+            value.startswith('/'))
+
+
+def setting_url(backend, *names):
+    for name in names:
+        if is_url(name):
+            return name
+        else:
+            value = backend.setting(name)
+            if is_url(value):
+                return value
+
+
+def handle_http_errors(func):
+    @functools.wraps(func)
+    def wrapper(*args, **kwargs):
+        try:
+            return func(*args, **kwargs)
+        except requests.HTTPError as err:
+            if err.response.status_code == 400:
+                raise AuthCanceled(args[0], response=err.response)
+            elif err.response.status_code == 401:
+                raise AuthForbidden(args[0])
+            elif err.response.status_code == 503:
+                raise AuthUnreachableProvider(args[0])
+            else:
+                raise
+    return wrapper
+
+
+def append_slash(url):
+    """Make sure we append a slash at the end of the URL otherwise we
+    have issues with urljoin Example:
+    >>> urlparse.urljoin('http://www.example.com/api/v3', 'user/1/')
+    'http://www.example.com/api/user/1/'
+    """
+    if url and not url.endswith('/'):
+        url = '{0}/'.format(url)
+    return url
+
+
+def get_strategy(strategy, storage, *args, **kwargs):
+    Strategy = module_member(strategy)
+    Storage = module_member(storage)
+    return Strategy(Storage, *args, **kwargs)
+
+
+class cache:
+    """
+    Cache decorator that caches the return value of a method for a
+    specified time.
+
+    It maintains a cache per class, so subclasses have a different cache entry
+    for the same cached method.
+
+    Does not work for methods with arguments.
+    """
+    def __init__(self, ttl):
+        self.ttl = ttl
+        self.cache = {}
+
+    def __call__(self, fn):
+        def wrapped(this):
+            now = time.time()
+            last_updated = None
+            cached_value = None
+            if this.__class__ in self.cache:
+                last_updated, cached_value = self.cache[this.__class__]
+            if not cached_value or now - last_updated > self.ttl:
+                try:
+                    cached_value = fn(this)
+                    self.cache[this.__class__] = (now, cached_value)
+                except Exception:
+                    # Use previously cached value when call fails, if available
+                    if not cached_value:
+                        raise
+            return cached_value
+        return wrapped
diff --git a/rootfs/api/authentication.py b/rootfs/api/authentication.py
index 56c107bd2..7d55ad9b8 100644
--- a/rootfs/api/authentication.py
+++ b/rootfs/api/authentication.py
@@ -35,9 +35,9 @@ def authenticate(self, request):
             return AnonymousUser(), None
 
 
-class DryccTokenAuthentication(TokenAuthentication):
+class DryccOIDCAuthentication(TokenAuthentication):
     def authenticate(self, request):
-        if 'manager' in request.META.get('HTTP_USER_AGENT', ''):
+        if 'Drycc' in request.META.get('HTTP_USER_AGENT', ''):
             auth = get_authorization_header(request).split()
 
             if not auth or auth[0].lower() != self.keyword.lower().encode():
@@ -57,7 +57,7 @@ def authenticate(self, request):
                 raise exceptions.AuthenticationFailed(msg)
             return cache.get_or_set(
                 token, lambda: self._get_user(token), settings.OAUTH_CACHE_USER_TIME), None  # noqa
-        return super(DryccTokenAuthentication, self).authenticate(request)  # noqa
+        return super(DryccOIDCAuthentication, self).authenticate(request)  # noqa
 
     @staticmethod
     def _get_user(key):
diff --git a/rootfs/api/backend.py b/rootfs/api/backend.py
index 9490c5e97..9d31f1103 100644
--- a/rootfs/api/backend.py
+++ b/rootfs/api/backend.py
@@ -29,3 +29,75 @@ def get_user(self, user_id):
         except ObjectDoesNotExist:
             pass
         return user
+
+from django.conf import settings
+
+from social_core.backends.oauth import BaseOAuth2
+from social_core.backends.open_id_connect import OpenIdConnectAuth
+
+
+class DryccOAuth(BaseOAuth2):
+    """Drycc OAuth authentication backend"""
+    name = 'drycc'
+    AUTHORIZATION_URL = settings.SOCIAL_AUTH_DRYCC_AUTHORIZATION_URL
+    ACCESS_TOKEN_URL = settings.SOCIAL_AUTH_DRYCC_ACCESS_TOKEN_URL
+    ACCESS_TOKEN_METHOD = 'POST'
+    SCOPE_SEPARATOR = ','
+    EXTRA_DATA = [
+        ('id', 'id'),
+        ('access_token', 'access_token'),
+        ('refresh_token', 'refresh_token'),
+        ('expires_in', 'expires_in'),
+        ('token_type', 'token_type'),
+        ('id_token', 'id_token'),
+        ('scope', 'scope'),
+    ]
+
+    def get_user_details(self, response):
+        """Return user details from GitHub account"""
+        print(response)
+        return {
+            'username': response.get('username'),
+            'email': response.get('email') or '',
+            'first_name': response.get('first_name'),
+            'last_name': response.get('last_name'),
+            'is_superuser': response.get('is_superuser'),
+            'is_staff': response.get('is_staff'),
+            'is_active': response.get('is_active'),
+        }
+
+    def user_data(self, access_token, *args, **kwargs):
+        """Loads user data from service"""
+        url = settings.SOCIAL_AUTH_DRYCC_ACCESS_API_URL
+        return self.get_json(url, headers={
+            'authorization': 'Bearer ' + access_token})
+
+    def get_user_id(self, details, response):
+        """Use user account id as unique id"""
+        return response.get('id')
+
+
+class DryccOIDC(OpenIdConnectAuth):
+    """Drycc Openid Connect authentication backend"""
+    name = 'drycc'
+    AUTHORIZATION_URL = settings.SOCIAL_AUTH_DRYCC_AUTHORIZATION_URL
+    ACCESS_TOKEN_URL = settings.SOCIAL_AUTH_DRYCC_ACCESS_TOKEN_URL
+    USERINFO_URL = settings.SOCIAL_AUTH_DRYCC_USERINFO_URL
+    JWKS_URI = settings.SOCIAL_AUTH_DRYCC_JWKS_URI
+    OIDC_ENDPOINT = settings.SOCIAL_AUTH_DRYCC_OIDC_ENDPOINT
+    DEFAULT_SCOPE = ['openid']
+    EXTRA_DATA = [
+        ('id', 'id'),
+        ('access_token', 'access_token'),
+        ('refresh_token', 'refresh_token'),
+        ('expires_in', 'expires_in'),
+        ('token_type', 'token_type'),
+        ('id_token', 'id_token'),
+        ('scope', 'scope'),
+    ]
+
+    from social_core.utils import cache
+    @cache(ttl=86400)
+    def oidc_config(self):
+        return self.get_json(self.OIDC_ENDPOINT +
+                             '/.well-known/openid-configuration/')
diff --git a/rootfs/api/influxdb.py b/rootfs/api/influxdb.py
index 38aa295a1..0cfdc9b40 100644
--- a/rootfs/api/influxdb.py
+++ b/rootfs/api/influxdb.py
@@ -29,6 +29,9 @@ def _query_stream(flux_script: str) -> Iterator[FluxRecord]:
         except ApiException as e:
             logger.exception(e)
             yield from []
+        except Exception as e:
+            logger.exception(e)
+            yield from []
         else:
             yield from records
 
diff --git a/rootfs/api/oauth.py b/rootfs/api/oauth.py
index 34c2061ff..7debe2f0a 100644
--- a/rootfs/api/oauth.py
+++ b/rootfs/api/oauth.py
@@ -6,32 +6,14 @@
 
 
 class OAuthManager(object):
-    def __enter__(self):
-        return self
 
     def __init__(self):
-        self.client_id = settings.OAUTH_CLIENT_ID
-        self.client_secret = settings.OAUTH_CLIENT_SECRET
-        self.token_url = settings.OAUTH_ACCESS_TOKEN_URL
-        self.api_url = settings.OAUTH_ACCESS_API_URL
+        self.client_id = settings.SOCIAL_AUTH_DRYCC_KEY
+        self.client_secret = settings.SOCIAL_AUTH_DRYCC_SECRET
+        self.token_url = settings.SOCIAL_AUTH_DRYCC_ACCESS_TOKEN_URL
+        self.api_url = settings.SOCIAL_AUTH_DRYCC_ACCESS_API_URL
         self.client = OAuth2Session(self.client_id, self.client_secret)
 
-    def fetch_token(self, username: str, password: str) -> Dict:
-        response = self.client.fetch_token(self.token_url,
-                                           username=username,
-                                           password=password)
-        return response
-
-    def get_user(self) -> Dict:
-        response = self.client.get(f'{self.api_url}/users')
-        result = response.json()
-        return result
-
-    def get_email(self) -> str:
-        response = self.client.get(f'{self.api_url}/users/emails')
-        result = response.json()
-        return result['email']
-
     def get_user_by_token(self, token: str) -> Dict:
         response = requests.get(f'{self.api_url}/users', headers={
             'Authorization': f'Bearer {token}'
@@ -46,5 +28,4 @@ def get_email_by_token(self, token: str) -> Dict:
         result = response.json()
         return result
 
-    def __exit__(self, exc_type, exc_val, exc_tb):
-        self.client.close()
+
diff --git a/rootfs/api/permissions.py b/rootfs/api/permissions.py
index 99d3ec42c..e70054a35 100644
--- a/rootfs/api/permissions.py
+++ b/rootfs/api/permissions.py
@@ -1,8 +1,6 @@
-
-from rest_framework import exceptions
 from rest_framework import permissions
 from django.conf import settings
-from django.contrib.auth.models import AnonymousUser, User
+from django.contrib.auth.models import AnonymousUser
 
 from api import models
 
@@ -94,31 +92,6 @@ def has_permission(self, request, view):
         return request.method in permissions.SAFE_METHODS or request.user.is_superuser
 
 
-class HasRegistrationAuth(permissions.BasePermission):
-    """
-    Checks to see if registration is enabled
-    """
-    def has_permission(self, request, view):
-        """
-        If settings.REGISTRATION_MODE does not exist, such as during a test, return True
-        Return `True` if permission is granted, `False` otherwise.
-        """
-        try:
-            if settings.REGISTRATION_MODE == 'disabled':
-                raise exceptions.PermissionDenied('Registration is disabled')
-            if settings.REGISTRATION_MODE == 'enabled':
-                return True
-            elif settings.REGISTRATION_MODE == 'admin_only':
-                if not User.objects.filter(is_superuser=True).exists():
-                    return True
-                return request.user.is_superuser
-            else:
-                raise Exception("{} is not a valid registation mode"
-                                .format(settings.REGISTRATION_MODE))
-        except AttributeError:
-            return True
-
-
 class HasBuilderAuth(permissions.BasePermission):
     """
     View permission to allow builder to perform actions
@@ -133,18 +106,3 @@ def has_permission(self, request, view):
         if not auth_header:
             return False
         return auth_header == settings.BUILDER_KEY
-
-
-class CanRegenerateToken(permissions.BasePermission):
-    """
-    Checks if a user can regenerate a token
-    """
-
-    def has_permission(self, request, view):
-        """
-        Return `True` if permission is granted, `False` otherwise.
-        """
-        if 'username' in request.data or 'all' in request.data:
-            return request.user.is_superuser
-        else:
-            return True
diff --git a/rootfs/api/pipeline.py b/rootfs/api/pipeline.py
new file mode 100644
index 000000000..e55ae490e
--- /dev/null
+++ b/rootfs/api/pipeline.py
@@ -0,0 +1,18 @@
+def update_user(backend, user, response, *args, **kwargs):
+    user.username = response.get('username')
+    user.email = response.get('email')
+    user.first_name = response.get('first_name')
+    user.last_name = response.get('last_name')
+    user.is_superuser = response.get('is_superuser')
+    user.is_staff = response.get('is_staff')
+    user.is_active = response.get('is_active')
+    user.save()
+
+
+def load_extra_data(backend, details, response, uid, user, *args, **kwargs):
+    social = kwargs.get('social') or \
+             backend.strategy.storage.user.get_social_auth(backend.name, uid)
+    if social:
+        extra_data = backend.extra_data(user, uid, response, details,
+                                        *args, **kwargs)
+        social.set_extra_data(extra_data)
diff --git a/rootfs/api/settings/production.py b/rootfs/api/settings/production.py
index f8d030759..7f3c57245 100644
--- a/rootfs/api/settings/production.py
+++ b/rootfs/api/settings/production.py
@@ -2,6 +2,7 @@
 Django settings for the Drycc project.
 """
 from distutils.util import strtobool
+import sys
 import os.path
 import tempfile
 import ldap
@@ -10,6 +11,8 @@
 
 from django_auth_ldap.config import LDAPSearch, GroupOfNamesType
 
+BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, os.path.join(BASE_DIR, 'apps_extra'))
 # A boolean that turns on/off debug mode.
 # https://docs.djangoproject.com/en/1.11/ref/settings/#debug
 DEBUG = bool(os.environ.get('DRYCC_DEBUG', False))
@@ -106,6 +109,7 @@
     'jsonfield',
     'rest_framework',
     'rest_framework.authtoken',
+    'social_django',
     # Drycc apps
     'api'
 )
@@ -135,9 +139,9 @@
 )
 
 X_FRAME_OPTIONS = 'DENY'
-CSRF_COOKIE_SECURE = True
+# CSRF_COOKIE_SECURE = True
 CSRF_COOKIE_HTTPONLY = True
-SESSION_COOKIE_SECURE = True
+# SESSION_COOKIE_SECURE = True
 SECURE_CONTENT_TYPE_NOSNIFF = True
 SECURE_BROWSER_XSS_FILTER = True
 
@@ -155,7 +159,7 @@
         'rest_framework.permissions.IsAuthenticated',
     ),
     'DEFAULT_AUTHENTICATION_CLASSES': (
-        'api.authentication.DryccTokenAuthentication',
+        'api.authentication.DryccOIDCAuthentication',
     ),
     'DEFAULT_RENDERER_CLASSES': (
         'rest_framework.renderers.JSONRenderer',
@@ -416,9 +420,6 @@
 MINIO_PORT = os.environ.get('DRYCC_MINIO_SERVICE_PORT', 80)
 APP_STORAGE = os.environ.get('APP_STORAGE')
 
-# check if we can register users with `drycc register`
-REGISTRATION_MODE = os.environ.get('REGISTRATION_MODE', 'enabled')
-
 DRYCC_DATABASE_URL = os.environ.get('DRYCC_DATABASE_URL', 'postgres://:@:5432/drycc')
 DATABASES = {
     'default': dj_database_url.config(default=DRYCC_DATABASE_URL, conn_max_age=600)
@@ -427,68 +428,31 @@
 APP_URL_REGEX = '[a-z0-9-]+'
 
 # Oauth settings
-OAUTH_ACCESS_TOKEN_URL = os.environ.get('OAUTH2_ACCESS_TOKEN_URL', '')
-OAUTH_ACCESS_API_URL = os.environ.get('OAUTH2_ACCESS_API_URL', '')
-OAUTH_CLIENT_ID = os.environ.get('OAUTH2_CLIENT_ID', '')
-OAUTH_CLIENT_SECRET = os.environ.get('OAUTH2_CLIENT_SECRET', '')
+LOGIN_REDIRECT_URL = os.environ.get('LOGIN_REDIRECT_URL', '/admin/')
+SOCIAL_AUTH_DRYCC_AUTHORIZATION_URL = os.environ.get('SOCIAL_AUTH_DRYCC_AUTHORIZATION_URL')
+SOCIAL_AUTH_DRYCC_ACCESS_TOKEN_URL = os.environ.get('SOCIAL_AUTH_DRYCC_ACCESS_TOKEN_URL')
+SOCIAL_AUTH_DRYCC_ACCESS_API_URL = os.environ.get('SOCIAL_AUTH_DRYCC_ACCESS_API_URL')
+SOCIAL_AUTH_DRYCC_USERINFO_URL = os.environ.get('SOCIAL_AUTH_DRYCC_ACCESS_TOKEN_URL')
+SOCIAL_AUTH_DRYCC_JWKS_URI = os.environ.get('SOCIAL_AUTH_DRYCC_ACCESS_API_URL')
+SOCIAL_AUTH_DRYCC_OIDC_ENDPOINT = os.environ.get('SOCIAL_AUTH_DRYCC_OIDC_ENDPOINT')
+SOCIAL_AUTH_DRYCC_KEY = os.environ.get('SOCIAL_AUTH_DRYCC_KEY')
+SOCIAL_AUTH_DRYCC_SECRET = os.environ.get('SOCIAL_AUTH_DRYCC_SECRET')
+SOCIAL_AUTH_POSTGRES_JSONFIELD = True
+SOCIAL_AUTH_PIPELINE = (
+    'social_core.pipeline.social_auth.social_details',
+    'social_core.pipeline.social_auth.social_uid',
+    'social_core.pipeline.social_auth.social_user',
+    'social_core.pipeline.user.get_username',
+    'social_core.pipeline.social_auth.associate_by_email',
+    'social_core.pipeline.user.create_user',
+    'api.pipeline.update_user',
+    'social_core.pipeline.social_auth.associate_user',
+    'social_core.pipeline.social_auth.load_extra_data',
+    'social_core.pipeline.user.user_details',
+)
+AUTHENTICATION_BACKENDS = ("api.backend.DryccOIDC",) + \
+    AUTHENTICATION_BACKENDS
 OAUTH_CACHE_USER_TIME = int(os.environ.get('OAUTH_CACHE_USER_TIME', 30 * 60))
-if OAUTH_ACCESS_TOKEN_URL:
-    AUTHENTICATION_BACKENDS = ("api.backend.DryccOauthBackend",) + AUTHENTICATION_BACKENDS
-
-# LDAP settings taken from environment variables.
-LDAP_ENDPOINT = os.environ.get('LDAP_ENDPOINT', '')
-LDAP_BIND_DN = os.environ.get('LDAP_BIND_DN', '')
-LDAP_BIND_PASSWORD = os.environ.get('LDAP_BIND_PASSWORD', '')
-LDAP_USER_BASEDN = os.environ.get('LDAP_USER_BASEDN', '')
-LDAP_USER_FILTER = os.environ.get('LDAP_USER_FILTER', 'username')
-LDAP_GROUP_BASEDN = os.environ.get('LDAP_GROUP_BASEDN', '')
-LDAP_GROUP_FILTER = os.environ.get('LDAP_GROUP_FILTER', '')
-LDAP_ACTIVE_GROUP = os.environ.get('LDAP_ACTIVE_GROUP', '')
-LDAP_STAFF_GROUP = os.environ.get('LDAP_STAFF_GROUP', '')
-LDAP_SUPERUSER_GROUP = os.environ.get('LDAP_SUPERUSER_GROUP', '')
-
-# Django LDAP backend configuration.
-# See https://pythonhosted.org/django-auth-ldap/reference.html
-# for variables' details.
-# In order to debug LDAP configuration it is possible to enable
-# verbose logging from auth-ldap plugin:
-# https://pythonhosted.org/django-auth-ldap/logging.html
-
-if LDAP_ENDPOINT:
-    AUTHENTICATION_BACKENDS = ("django_auth_ldap.backend.LDAPBackend",) + AUTHENTICATION_BACKENDS
-    AUTH_LDAP_SERVER_URI = LDAP_ENDPOINT
-    AUTH_LDAP_BIND_DN = LDAP_BIND_DN
-    AUTH_LDAP_BIND_PASSWORD = LDAP_BIND_PASSWORD
-    AUTH_LDAP_USER_SEARCH = LDAPSearch(
-        base_dn=LDAP_USER_BASEDN,
-        scope=ldap.SCOPE_SUBTREE,
-        filterstr="%s" % LDAP_USER_FILTER
-    )
-    AUTH_LDAP_GROUP_SEARCH = LDAPSearch(
-        base_dn=LDAP_GROUP_BASEDN,
-        scope=ldap.SCOPE_SUBTREE,
-        filterstr="%s" % LDAP_GROUP_FILTER
-    )
-    AUTH_LDAP_USER_FLAGS_BY_GROUP = {
-        'is_active': LDAP_ACTIVE_GROUP,
-        'is_staff': LDAP_STAFF_GROUP,
-        'is_superuser': LDAP_SUPERUSER_GROUP,
-    }
-    AUTH_LDAP_GROUP_TYPE = GroupOfNamesType()
-    AUTH_LDAP_USER_ATTR_MAP = {
-        "first_name": "givenName",
-        "last_name": "sn",
-        "email": "mail",
-        "username": LDAP_USER_FILTER,
-    }
-    AUTH_LDAP_GLOBAL_OPTIONS = {
-        ldap.OPT_X_TLS_REQUIRE_CERT: False,
-        ldap.OPT_REFERRALS: False
-    }
-    AUTH_LDAP_ALWAYS_UPDATE_USER = True
-    AUTH_LDAP_MIRROR_GROUPS = True
-    AUTH_LDAP_FIND_GROUP_PERMS = True
-    AUTH_LDAP_CACHE_GROUPS = False
 
 # Redis Configuration
 DRYCC_REDIS_ADDRS = os.environ.get('DRYCC_REDIS_ADDRS', '127.0.0.1:6379').split(",")
diff --git a/rootfs/api/tasks.py b/rootfs/api/tasks.py
index add65b91c..14b243b80 100644
--- a/rootfs/api/tasks.py
+++ b/rootfs/api/tasks.py
@@ -10,8 +10,9 @@
 from django.conf import settings
 from django.utils.timezone import now
 from celery import shared_task
+from requests_toolbelt import user_agent
 from api.models.resource import Resource
-
+from api import __version__ as drycc_version
 logger = logging.getLogger(__name__)
 
 
@@ -59,7 +60,8 @@ def measure_config(config: List[Dict[str, str]]):
             url="%s/measurements/config/" % settings.WORKFLOW_MANAGER_URL,
             headers={
                 'Content-Type': 'application/json',
-                'Authorization': 'token %s' % settings.WORKFLOW_MANAGER_TOKEN
+                'Authorization': 'token %s' % settings.WORKFLOW_MANAGER_TOKEN,
+                'User-Agent': user_agent('Drycc Controller ', drycc_version)
             },
             data=json.dumps(config)
         )
@@ -89,7 +91,8 @@ def measure_volumes(volumes: List[Dict[str, str]]):
             url="%s/measurements/volumes/" % settings.WORKFLOW_MANAGER_URL,
             headers={
                 'Content-Type': 'application/json',
-                'Authorization': 'token %s' % settings.WORKFLOW_MANAGER_TOKEN
+                'Authorization': 'token %s' % settings.WORKFLOW_MANAGER_TOKEN,
+                'User-Agent': user_agent('Drycc Controller ', drycc_version)
             },
             data=json.dumps(volumes)
         )
@@ -120,7 +123,8 @@ def measure_networks(networks: List[Dict[str, str]]):
             url="%s/measurements/networks/" % settings.WORKFLOW_MANAGER_URL,
             headers={
                 'Content-Type': 'application/json',
-                'Authorization': 'token %s' % settings.WORKFLOW_MANAGER_TOKEN
+                'Authorization': 'token %s' % settings.WORKFLOW_MANAGER_TOKEN,
+                'User-Agent': user_agent('Drycc Controller ', drycc_version)
             },
             data=json.dumps(networks)
         )
@@ -150,7 +154,8 @@ def measure_instances(instances: List[Dict[str, str]]):
             url="%s/measurements/instances/" % settings.WORKFLOW_MANAGER_URL,
             headers={
                 'Content-Type': 'application/json',
-                'Authorization': 'token %s' % settings.WORKFLOW_MANAGER_TOKEN
+                'Authorization': 'token %s' % settings.WORKFLOW_MANAGER_TOKEN,
+                'User-Agent': user_agent('Drycc Controller ', drycc_version)
             },
             data=json.dumps(instances)
         )
@@ -180,7 +185,8 @@ def measure_resources(resources: List[Dict[str, str]]):
             url="%s/measurements/resources/" % settings.WORKFLOW_MANAGER_URL,
             headers={
                 'Content-Type': 'application/json',
-                'Authorization': 'token %s' % settings.WORKFLOW_MANAGER_TOKEN
+                'Authorization': 'token %s' % settings.WORKFLOW_MANAGER_TOKEN,
+                'User-Agent': user_agent('Drycc Controller ', drycc_version)
             },
             data=json.dumps(resources)
         )
diff --git a/rootfs/api/tests/test_build.py b/rootfs/api/tests/test_build.py
index f9a3181e4..f53390afb 100644
--- a/rootfs/api/tests/test_build.py
+++ b/rootfs/api/tests/test_build.py
@@ -119,7 +119,7 @@ def test_build_default_containers(self, mock_requests):
         self.assertEqual(container['type'], 'cmd')
         self.assertEqual(container['release'], 'v2')
         # pod name is auto generated so use regex
-        self.assertRegex(container['name'], app_id + '-cmd-[0-9]{8,10}-[a-z0-9]{5}')
+        self.assertRegex(container['name'], app_id + '-cmd-[0-9]{1,10}-[a-z0-9]{5}')
 
         # post an image as a build with a procfile
         app_id = self.create_app()
@@ -143,7 +143,7 @@ def test_build_default_containers(self, mock_requests):
         self.assertEqual(container['type'], 'web')
         self.assertEqual(container['release'], 'v2')
         # pod name is auto generated so use regex
-        self.assertRegex(container['name'], app_id + '-web-[0-9]{7,10}-[a-z0-9]{5}')
+        self.assertRegex(container['name'], app_id + '-web-[0-9]{1,10}-[a-z0-9]{5}')
 
         # start with a new app
         app_id = self.create_app()
@@ -166,7 +166,7 @@ def test_build_default_containers(self, mock_requests):
         self.assertEqual(container['type'], 'cmd')
         self.assertEqual(container['release'], 'v2')
         # pod name is auto generated so use regex
-        self.assertRegex(container['name'], app_id + '-cmd-[0-9]{8,10}-[a-z0-9]{5}')
+        self.assertRegex(container['name'], app_id + '-cmd-[0-9]{1,10}-[a-z0-9]{5}')
 
         # start with a new app
         app_id = self.create_app()
@@ -193,7 +193,7 @@ def test_build_default_containers(self, mock_requests):
         self.assertEqual(container['type'], 'cmd')
         self.assertEqual(container['release'], 'v2')
         # pod name is auto generated so use regex
-        self.assertRegex(container['name'], app_id + '-cmd-[0-9]{8,10}-[a-z0-9]{5}')
+        self.assertRegex(container['name'], app_id + '-cmd-[0-9]{1,10}-[a-z0-9]{5}')
 
         # start with a new app
         app_id = self.create_app()
@@ -220,7 +220,7 @@ def test_build_default_containers(self, mock_requests):
         self.assertEqual(container['type'], 'web')
         self.assertEqual(container['release'], 'v2')
         # pod name is auto generated so use regex
-        self.assertRegex(container['name'], app_id + '-web-[0-9]{7,10}-[a-z0-9]{5}')
+        self.assertRegex(container['name'], app_id + '-web-[0-9]{1,10}-[a-z0-9]{5}')
 
         # start with a new app
         app_id = self.create_app()
@@ -278,7 +278,7 @@ def test_build_forgotten_procfile(self, mock_requests):
         self.assertEqual(container['type'], 'worker')
         self.assertEqual(container['release'], 'v2')
         # pod name is auto generated so use regex
-        self.assertRegex(container['name'], app_id + '-worker-[0-9]{8,10}-[a-z0-9]{5}')
+        self.assertRegex(container['name'], app_id + '-worker-[0-9]{1,10}-[a-z0-9]{5}')
 
         # do another deploy for this time forget Procfile
         url = "/v2/apps/{app_id}/builds".format(**locals())
@@ -349,7 +349,7 @@ def test_build_no_remove_process(self, mock_requests):
         self.assertEqual(container['type'], 'worker')
         self.assertEqual(container['release'], 'v2')
         # pod name is auto generated so use regex
-        self.assertRegex(container['name'], app_id + '-worker-[0-9]{7,10}-[a-z0-9]{5}')
+        self.assertRegex(container['name'], app_id + '-worker-[0-9]{1,10}-[a-z0-9]{5}')
 
         # do another deploy for this time forget Procfile
         url = "/v2/apps/{app_id}/builds".format(**locals())
@@ -366,7 +366,7 @@ def test_build_no_remove_process(self, mock_requests):
         self.assertEqual(container['type'], 'worker')
         self.assertEqual(container['release'], 'v3')
         # pod name is auto generated so use regex
-        self.assertRegex(container['name'], app_id + '-worker-[0-9]{8,10}-[a-z0-9]{5}')
+        self.assertRegex(container['name'], app_id + '-worker-[0-9]{1,10}-[a-z0-9]{5}')
 
         # verify web is still there
         url = "/v2/apps/{app_id}/pods/web".format(**locals())
@@ -377,7 +377,7 @@ def test_build_no_remove_process(self, mock_requests):
         self.assertEqual(container['type'], 'web')
         self.assertEqual(container['release'], 'v3')
         # pod name is auto generated so use regex
-        self.assertRegex(container['name'], app_id + '-web-[0-9]{8,10}-[a-z0-9]{5}')
+        self.assertRegex(container['name'], app_id + '-web-[0-9]{1,10}-[a-z0-9]{5}')
 
         # look at the app structure
         url = "/v2/apps/{app_id}".format(**locals())
@@ -400,7 +400,7 @@ def test_build_no_remove_process(self, mock_requests):
         self.assertEqual(container['type'], 'worker')
         self.assertEqual(container['release'], 'v3')
         # pod name is auto generated so use regex
-        self.assertRegex(container['name'], app_id + '-worker-[0-9]{8,10}-[a-z0-9]{5}')
+        self.assertRegex(container['name'], app_id + '-worker-[0-9]{1,10}-[a-z0-9]{5}')
 
     @override_settings(DRYCC_DEPLOY_REJECT_IF_PROCFILE_MISSING=True)
     def test_build_forgotten_procfile_reject(self, mock_requests):
@@ -441,7 +441,7 @@ def test_build_forgotten_procfile_reject(self, mock_requests):
         self.assertEqual(container['type'], 'worker')
         self.assertEqual(container['release'], 'v2')
         # pod name is auto generated so use regex
-        self.assertRegex(container['name'], app_id + '-worker-[0-9]{8,10}-[a-z0-9]{5}')
+        self.assertRegex(container['name'], app_id + '-worker-[0-9]{1,10}-[a-z0-9]{5}')
 
         # do another deploy for this time forget Procfile
         url = "/v2/apps/{app_id}/builds".format(**locals())
diff --git a/rootfs/api/tests/test_hooks.py b/rootfs/api/tests/test_hooks.py
index 36d8bc0c5..cb53f751b 100644
--- a/rootfs/api/tests/test_hooks.py
+++ b/rootfs/api/tests/test_hooks.py
@@ -240,7 +240,7 @@ def test_build_hook_procfile(self, mock_requests):
         self.assertEqual(container['type'], 'web')
         self.assertEqual(container['release'], 'v2')
         # pod name is auto generated so use regex
-        self.assertRegex(container['name'], app_id + '-web-[0-9]{8,10}-[a-z0-9]{5}')
+        self.assertRegex(container['name'], app_id + '-web-[0-9]{1,10}-[a-z0-9]{5}')
 
         # post the build without an auth token
         self.client.credentials()
@@ -285,7 +285,7 @@ def test_build_hook_dockerfile(self, mock_requests):
         self.assertEqual(container['type'], 'cmd')
         self.assertEqual(container['release'], 'v2')
         # pod name is auto generated so use regex
-        self.assertRegex(container['name'], app_id + '-cmd-[0-9]{8,10}-[a-z0-9]{5}')
+        self.assertRegex(container['name'], app_id + '-cmd-[0-9]{1,10}-[a-z0-9]{5}')
 
         # post the build without an auth token
         self.client.credentials()
diff --git a/rootfs/api/urls.py b/rootfs/api/urls.py
index 5ed8ac65f..04a52d4e9 100644
--- a/rootfs/api/urls.py
+++ b/rootfs/api/urls.py
@@ -5,7 +5,6 @@
 
 from django.conf import settings
 from django.conf.urls import include, url
-from rest_framework.authtoken.views import obtain_auth_token as views_obtain_auth_token
 from rest_framework.routers import DefaultRouter
 
 from api import views
@@ -16,6 +15,9 @@
 # Add the generated REST URLs and login/logout endpoint
 urlpatterns = [
     url(r'^', include(router.urls)),
+    url('', include('social_django.urls', namespace='social')),
+    url(r'auth/login/?$', views.AuthLoginView.as_view()),
+    url(r'auth/token/(?P<key>[-_\w]+)/?$', views.AuthTokenView.as_view()),
     # application release components
     url(r"^apps/(?P<id>{})/config/?$".format(settings.APP_URL_REGEX),
         views.ConfigViewSet.as_view({'get': 'retrieve', 'post': 'create'})),
@@ -118,21 +120,6 @@
         views.BuildHookViewSet.as_view({'post': 'create'})),
     url(r'^hooks/config/?$',
         views.ConfigHookViewSet.as_view({'post': 'create'})),
-    # authn / authz
-    url(r'^auth/register/?$',
-        views.UserRegistrationViewSet.as_view({'post': 'create'})),
-    url(r'^auth/cancel/?$',
-        views.UserManagementViewSet.as_view({'delete': 'destroy'})),
-    url(r'^auth/passwd/?$',
-        views.UserManagementViewSet.as_view({'post': 'passwd'})),
-    url(r'^auth/whoami/?$',
-        views.UserManagementViewSet.as_view({'get': 'list'})),
-    url(r'^auth/login/$',
-        views_obtain_auth_token),
-    url(r'^auth/tokens/$',
-        views.TokenManagementViewSet.as_view({'post': 'regenerate'})),
-    url(r'^auth/tokens/(?P<username>[\w.@+-]+)/?$',
-        views.TokenManagementViewSet.as_view({'get': 'token'})),
     # admin sharing
     url(r'^admin/perms/(?P<username>[\w.@+-]+)/?$',
         views.AdminPermsViewSet.as_view({'delete': 'destroy'})),
@@ -155,6 +142,6 @@
         views.UserView.as_view({'patch': 'enable'})),
     url(r'^users/(?P<username>[\w.@+-]+)/disable/?$',
         views.UserView.as_view({'patch': 'disable'})),
-    url(r'^apps/(?P<id>{})/metrics/(?P<container_type>[a-z0-9]+(\-[a-z0-9]+)*)?$'.format(settings.APP_URL_REGEX),  # noqa
+    url(r'^apps/(?P<id>{})/metrics/(?P<container_type>[a-z0-9]+(\-[a-z0-9]+)*)/?$'.format(settings.APP_URL_REGEX),  # noqa
         views.MetricView.as_view({'get': 'status'})),
 ]
diff --git a/rootfs/api/views.py b/rootfs/api/views.py
index 762b8535b..80cdbb46a 100644
--- a/rootfs/api/views.py
+++ b/rootfs/api/views.py
@@ -2,11 +2,13 @@
 RESTful view classes for presenting Drycc API objects.
 """
 import logging
+import json
 from copy import deepcopy
+from django.core.cache import cache
 from django.http import Http404, HttpResponse
 from django.conf import settings
 from django.contrib.auth.models import User
-from django.shortcuts import get_object_or_404
+from django.shortcuts import get_object_or_404, redirect
 from guardian.shortcuts import assign_perm, get_objects_for_user, \
     get_users_with_perms, remove_perm
 from django.views.generic import View
@@ -53,127 +55,23 @@ def get(self, request):
     head = get
 
 
-class UserRegistrationViewSet(GenericViewSet,
-                              mixins.CreateModelMixin):
-    """ViewSet to handle registering new users. The logic is in the serializer."""
-    authentication_classes = [authentication.AnonymousOrAuthenticatedAuthentication]
-    permission_classes = [permissions.HasRegistrationAuth]
-    serializer_class = serializers.UserSerializer
-
-    def create(self, request, *args, **kwargs):
-        if settings.LDAP_ENDPOINT:
-            raise DryccException("You cannot register user when ldap is enabled.")
-        if settings.OAUTH_ACCESS_TOKEN_URL:
-            raise DryccException("You cannot register user when oauth2 is enabled.")
-        return super(UserRegistrationViewSet, self).create(request, *args, **kwargs)
-
-
-class UserManagementViewSet(GenericViewSet):
-    serializer_class = serializers.UserSerializer
-
-    def get_queryset(self):
-        return User.objects.filter(pk=self.request.user.pk)
-
-    def get_object(self):
-        return self.get_queryset()[0]
-
-    def list(self, request, **kwargs):
-        user = self.get_object()
-        serializer = self.get_serializer(user, many=False)
-        return Response(serializer.data)
-
-    def destroy(self, request, **kwargs):
-        if settings.LDAP_ENDPOINT:
-            raise DryccException("You cannot destroy user when ldap is enabled.")
-        if settings.OAUTH_ACCESS_TOKEN_URL:
-            raise DryccException("You cannot destroy user when oauth2 is enabled.")
-        calling_obj = self.get_object()
-        target_obj = calling_obj
-
-        if request.data.get('username'):
-            # if you "accidentally" target yourself, that should be fine
-            if calling_obj.username == request.data['username'] or calling_obj.is_superuser:
-                target_obj = get_object_or_404(User, username=request.data['username'])
-            else:
-                raise PermissionDenied()
-
-        # A user can not be removed without apps changing ownership first
-        if len(models.App.objects.filter(owner=target_obj)) > 0:
-            msg = '{} still has applications assigned. Delete or transfer ownership'.format(str(target_obj))  # noqa
-            raise AlreadyExists(msg)
-
-        try:
-            target_obj.delete()
-            return Response(status=status.HTTP_204_NO_CONTENT)
-        except ProtectedError as e:
-            raise AlreadyExists(e)
-
-    def passwd(self, request, **kwargs):
-        if not request.data.get('new_password'):
-            raise DryccException("new_password is a required field")
-        if settings.LDAP_ENDPOINT:
-            raise DryccException("You cannot change password when ldap is enabled.")
-        if settings.OAUTH_ACCESS_TOKEN_URL:
-            raise DryccException("You cannot change user when oauth2 is enabled.")
-        caller_obj = self.get_object()
-        target_obj = self.get_object()
-        if request.data.get('username'):
-            # if you "accidentally" target yourself, that should be fine
-            if caller_obj.username == request.data['username'] or caller_obj.is_superuser:
-                target_obj = get_object_or_404(User, username=request.data['username'])
-            else:
-                raise PermissionDenied()
-
-        if not caller_obj.is_superuser:
-            if not request.data.get('password'):
-                raise DryccException("password is a required field")
-            if not target_obj.check_password(request.data['password']):
-                raise AuthenticationFailed('Current password does not match')
-
-        target_obj.set_password(request.data['new_password'])
-        target_obj.save()
-        return Response({'status': 'password set'})
+class AuthLoginView(View):
+    def post(self, request, *args, **kwargs):
+        import uuid
+        def get_local_host(request):
+            uri = request.build_absolute_uri()
+            return uri[0:uri.find(request.path)]
+        res = redirect(get_local_host(request) + f"/v2/login/drycc/?key=" + uuid.uuid4().hex)
+        return res
 
 
-class TokenManagementViewSet(GenericViewSet,
-                             mixins.DestroyModelMixin):
-    serializer_class = serializers.UserSerializer
-    permission_classes = [permissions.CanRegenerateToken]
-
-    def get_queryset(self):
-        return User.objects.filter(pk=self.request.user.pk)
-
-    def get_object(self):
-        return self.get_queryset()[0]
-
-    def regenerate(self, request, **kwargs):
-        obj = self.get_object()
-
-        if 'all' in request.data:
-            for user in User.objects.all():
-                if not user.is_anonymous:
-                    token = Token.objects.get(user=user)
-                    token.delete()
-                    Token.objects.create(user=user)
-            return Response("")
-
-        if 'username' in request.data:
-            obj = get_object_or_404(User,
-                                    username=request.data['username'])
-            self.check_object_permissions(self.request, obj)
-
-        token = Token.objects.get(user=obj)
-        token.delete()
-        token = Token.objects.create(user=obj)
-        return Response({'token': token.key})
-
-    def token(self, request, **kwargs):
-        if self.request.user.username == kwargs['username'] \
-                or self.request.user.is_superuser:
-            obj = get_object_or_404(User, username=kwargs['username'])
-            token = Token.objects.get(user=obj)
-            return Response({'token': token.key})
-        return Response(status=status.HTTP_403_FORBIDDEN)
+class AuthTokenView(View):
+    def get(self, request, *args, **kwargs):
+        state = cache.get("oidc_key_" + self.kwargs['key'], "")
+        token = cache.get("oidc_state_" + state, {})
+        if not token.get('token'):
+            return HttpResponse(status=404)
+        return HttpResponse(json.dumps(token))
 
 
 class BaseDryccViewSet(viewsets.OwnerViewSet):
diff --git a/rootfs/requirements.txt b/rootfs/requirements.txt
index 0245e1b9f..9d98929e7 100644
--- a/rootfs/requirements.txt
+++ b/rootfs/requirements.txt
@@ -1,8 +1,7 @@
 # Drycc controller requirements
 backoff==1.10.0
 django==2.2.24
-django-auth-ldap==2.2.0
-django-cors-middleware==1.5.0
+django-cors-headers==3.7.0
 django-guardian==2.3.0
 djangorestframework==3.11.2
 docker==4.2.2
@@ -24,4 +23,6 @@ celery==5.0.2
 django_redis==4.12.1
 influxdb-client==1.13.0
 dj-database-url
+social-auth-app-django==4.0.0
+python-jose
 Authlib
