Repository: goauthentik/authentik

Issue:
Twitter OAuth Server Error
**Describe the bug**
When you click the twitter icon to login via Twitter it gives 'server error'

**To Reproduce**
Steps to reproduce the behavior:
1. Setup twitter with OAuth Keys
2. Click on twitter icon on login page
3. Get "Server Error" at url https://weddingtonpeeps.org/source/oauth/login/twitter/

**Expected behavior**
Functioning Twitter login

**Screenshots**

**Logs**
```
authentik {"event": "Internal Server Error: /source/oauth/login/twitter/", "exc_info": ["< │
│ class 'authentik.sources.oauth.exceptions.OAuthSourceException'>", "OAuthSourceException() │
│ ", "<traceback object at 0x7f6190a7c040>"], "level": "error", "logger": "django.request",  │
│ "timestamp": 1652776815.564639}
```

**Version and Deployment (please complete the following information):**
 - authentik version: 2022.4.1
 - Deployment: helm

**Additional context**




Discussion:

Resolving patch:
diff --git a/authentik/sources/oauth/clients/oauth2.py b/authentik/sources/oauth/clients/oauth2.py
--- a/authentik/sources/oauth/clients/oauth2.py
+++ b/authentik/sources/oauth/clients/oauth2.py
@@ -11,6 +11,7 @@
 from authentik.sources.oauth.clients.base import BaseOAuthClient
 
 LOGGER = get_logger()
+SESSION_OAUTH_PKCE = "oauth_pkce"
 
 
 class OAuth2Client(BaseOAuthClient):
@@ -69,6 +70,8 @@ def get_access_token(self, **request_kwargs) -> Optional[dict[str, Any]]:
             "code": code,
             "grant_type": "authorization_code",
         }
+        if SESSION_OAUTH_PKCE in self.request.session:
+            args["code_verifier"] = self.request.session[SESSION_OAUTH_PKCE]
         try:
             access_token_url = self.source.type.access_token_url or ""
             if self.source.type.urls_customizable and self.source.access_token_url:
diff --git a/authentik/sources/oauth/types/twitter.py b/authentik/sources/oauth/types/twitter.py
--- a/authentik/sources/oauth/types/twitter.py
+++ b/authentik/sources/oauth/types/twitter.py
@@ -1,21 +1,46 @@
 """Twitter OAuth Views"""
 from typing import Any
 
+from authentik.lib.generators import generate_id
+from authentik.sources.oauth.clients.oauth2 import SESSION_OAUTH_PKCE
+from authentik.sources.oauth.types.azure_ad import AzureADClient
 from authentik.sources.oauth.types.manager import MANAGER, SourceType
 from authentik.sources.oauth.views.callback import OAuthCallback
+from authentik.sources.oauth.views.redirect import OAuthRedirect
+
+
+class TwitterOAuthRedirect(OAuthRedirect):
+    """Twitter OAuth2 Redirect"""
+
+    def get_additional_parameters(self, source):  # pragma: no cover
+        self.request.session[SESSION_OAUTH_PKCE] = generate_id()
+        return {
+            "scope": ["users.read", "tweet.read"],
+            "code_challenge": self.request.session[SESSION_OAUTH_PKCE],
+            "code_challenge_method": "plain",
+        }
 
 
 class TwitterOAuthCallback(OAuthCallback):
     """Twitter OAuth2 Callback"""
 
+    # Twitter has the same quirk as azure and throws an error if the access token
+    # is set via query parameter, so we re-use the azure client
+    # see https://github.com/goauthentik/authentik/issues/1910
+    client_class = AzureADClient
+
+    def get_user_id(self, info: dict[str, str]) -> str:
+        return info.get("data", {}).get("id", "")
+
     def get_user_enroll_context(
         self,
         info: dict[str, Any],
     ) -> dict[str, Any]:
+        data = info.get("data", {})
         return {
-            "username": info.get("screen_name"),
-            "email": info.get("email", None),
-            "name": info.get("name"),
+            "username": data.get("username"),
+            "email": None,
+            "name": data.get("name"),
         }
 
 
@@ -24,10 +49,10 @@ class TwitterType(SourceType):
     """Twitter Type definition"""
 
     callback_view = TwitterOAuthCallback
+    redirect_view = TwitterOAuthRedirect
     name = "Twitter"
     slug = "twitter"
 
-    request_token_url = "https://api.twitter.com/oauth/request_token"  # nosec
-    authorization_url = "https://api.twitter.com/oauth/authenticate"
-    access_token_url = "https://api.twitter.com/oauth/access_token"  # nosec
-    profile_url = "https://api.twitter.com/1.1/account/verify_credentials.json?include_email=true"
+    authorization_url = "https://twitter.com/i/oauth2/authorize"
+    access_token_url = "https://api.twitter.com/2/oauth2/token"  # nosec
+    profile_url = "https://api.twitter.com/2/users/me"


Test patch:
diff --git a/authentik/sources/oauth/tests/test_type_twitter.py b/authentik/sources/oauth/tests/test_type_twitter.py
--- a/authentik/sources/oauth/tests/test_type_twitter.py
+++ b/authentik/sources/oauth/tests/test_type_twitter.py
@@ -4,88 +4,8 @@
 from authentik.sources.oauth.models import OAuthSource
 from authentik.sources.oauth.types.twitter import TwitterOAuthCallback
 
-# https://developer.twitter.com/en/docs/twitter-api/v1/accounts-and-users/manage-account-settings/ \
-# api-reference/get-account-verify_credentials
-TWITTER_USER = {
-    "contributors_enabled": True,
-    "created_at": "Sat May 09 17:58:22 +0000 2009",
-    "default_profile": False,
-    "default_profile_image": False,
-    "description": "I taught your phone that thing you like.",
-    "favourites_count": 588,
-    "follow_request_sent": None,
-    "followers_count": 10625,
-    "following": None,
-    "friends_count": 1181,
-    "geo_enabled": True,
-    "id": 38895958,
-    "id_str": "38895958",
-    "is_translator": False,
-    "lang": "en",
-    "listed_count": 190,
-    "location": "San Francisco",
-    "name": "Sean Cook",
-    "notifications": None,
-    "profile_background_color": "1A1B1F",
-    "profile_background_image_url": "",
-    "profile_background_image_url_https": "",
-    "profile_background_tile": True,
-    "profile_image_url": "",
-    "profile_image_url_https": "",
-    "profile_link_color": "2FC2EF",
-    "profile_sidebar_border_color": "181A1E",
-    "profile_sidebar_fill_color": "252429",
-    "profile_text_color": "666666",
-    "profile_use_background_image": True,
-    "protected": False,
-    "screen_name": "theSeanCook",
-    "show_all_inline_media": True,
-    "status": {
-        "contributors": None,
-        "coordinates": {"coordinates": [-122.45037293, 37.76484123], "type": "Point"},
-        "created_at": "Tue Aug 28 05:44:24 +0000 2012",
-        "favorited": False,
-        "geo": {"coordinates": [37.76484123, -122.45037293], "type": "Point"},
-        "id": 240323931419062272,
-        "id_str": "240323931419062272",
-        "in_reply_to_screen_name": "messl",
-        "in_reply_to_status_id": 240316959173009410,
-        "in_reply_to_status_id_str": "240316959173009410",
-        "in_reply_to_user_id": 18707866,
-        "in_reply_to_user_id_str": "18707866",
-        "place": {
-            "attributes": {},
-            "bounding_box": {
-                "coordinates": [
-                    [
-                        [-122.45778216, 37.75932999],
-                        [-122.44248216, 37.75932999],
-                        [-122.44248216, 37.76752899],
-                        [-122.45778216, 37.76752899],
-                    ]
-                ],
-                "type": "Polygon",
-            },
-            "country": "United States",
-            "country_code": "US",
-            "full_name": "Ashbury Heights, San Francisco",
-            "id": "866269c983527d5a",
-            "name": "Ashbury Heights",
-            "place_type": "neighborhood",
-            "url": "http://api.twitter.com/1/geo/id/866269c983527d5a.json",
-        },
-        "retweet_count": 0,
-        "retweeted": False,
-        "source": "Twitter for  iPhone",
-        "text": "@messl congrats! So happy for all 3 of you.",
-        "truncated": False,
-    },
-    "statuses_count": 2609,
-    "time_zone": "Pacific Time (US & Canada)",
-    "url": None,
-    "utc_offset": -28800,
-    "verified": False,
-}
+# https://developer.twitter.com/en/docs/twitter-api/users/lookup/api-reference/get-users-me
+TWITTER_USER = {"data": {"id": "2244994945", "name": "TwitterDev", "username": "Twitter Dev"}}
 
 
 class TestTypeGitHub(TestCase):
@@ -104,6 +24,6 @@ def setUp(self):
     def test_enroll_context(self):
         """Test Twitter Enrollment context"""
         ak_context = TwitterOAuthCallback().get_user_enroll_context(TWITTER_USER)
-        self.assertEqual(ak_context["username"], TWITTER_USER["screen_name"])
-        self.assertEqual(ak_context["email"], TWITTER_USER.get("email", None))
-        self.assertEqual(ak_context["name"], TWITTER_USER["name"])
+        self.assertEqual(ak_context["username"], TWITTER_USER["data"]["username"])
+        self.assertEqual(ak_context["email"], None)
+        self.assertEqual(ak_context["name"], TWITTER_USER["data"]["name"])
diff --git a/tests/e2e/test_source_oauth.py b/tests/e2e/test_source_oauth.py
--- a/tests/e2e/test_source_oauth.py
+++ b/tests/e2e/test_source_oauth.py
@@ -4,7 +4,6 @@
 from time import sleep
 from typing import Any, Optional
 from unittest.case import skipUnless
-from unittest.mock import Mock, patch
 
 from docker.models.containers import Container
 from docker.types import Healthcheck
@@ -18,20 +17,38 @@
 from authentik.flows.models import Flow
 from authentik.lib.generators import generate_id, generate_key
 from authentik.sources.oauth.models import OAuthSource
-from authentik.sources.oauth.types.manager import SourceType
-from authentik.sources.oauth.types.twitter import TwitterOAuthCallback
+from authentik.sources.oauth.types.manager import MANAGER, SourceType
+from authentik.sources.oauth.views.callback import OAuthCallback
 from authentik.stages.identification.models import IdentificationStage
 from tests.e2e.utils import SeleniumTestCase, apply_migration, object_manager, retry
 
 CONFIG_PATH = "/tmp/dex.yml"  # nosec
 
 
+class OAUth1Callback(OAuthCallback):
+    """OAuth1 Callback with custom getters"""
+
+    def get_user_id(self, info: dict[str, str]) -> str:
+        return info.get("id")
+
+    def get_user_enroll_context(
+        self,
+        info: dict[str, Any],
+    ) -> dict[str, Any]:
+        return {
+            "username": info.get("screen_name"),
+            "email": info.get("email"),
+            "name": info.get("name"),
+        }
+
+
+@MANAGER.type()
 class OAUth1Type(SourceType):
-    """Twitter Type definition"""
+    """OAuth1 Type definition"""
 
-    callback_view = TwitterOAuthCallback
-    name = "Twitter"
-    slug = "twitter"
+    callback_view = OAUth1Callback
+    name = "OAuth1"
+    slug = "oauth1"
 
     request_token_url = "http://localhost:5000/oauth/request_token"  # nosec
     access_token_url = "http://localhost:5000/oauth/access_token"  # nosec
@@ -40,9 +57,6 @@ class OAUth1Type(SourceType):
     urls_customizable = False
 
 
-SOURCE_TYPE_MOCK = Mock(return_value=OAUth1Type)
-
-
 @skipUnless(platform.startswith("linux"), "requires local docker")
 class TestSourceOAuth2(SeleniumTestCase):
     """test OAuth Source flow"""
@@ -256,7 +270,7 @@ def create_objects(self):
             slug=self.source_slug,
             authentication_flow=authentication_flow,
             enrollment_flow=enrollment_flow,
-            provider_type="twitter",
+            provider_type="oauth1",
             consumer_key=self.client_id,
             consumer_secret=self.client_secret,
         )
@@ -269,10 +283,6 @@ def create_objects(self):
     @apply_migration("authentik_flows", "0011_flow_title")
     @apply_migration("authentik_flows", "0009_source_flows")
     @apply_migration("authentik_crypto", "0002_create_self_signed_kp")
-    @patch(
-        "authentik.sources.oauth.types.manager.SourceTypeManager.find_type",
-        SOURCE_TYPE_MOCK,
-    )
     @object_manager
     def test_oauth_enroll(self):
         """test OAuth Source With With OIDC"""
diff --git a/tests/e2e/utils.py b/tests/e2e/utils.py
--- a/tests/e2e/utils.py
+++ b/tests/e2e/utils.py
@@ -30,7 +30,7 @@
 from authentik.core.tests.utils import create_test_admin_user
 from authentik.managed.manager import ObjectManager
 
-RETRIES = int(environ.get("RETRIES", "5"))
+RETRIES = int(environ.get("RETRIES", "3"))
 
 
 def get_docker_tag() -> str: