Repository: doccano/doccano

Issue:
Add Okta Integration for OAuth
Feature description
---------
<!-- Please describe the feature: Which area of the library is it related to? What specific solution would you like? -->

Add Okta OpenID and OAuth support as other social login options.


Discussion:

Resolving patch:
diff --git a/app/app/settings.py b/app/app/settings.py
--- a/app/app/settings.py
+++ b/app/app/settings.py
@@ -144,6 +144,8 @@
 AUTHENTICATION_BACKENDS = [
     'social_core.backends.github.GithubOAuth2',
     'social_core.backends.azuread_tenant.AzureADTenantOAuth2',
+    'social_core.backends.okta.OktaOAuth2',
+    'social_core.backends.okta_openidconnect.OktaOpenIdConnect',
     'django.contrib.auth.backends.ModelBackend',
 ]
 
@@ -173,6 +175,22 @@
     SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_RESOURCE = 'https://graph.microsoft.com/'
     SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_SCOPE = ['Directory.Read.All']
 
+SOCIAL_AUTH_OKTA_OAUTH2_KEY = env('OAUTH_OKTA_OAUTH2_KEY', None)
+SOCIAL_AUTH_OKTA_OAUTH2_SECRET = env('OAUTH_OKTA_OAUTH2_SECRET', None)
+SOCIAL_AUTH_OKTA_OAUTH2_API_URL = env('OAUTH_OKTA_OAUTH2_API_URL', None)
+OKTA_OAUTH2_ADMIN_GROUP_NAME = env('OKTA_OAUTH2_ADMIN_GROUP_NAME', None)
+
+if SOCIAL_AUTH_OKTA_OAUTH2_API_URL:
+    SOCIAL_AUTH_OKTA_OAUTH2_SCOPE = ["groups"]
+
+SOCIAL_AUTH_OKTA_OPENIDCONNECT_KEY = env('OAUTH_OKTA_OPENIDCONNECT_KEY', None)
+SOCIAL_AUTH_OKTA_OPENIDCONNECT_SECRET = env('OAUTH_OKTA_OPENIDCONNECT_SECRET', None)
+SOCIAL_AUTH_OKTA_OPENIDCONNECT_API_URL = env('OAUTH_OKTA_OPENIDCONNECT_API_URL', None)
+OKTA_OPENIDCONNECT_ADMIN_GROUP_NAME = env('OKTA_OPENIDCONNECT_ADMIN_GROUP_NAME', None)
+
+if SOCIAL_AUTH_OKTA_OPENIDCONNECT_API_URL:
+    SOCIAL_AUTH_OKTA_OPENIDCONNECT_SCOPE = ["groups"]
+
 SOCIAL_AUTH_PIPELINE = [
     'social_core.pipeline.social_auth.social_details',
     'social_core.pipeline.social_auth.social_uid',
@@ -185,6 +203,8 @@
     'social_core.pipeline.user.user_details',
     'server.social_auth.fetch_github_permissions',
     'server.social_auth.fetch_azuread_permissions',
+    'server.social_auth.fetch_okta_oauth2_permissions',
+    'server.social_auth.fetch_okta_openidconnect_permissions',
 ]
 
 ROLE_PROJECT_ADMIN = env('ROLE_PROJECT_ADMIN', 'project_admin')
diff --git a/app/server/social_auth.py b/app/server/social_auth.py
--- a/app/server/social_auth.py
+++ b/app/server/social_auth.py
@@ -2,6 +2,8 @@
 from django.conf import settings
 from social_core.backends.azuread_tenant import AzureADTenantOAuth2
 from social_core.backends.github import GithubOAuth2
+from social_core.backends.okta import OktaOAuth2
+from social_core.backends.okta_openidconnect import OktaOpenIdConnect
 
 
 # noinspection PyUnusedLocal
@@ -68,3 +70,74 @@ def fetch_azuread_permissions(strategy, details, user=None, is_new=False, *args,
     if user.is_superuser != is_superuser:
         user.is_superuser = is_superuser
         user.save()
+
+
+# noinspection PyUnusedLocal
+def fetch_okta_oauth2_permissions(strategy, details, user=None, is_new=False, *args, **kwargs):
+    org_url = getattr(settings, 'SOCIAL_AUTH_OKTA_OAUTH2_API_URL', '')
+    admin_group_name = getattr(settings, "OKTA_OAUTH2_ADMIN_GROUP_NAME", "")
+    if not user or not isinstance(kwargs['backend'], OktaOAuth2):
+        return
+
+    # OktaOpenIdConnect inherits `OktaOAuth2`, so we have to explicitly skip OAuth2 trying
+    # to fetch permissions when using OIDC backend.
+    if isinstance(kwargs['backend'], OktaOpenIdConnect):
+        return
+
+    response = requests.post(
+        url=f"{org_url}/v1/userinfo",
+        headers={
+            'Authorization': 'Bearer {}'.format(kwargs['response']['access_token']),
+        },
+    )
+    response.raise_for_status()
+    response = response.json()
+
+    is_superuser = admin_group_name in response.get("groups", [])
+    is_staff = admin_group_name in response.get("groups", [])
+
+    user_changed = False
+
+    if user.is_superuser != is_superuser:
+        user.is_superuser = is_superuser
+        user_changed = user_changed or True
+
+    if user.is_staff != is_staff:
+        user.is_staff = is_staff
+        user_changed = user_changed or True
+
+    if user_changed:
+        user.save()
+
+
+# noinspection PyUnusedLocal
+def fetch_okta_openidconnect_permissions(strategy, details, user=None, is_new=False, *args, **kwargs):
+    org_url = getattr(settings, 'SOCIAL_AUTH_OKTA_OPENIDCONNECT_API_URL', '')
+    admin_group_name = getattr(settings, "OKTA_OPENIDCONNECT_ADMIN_GROUP_NAME", "")
+    if not user or not isinstance(kwargs['backend'], OktaOpenIdConnect):
+        return
+
+    response = requests.post(
+        url=f"{org_url}/v1/userinfo",
+        headers={
+            'Authorization': 'Bearer {}'.format(kwargs['response']['access_token']),
+        },
+    )
+    response.raise_for_status()
+    response = response.json()
+
+    is_superuser = admin_group_name in response.get("groups", [])
+    is_staff = admin_group_name in response.get("groups", [])
+
+    user_changed = False
+
+    if user.is_superuser != is_superuser:
+        user.is_superuser = is_superuser
+        user_changed = user_changed or True
+
+    if user.is_staff != is_staff:
+        user.is_staff = is_staff
+        user_changed = user_changed or True
+
+    if user_changed:
+        user.save()
diff --git a/app/server/views.py b/app/server/views.py
--- a/app/server/views.py
+++ b/app/server/views.py
@@ -104,6 +104,8 @@ class LoginView(BaseLoginView):
     extra_context = {
         'github_login': bool(settings.SOCIAL_AUTH_GITHUB_KEY),
         'aad_login': bool(settings.SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_TENANT_ID),
+        'okta_oauth_login': bool(settings.SOCIAL_AUTH_OKTA_OAUTH2_KEY),
+        'okta_openidconnect_login': bool(settings.SOCIAL_AUTH_OKTA_OPENIDCONNECT_KEY),
         'allow_signup': bool(settings.ALLOW_SIGNUP),
     }
 


Test patch:
diff --git a/app/server/tests/cassettes/TestOktaOAuth2SocialAuth.test_fetch_permissions_is_admin.yaml b/app/server/tests/cassettes/TestOktaOAuth2SocialAuth.test_fetch_permissions_is_admin.yaml
new file mode 100644
--- /dev/null
+++ b/app/server/tests/cassettes/TestOktaOAuth2SocialAuth.test_fetch_permissions_is_admin.yaml
@@ -0,0 +1,38 @@
+interactions:
+- request:
+    body: ""
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '54'
+      Content-Type:
+      - application/json
+      User-Agent:
+      - python-requests/2.21.0
+    method: POST
+    uri: https://dev-000000.okta.com/oauth2/v1/userinfo
+  response:
+    body:
+      string: '{"sub":"agaga42hrey546","groups":["admin-group"]}'
+    headers:
+      Cache-Control:
+      - no-cache, no-store
+      Content-Type:
+      - application/json;charset=UTF-8
+      Date:
+      - Fri, 24 Apr 2020 02:54:39 GMT
+      Strict-Transport-Security:
+      - max-age=315360000
+      Transfer-Encoding:
+      - chunked
+      Vary:
+      - Accept-Encoding
+    status:
+      code: 200
+      message: OK
+version: 1
diff --git a/app/server/tests/cassettes/TestOktaOAuth2SocialAuth.test_fetch_permissions_not_admin.yaml b/app/server/tests/cassettes/TestOktaOAuth2SocialAuth.test_fetch_permissions_not_admin.yaml
new file mode 100644
--- /dev/null
+++ b/app/server/tests/cassettes/TestOktaOAuth2SocialAuth.test_fetch_permissions_not_admin.yaml
@@ -0,0 +1,38 @@
+interactions:
+- request:
+    body: ""
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '54'
+      Content-Type:
+      - application/json
+      User-Agent:
+      - python-requests/2.21.0
+    method: POST
+    uri: https://dev-000000.okta.com/oauth2/v1/userinfo
+  response:
+    body:
+      string: '{"sub":"agaga42hrey546","groups":["user-group"]}'
+    headers:
+      Cache-Control:
+      - no-cache, no-store
+      Content-Type:
+      - application/json;charset=UTF-8
+      Date:
+      - Fri, 24 Apr 2020 02:54:39 GMT
+      Strict-Transport-Security:
+      - max-age=315360000
+      Transfer-Encoding:
+      - chunked
+      Vary:
+      - Accept-Encoding
+    status:
+      code: 200
+      message: OK
+version: 1
diff --git a/app/server/tests/cassettes/TestOktaOpenIdConnectSocialAuth.test_fetch_permissions_is_admin.yaml b/app/server/tests/cassettes/TestOktaOpenIdConnectSocialAuth.test_fetch_permissions_is_admin.yaml
new file mode 100644
--- /dev/null
+++ b/app/server/tests/cassettes/TestOktaOpenIdConnectSocialAuth.test_fetch_permissions_is_admin.yaml
@@ -0,0 +1,38 @@
+interactions:
+- request:
+    body: ""
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '54'
+      Content-Type:
+      - application/json
+      User-Agent:
+      - python-requests/2.21.0
+    method: POST
+    uri: https://dev-000000.okta.com/oauth2/v1/userinfo
+  response:
+    body:
+      string: '{"sub":"agaga42hrey546","groups":["admin-group"]}'
+    headers:
+      Cache-Control:
+      - no-cache, no-store
+      Content-Type:
+      - application/json;charset=UTF-8
+      Date:
+      - Fri, 24 Apr 2020 02:54:39 GMT
+      Strict-Transport-Security:
+      - max-age=315360000
+      Transfer-Encoding:
+      - chunked
+      Vary:
+      - Accept-Encoding
+    status:
+      code: 200
+      message: OK
+version: 1
diff --git a/app/server/tests/cassettes/TestOktaOpenIdConnectSocialAuth.test_fetch_permissions_not_admin.yaml b/app/server/tests/cassettes/TestOktaOpenIdConnectSocialAuth.test_fetch_permissions_not_admin.yaml
new file mode 100644
--- /dev/null
+++ b/app/server/tests/cassettes/TestOktaOpenIdConnectSocialAuth.test_fetch_permissions_not_admin.yaml
@@ -0,0 +1,38 @@
+interactions:
+- request:
+    body: ""
+    headers:
+      Accept:
+      - '*/*'
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '54'
+      Content-Type:
+      - application/json
+      User-Agent:
+      - python-requests/2.21.0
+    method: POST
+    uri: https://dev-000000.okta.com/oauth2/v1/userinfo
+  response:
+    body:
+      string: '{"sub":"agaga42hrey546","groups":["user-group"]}'
+    headers:
+      Cache-Control:
+      - no-cache, no-store
+      Content-Type:
+      - application/json;charset=UTF-8
+      Date:
+      - Fri, 24 Apr 2020 02:54:39 GMT
+      Strict-Transport-Security:
+      - max-age=315360000
+      Transfer-Encoding:
+      - chunked
+      Vary:
+      - Accept-Encoding
+    status:
+      code: 200
+      message: OK
+version: 1
diff --git a/app/server/tests/test_social_auth.py b/app/server/tests/test_social_auth.py
--- a/app/server/tests/test_social_auth.py
+++ b/app/server/tests/test_social_auth.py
@@ -2,6 +2,8 @@
 from django.test import TestCase, override_settings
 from social_core.backends.azuread_tenant import AzureADTenantOAuth2
 from social_core.backends.github import GithubOAuth2
+from social_core.backends.okta import OktaOAuth2
+from social_core.backends.okta_openidconnect import OktaOpenIdConnect
 from vcr_unittest import VCRMixin
 
 from .. import social_auth
@@ -93,3 +95,75 @@ def test_fetch_permissions_not_admin(self):
         )
 
         self.assertFalse(user.is_superuser)
+
+
+@override_settings(SOCIAL_AUTH_OKTA_OAUTH2_KEY='0000000000aaaaaaaaaa')  # nosec
+@override_settings(SOCIAL_AUTH_OKTA_OAUTH2_SECRET='bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb=')  # nosec
+@override_settings(SOCIAL_AUTH_OKTA_OAUTH2_API_URL='https://dev-000000.okta.com/oauth2')  # nosec
+@override_settings(OKTA_OAUTH2_ADMIN_GROUP_NAME='admin-group')
+class TestOktaOAuth2SocialAuth(VCRTestCase):
+    strategy = None
+    backend = OktaOAuth2(strategy=strategy)
+    access_token = 'censored'
+
+    def test_fetch_permissions_is_admin(self):
+        user = User()
+
+        social_auth.fetch_okta_oauth2_permissions(
+            strategy=self.strategy,
+            details={},
+            user=user,
+            backend=self.backend,
+            response={'access_token': self.access_token},
+        )
+
+        self.assertTrue(user.is_superuser)
+
+    def test_fetch_permissions_not_admin(self):
+        user = User()
+
+        social_auth.fetch_okta_oauth2_permissions(
+            strategy=self.strategy,
+            details={},
+            user=user,
+            backend=self.backend,
+            response={'access_token': self.access_token},
+        )
+
+        self.assertFalse(user.is_superuser)
+
+
+@override_settings(SOCIAL_AUTH_OKTA_OPENIDCONNECT_KEY='0000000000aaaaaaaaaa')  # nosec
+@override_settings(SOCIAL_AUTH_OKTA_OPENIDCONNECT_SECRET='bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb=')  # nosec
+@override_settings(SOCIAL_AUTH_OKTA_OPENIDCONNECT_API_URL='https://dev-000000.okta.com/oauth2')  # nosec
+@override_settings(OKTA_OPENIDCONNECT_ADMIN_GROUP_NAME='admin-group')
+class TestOktaOpenIdConnectSocialAuth(VCRTestCase):
+    strategy = None
+    backend = OktaOpenIdConnect(strategy=strategy)
+    access_token = 'censored'
+
+    def test_fetch_permissions_is_admin(self):
+        user = User()
+
+        social_auth.fetch_okta_openidconnect_permissions(
+            strategy=self.strategy,
+            details={},
+            user=user,
+            backend=self.backend,
+            response={'access_token': self.access_token},
+        )
+
+        self.assertTrue(user.is_superuser)
+
+    def test_fetch_permissions_not_admin(self):
+        user = User()
+
+        social_auth.fetch_okta_openidconnect_permissions(
+            strategy=self.strategy,
+            details={},
+            user=user,
+            backend=self.backend,
+            response={'access_token': self.access_token},
+        )
+
+        self.assertFalse(user.is_superuser)