CVE-2023-48228 (published 2023-11-21T21:15Z)

authentik is an open-source identity provider. When initialising a oauth2 flow with a `code_challenge` and `code_method` (thus requesting PKCE), the single sign-on provider (authentik) must check if there is a matching and existing `code_verifier` during the token step. Prior to versions 2023.10.4 and 2023.8.5, authentik checks if the contents of `code_verifier` is matching only when it is provided. When it is left out completely, authentik simply accepts the token request with out it; even when the flow was started with a `code_challenge`. authentik 2023.8.5 and 2023.10.4 fix this issue.

CVSS v3 base score: 9.8, CRITICAL (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)

Weakness information:
cwe_id: CWE-287
cwe_name: Improper Authentication
description: When an actor claims to have a given identity, the product does not prove or insufficiently proves that the claim is correct.
extended_description: 
            
			
         
         

Fix information:
repo_url: https://github.com/goauthentik/authentik
commit_details: repo_url: https://github.com/goauthentik/authentik
author: Jens L
author_date: 2023-11-21 18:10:07+01:00
msg: security: fix CVE-2023-48228 (#7666)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
repository: repo_url: https://github.com/goauthentik/authentik
repo_name: goauthentik/authentik
description: The authentication glue you need.
repo_language: Python
owner: goauthentik
file_changes: filename: test_token_pkce.py
change_type: ModificationType.ADD
diff: @@ -0,0 +1,187 @@
+"""Test token view"""
+from base64 import b64encode, urlsafe_b64encode
+from hashlib import sha256
+
+from django.test import RequestFactory
+from django.urls import reverse
+
+from authentik.core.models import Application
+from authentik.core.tests.utils import create_test_admin_user, create_test_flow
+from authentik.flows.challenge import ChallengeTypes
+from authentik.lib.generators import generate_id
+from authentik.providers.oauth2.constants import GRANT_TYPE_AUTHORIZATION_CODE
+from authentik.providers.oauth2.models import AuthorizationCode, OAuth2Provider
+from authentik.providers.oauth2.tests.utils import OAuthTestCase
+
+
+class TestTokenPKCE(OAuthTestCase):
+    """Test token view"""
+
+    def setUp(self) -> None:
+        super().setUp()
+        self.factory = RequestFactory()
+        self.app = Application.objects.create(name=generate_id(), slug="test")
+
+    def test_pkce_missing_in_token(self):
+        """Test full with pkce"""
+        flow = create_test_flow()
+        provider = OAuth2Provider.objects.create(
+            name=generate_id(),
+            client_id="test",
+            authorization_flow=flow,
+            redirect_uris="foo://localhost",
+            access_code_validity="seconds=100",
+        )
+        Application.objects.create(name="app", slug="app", provider=provider)
+        state = generate_id()
+        user = create_test_admin_user()
+        self.client.force_login(user)
+        challenge = generate_id()
+        header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
+        # Step 1, initiate params and get redirect to flow
+        self.client.get(
+            reverse("authentik_providers_oauth2:authorize"),
+            data={
+                "response_type": "code",
+                "client_id": "test",
+                "state": state,
+                "redirect_uri": "foo://localhost",
+                "code_challenge": challenge,
+                "code_challenge_method": "S256",
+            },
+        )
+        response = self.client.get(
+            reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
+        )
+        code: AuthorizationCode = AuthorizationCode.objects.filter(user=user).first()
+        self.assertJSONEqual(
+            response.content.decode(),
+            {
+                "component": "xak-flow-redirect",
+                "type": ChallengeTypes.REDIRECT.value,
+                "to": f"foo://localhost?code={code.code}&state={state}",
+            },
+        )
+        response = self.client.post(
+            reverse("authentik_providers_oauth2:token"),
+            data={
+                "grant_type": GRANT_TYPE_AUTHORIZATION_CODE,
+                "code": code.code,
+                # Missing the code_verifier here
+                "redirect_uri": "foo://localhost",
+            },
+            HTTP_AUTHORIZATION=f"Basic {header}",
+        )
+        self.assertJSONEqual(
+            response.content,
+            {"error": "invalid_request", "error_description": "The request is otherwise malformed"},
+        )
+        self.assertEqual(response.status_code, 400)
+
+    def test_pkce_correct_s256(self):
+        """Test full with pkce"""
+        flow = create_test_flow()
+        provider = OAuth2Provider.objects.create(
+            name=generate_id(),
+            client_id="test",
+            authorization_flow=flow,
+            redirect_uris="foo://localhost",
+            access_code_validity="seconds=100",
+        )
+        Application.objects.create(name="app", slug="app", provider=provider)
+        state = generate_id()
+        user = create_test_admin_user()
+        self.client.force_login(user)
+        verifier = generate_id()
+        challenge = (
+            urlsafe_b64encode(sha256(verifier.encode("ascii")).digest())
+            .decode("utf-8")
+            .replace("=", "")
+        )
+        header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
+        # Step 1, initiate params and get redirect to flow
+        self.client.get(
+            reverse("authentik_providers_oauth2:authorize"),
+            data={
+                "response_type": "code",
+                "client_id": "test",
+                "state": state,
+                "redirect_uri": "foo://localhost",
+                "code_challenge": challenge,
+                "code_challenge_method": "S256",
+            },
+        )
+        response = self.client.get(
+            reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
+        )
+        code: AuthorizationCode = AuthorizationCode.objects.filter(user=user).first()
+        self.assertJSONEqual(
+            response.content.decode(),
+            {
+                "component": "xak-flow-redirect",
+                "type": ChallengeTypes.REDIRECT.value,
+                "to": f"foo://localhost?code={code.code}&state={state}",
+            },
+        )
+        response = self.client.post(
+            reverse("authentik_providers_oauth2:token"),
+            data={
+                "grant_type": GRANT_TYPE_AUTHORIZATION_CODE,
+                "code": code.code,
+                "code_verifier": verifier,
+                "redirect_uri": "foo://localhost",
+            },
+            HTTP_AUTHORIZATION=f"Basic {header}",
+        )
+        self.assertEqual(response.status_code, 200)
+
+    def test_pkce_correct_plain(self):
+        """Test full with pkce"""
+        flow = create_test_flow()
+        provider = OAuth2Provider.objects.create(
+            name=generate_id(),
+            client_id="test",
+            authorization_flow=flow,
+            redirect_uris="foo://localhost",
+            access_code_validity="seconds=100",
+        )
+        Application.objects.create(name="app", slug="app", provider=provider)
+        state = generate_id()
+        user = create_test_admin_user()
+        self.client.force_login(user)
+        verifier = generate_id()
+        header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
+        # Step 1, initiate params and get redirect to flow
+        self.client.get(
+            reverse("authentik_providers_oauth2:authorize"),
+            data={
+                "response_type": "code",
+                "client_id": "test",
+                "state": state,
+                "redirect_uri": "foo://localhost",
+                "code_challenge": verifier,
+            },
+        )
+        response = self.client.get(
+            reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
+        )
+        code: AuthorizationCode = AuthorizationCode.objects.filter(user=user).first()
+        self.assertJSONEqual(
+            response.content.decode(),
+            {
+                "component": "xak-flow-redirect",
+                "type": ChallengeTypes.REDIRECT.value,
+                "to": f"foo://localhost?code={code.code}&state={state}",
+            },
+        )
+        response = self.client.post(
+            reverse("authentik_providers_oauth2:token"),
+            data={
+                "grant_type": GRANT_TYPE_AUTHORIZATION_CODE,
+                "code": code.code,
+                "code_verifier": verifier,
+                "redirect_uri": "foo://localhost",
+            },
+            HTTP_AUTHORIZATION=f"Basic {header}",
+        )
+        self.assertEqual(response.status_code, 200)

programming_language: Python
filename: token.py
change_type: ModificationType.MODIFY
diff: @@ -222,7 +222,10 @@ class TokenParams:
             raise TokenError("invalid_grant")
 
         # Validate PKCE parameters.
-        if self.code_verifier:
+        if self.authorization_code.code_challenge:
+            # Authorization code had PKCE but we didn't get one
+            if not self.code_verifier:
+                raise TokenError("invalid_request")
             if self.authorization_code.code_challenge_method == PKCE_METHOD_S256:
                 new_code_challenge = (
                     urlsafe_b64encode(sha256(self.code_verifier.encode("ascii")).digest())

programming_language: Python
filename: CVE-2023-48228.md
change_type: ModificationType.ADD
diff: @@ -0,0 +1,61 @@
+# CVE-2023-48228
+
+_Reported by [@Sapd](https://github.com/Sapd)_
+
+## OAuth2: Insufficient PKCE check
+
+### Summary
+
+When initialising a OAuth2 flow with a `code_challenge` and `code_method` (thus requesting PKCE), the SSO provider (authentik) **must** check if there is a matching **and** existing `code_verifier` during the token step.
+
+authentik checks if the contents of code*verifier is matching \*\*\_ONLY*\*\* when it is provided. When it is left out completely, authentik simply accepts the token request with out it; even when the flow was started with a `code_challenge`.
+
+### Patches
+
+authentik 2023.8.5 and 2023.10.4 fix this issue.
+
+### Details
+
+The `code_verifier` is only checked when the user provides it. Note that in line 209 there is a check if the code_parameter is left out. But there is no check if the PKCE parameter simply was omitted WHEN the request was started with a `code_challenge_method`.
+
+This oversight likely did not stem from a coding error but from a misinterpretation of the RFC, where the backward compatibility section may be somewhat confusing.
+https://datatracker.ietf.org/doc/html/rfc7636#section-4.5
+RFC7636 explicitly says in Section 4.5:
+
+> The "code_challenge_method" is bound to the Authorization Code when
+> the Authorization Code is issued. That is the method that the token
+> endpoint MUST use to verify the "code_verifier".
+
+Section 5, Compatibility
+
+> Server implementations of this specification MAY accept OAuth2.0
+> clients that do not implement this extension. If the "code_verifier"
+> is not received from the client in the Authorization Request, servers
+> supporting backwards compatibility revert to the OAuth 2.0 [[RFC6749](https://datatracker.ietf.org/doc/html/rfc6749)]
+> protocol without this extension.
+
+Section 5, Compatibility, allows server implementations of this specification to accept OAuth 2.0 clients that do not implement this extension. However, if a `code_verifier` is not received from the client in the Authorization Request, servers that support backward compatibility should revert to the standard OAuth 2.0 protocol sans this extension (including all steps).
+
+It should be noted that this does not mean that the `code_verifier` check can be disregarded at any point if the initial request included `code_challenge` or `code_challenge_method`. Since Authentik supports PKCE, it **MUST** verify the code_verifier as described in Section 4.5 **AND** fail if it was not provided.
+
+Ofc verification can be skipped if the original authorization request did not invoke PKCE (no `code_challenge_method` and no `code_challenge`).
+
+Failure to check the `code_verifier` renders the PKCE flow ineffective. This vulnerability particularly endangers public or hybrid clients, as their `code` is deemed non-confidential.
+
+While not explicitly stated in the standard, it is generally recommended that OAuth2 flows accepting public clients should enforce PKCE - at least when redirecting to a non HTTPS URL (like http or an app link).
+
+### Impact
+
+The vulnerability poses a high risk to both public and hybrid clients.
+When for example a mobile app implements oauth2, a malicious app can simply also register the same in-app-link (e.g. `mycoolapp://oauth2`) for the redirect callback URL, possibly receiving `code` during callback. With PKCE working, a malicious app would still receive a `code` but the `code` would not work without the correct unhashed code-challenge.
+This is especially problematic, because authentik claims to support PKCE, and a developer can expect that the proper checks are in place. Note that app-links cannot be protected by HTTPS or similar mechanisms.
+
+Note also that this vulnerability poses a threat to confidential clients. Many confidential clients act as a proxy for OAuth2 API requests, typically from mobile apps or single-page applications. These proxies relay `code_challenge`, `code_challenge_method` (in auth request, which most libraries force and provide on default settings) and `code_verifier` in the token request unchanged and supplement the CLIENT_SECRET which only the relay knows. The relay can but does not have to check for an existing `code_verifier` as the standard does not define that PKCE can be ignored on confidential clients during the token request when the client requested PKCE during the authorization request.
+
+An attacker could potentially gain full access to the application. If the code grants access to an admin account, the confidentiality, integrity, and availability of that application are compromised.
+
+### For more information
+
+If you have any questions or comments about this advisory:
+
+-   Email us at [security@goauthentik.io](mailto:security@goauthentik.io)

programming_language: Markdown
filename: sidebars.js
change_type: ModificationType.MODIFY
diff: @@ -407,6 +407,7 @@ const docsSidebar = {
             },
             items: [
                 "security/policy",
+                "security/CVE-2023-48228",
                 "security/GHSA-rjvp-29xq-f62w",
                 "security/CVE-2023-39522",
                 "security/CVE-2023-36456",

programming_language: JavaScript