Repository: jazzband/django-oauth-toolkit

Issue:
Allow Authorization Code flow without a `client_secret`
After enabling PKCE with `PKCE_REQUIRED': True` I'm unable to retrieve an oauth response when submitting a request to `/o/token/ `. It provides a authentication code fine. 

This article has been used as reference: https://www.liip.ch/en/blog/authorization-code-with-pkce-on-django-using-django-oauth-toolkit 

Here is the request (with client_id and code redacted)

<img width="1001" alt="Screenshot 2022-01-19 at 17 04 13" src="https://user-images.githubusercontent.com/13102109/150179141-1b492699-f021-4dbe-a3e5-272c712bd1b6.png">




Discussion:
Have you tried the same with `PKCE_REQUIRED` set to False? What's in your console log?

I haven't looked at the blog post. Generally I test with Postman. Make sure your application has `https://www.getpostman.com/oauth2/callback` as one of the redirect URIs. Postman has `Authorization Code with PKCE` vs `Authorization Code`.

I've just tried with `PKCE_REQUIRED` set to `False` and it worked fine. 

`client_secret` replaced `code_verifier` within the request (see below)

<img width="992" alt="Screenshot 2022-01-19 at 18 17 21" src="https://user-images.githubusercontent.com/13102109/150190497-16972d06-1c2e-46d0-9068-6daab26dad9a.png">

It also triggered my debug breakpoint on `if challenge is not None:` within `oauthlib/oauth2/rfc6749/grant_types/authorization_code.py`, which doesn't get triggered when `PKCE_REQUIRED` is set to `True`. 

I'll try updating postman, although I would still expect it to work with parameters manually set.
Instead of a POST to the endpoint, try using Postman's native OAuth2 auth:

![image](https://user-images.githubusercontent.com/4356471/150191696-36c516c0-9d50-4d9b-aaaf-5a0ae2b68a53.png)

I've upgraded Postman and tried a request to a protected endpoint (previously working without PKCE) using Authorisation Code (With PKCE), resulted in the same issue though. 

<img width="1559" alt="Screenshot 2022-01-19 at 19 25 33" src="https://user-images.githubusercontent.com/13102109/150200172-d9ce6328-e2d6-46aa-9787-d1cf671689bd.png">


Anything in the console? You can set debug-level logging in settings.py something like this:
```
LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,
    'filters': {
        'require_debug_true': {
            '()': 'django.utils.log.RequireDebugTrue',
        }
    },
    'formatters': {
        'verbose': {
        'format': '%(asctime)s %(levelname)s %(name)s.%(funcName)s:%(lineno)d: %(message)s'
        },
        'simple': {
            'format': '%(levelname)s %(message)s'
        },
    },
    'handlers': {
        'console': {
            'level': 'DEBUG',
            'filters': ['require_debug_true'],
            'class': 'logging.StreamHandler',
            'formatter': 'verbose'
        }
    },
    'loggers': {
        'django.db.backends': {
            'level': 'INFO',
            'handlers': ['console'],
        },
        'oauth2_provider': {
            'level': 'DEBUG',
            'handlers': ['console'],
        },
        'oauthlib': {
            'level': 'DEBUG',
            'handlers': ['console'],
        },
        'myapp': {
            'level': 'INFO',
            'handlers': ['console'],
        },
        'oauth': {
            'level': 'DEBUG',
            'handlers': ['console'],
        },
    }
}
```

Thanks, I set the debugging config and (through the log) noticed my client_secret was incorrect, it now works correctly through postman using Authentication tab. 

I've tried replicating the postman process manually but still get invalid_client error (using my old postman installation). 

Unsure what the difference between manual and postman process is but happy to close this issue.
Not being able to rest without figuring out why this wasn't working. I delved into the request logs in Postman and compared them to my own. The difference was that client_secret was left out of my token request, but was used within the request via Postman. Sure enough, if I disable the parameter within my own request it fails again. 

I believe this is incorrect behaviour as Authorisation flow with PKCE shouldn't require the client_secret, this has effectively been replaced with the code_verifier. 
I do not believe that to he the case. Do you have an RFC document reference that says that? PKCE supplements, not replaces use of client_secret.  However one can have a null client_secret now. Not sure if DOT implements that correctly, but that would be a different problem. This was the top hit on a google search and is on the Internet so it must be true ;-)

https://www.scottbrady91.com/oauth/client-authentication-vs-pkce#:~:text=PKCE%20is%20not%20a%20replacement,token%20on%20a%20login%20page.

Section [4.3] of RFC7636 (https://datatracker.ietf.org/doc/html/rfc7636#page-9) references [Section 4.1.1 of RFC6749](https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1) which doesn't include the client_secret in the request. 

Interestingly I found a similar issue reported within Postman 
https://github.com/postmanlabs/postman-app-support/issues/9409

There does seem to be a bit of confusion about this online, I can't imagine sending the client secret would have any negative affects other than being unnecessary. 
Hey @R70YNS, Thanks for the research. I'm going to rename this issue to something about `client_secret` not being sent.
I know that in at least one commercial OAuth2 server there is an explicit configuration option of either having no client_secret or having a value for it. We use this with Authorization Code flow with PKCE for our SPA apps that formerly used Implicit flow.

I suspect that this may not be implemented in DOT
No problem @n2ygk. I think the name change is better too, it more accurately describes the problem. I do think that those who can secure the `client_secret` may as well continue to send it as an added layer of protection. This issue only really affects those who can't secure the client secret.

For a workaround I will use a null value for the client_secret as you mentioned above.
> For a workaround I will use a null value for the client_secret as you mentioned above.

I'm curious to know if that will actually work. Looking forward to your report.
I can confirm that attempting to retrieve a new oAuth token whilst sending a blank string (empty field in Postman) still returns a 401. 

<img width="719" alt="Screenshot 2022-01-25 at 21 15 52" src="https://user-images.githubusercontent.com/13102109/151074931-5cec7d16-da9e-4d08-8095-993ce22d82d1.png">



Are you able to test with leaving the `client_secret` out of the request body?
@n2ygk I can confirm it returns: 

`
{
    "error": "invalid_client"
}
`
A PR implementing this would be gladly accepted!
Hello @n2ygk 
Can I take this issue up please? Also Is there any place like IRC or other forms of contact where i can reach out?
Sure. No IRC. Just issue comments. 
Hey @n2ygk 

I did some looking around i think I've narrowed down the problem
The error seems to arise from '_def _authenticate_request_body_' of [oauth2_validators.py](https://github.com/jazzband/django-oauth-toolkit/blob/master/oauth2_provider/oauth2_validators.py#L161) file:

Original Code below:

```
    def _authenticate_request_body(self, request):
        """
        Try to authenticate the client using client_id and client_secret
        parameters included in body.

        Remember that this method is NOT RECOMMENDED and SHOULD be limited to
        clients unable to directly utilize the HTTP Basic authentication scheme.
        See rfc:'2.3.1' for more details.
        """
        # TODO: check if oauthlib has already unquoted client_id and client_secret
        try:
            client_id = request.client_id
            client_secret = request.client_secret
        except AttributeError:
            return False

        if self._load_application(client_id, request) is None:
            log.debug("Failed body auth: Application %s does not exists" % client_id)
            return False
        elif not check_password(client_secret, request.client.client_secret):
            log.debug("Failed body auth: wrong client secret %s" % client_secret)
            return False
        else:
            return True
```

By commenting out the _elif_ part and the '_client_secret_' assignment, Ive been able to get a token without problems.


```
    def _authenticate_request_body(self, request):
        """
        Try to authenticate the client using client_id and client_secret
        parameters included in body.

        Remember that this method is NOT RECOMMENDED and SHOULD be limited to
        clients unable to directly utilize the HTTP Basic authentication scheme.
        See rfc:'2.3.1' for more details.
        """
        # TODO: check if oauthlib has already unquoted client_id and client_secret
        try:
            client_id = request.client_id
            # client_secret = request.client_secret
        except AttributeError:
            return False

        if self._load_application(client_id, request) is None:
            log.debug("Failed body auth: Application %s does not exists" % client_id)
            return False
        # elif not check_password(client_secret, request.client.client_secret):
        #     log.debug("Failed body auth: wrong client secret %s" % client_secret)
        #    return False
        else:
            return True
```

Please let me know if there are other things i should be taking care of while addressing this issue.
Also is it better to comment the code or to remove it entirely if the above findings is the only change required
@bull500 commenting out the `client_secret` means the auth check will succeed even when there is a required `client_secret` without comparing it. I believe instead you want to check to see when the `client_secret` query parameter is missing or has an empty value whether the `application.client_secret` is the empty string. This should align with https://www.rfc-editor.org/rfc/rfc6749.html#section-2.3.1 where it says:

REQUIRED.  The client secret.  **The client MAY omit the
parameter if the client secret is an empty string.**





hey @n2ygk 
Thank you for the clarity!

I've added a simple if-else check to the client secret assignment.
I tested it with 2 apps, one with auto generated client secret and one with a empty client secret,  while passing and omitting the client_secret value during POST and it seems to be working fine


Please let me know your thought and if this is good for PR


```
    def _authenticate_request_body(self, request):
        """
        Try to authenticate the client using client_id and client_secret
        parameters included in body.

        Remember that this method is NOT RECOMMENDED and SHOULD be limited to
        clients unable to directly utilize the HTTP Basic authentication scheme.
        See rfc:'2.3.1' for more details.
        """
        # TODO: check if oauthlib has already unquoted client_id and client_secret
        try:
            client_id = request.client_id
            client_secret = request.client_secret if request.client_secret else ''
        except AttributeError:
            return False

        if self._load_application(client_id, request) is None:
            log.debug("Failed body auth: Application %s does not exists" % client_id)
            return False
        elif not check_password(client_secret, request.client.client_secret):
            log.debug("Failed body auth: wrong client secret %s" % client_secret)
            return False
        else:
            return True
```
Add some test cases and should be good to go.
Hello @n2ygk 


I've made a few changes and included test cases:

In [oauth2_validators.py](https://github.com/jazzband/django-oauth-toolkit/blob/master/oauth2_provider/oauth2_validators.py#L161) used _hasattr()_ to check object property exists

```
    def _authenticate_request_body(self, request):
        """
        Try to authenticate the client using client_id and client_secret
        parameters included in body.

        Remember that this method is NOT RECOMMENDED and SHOULD be limited to
        clients unable to directly utilize the HTTP Basic authentication scheme.
        See rfc:`2.3.1` for more details.
        """
        # TODO: check if oauthlib has already unquoted client_id and client_secret
        try:
            client_id = request.client_id
            client_secret = request.client_secret if hasattr(request, "client_secret") else ""
        except AttributeError:
            return False

        if self._load_application(client_id, request) is None:
            log.debug("Failed body auth: Application %s does not exists" % client_id)
            return False
        elif not check_password(client_secret, request.client.client_secret):
            log.debug("Failed body auth: wrong client secret %s" % client_secret)
            return False
        else:
            return True
```


With respect to testcases, i edited the _test_oauth2_validators.py_ file.
I've added a separate _def setUp(self)_ portion to create an app with blank client secret
Also made changes and addition  to the _def test_authenticate_request_body(self)_ method
I ran tox and the outputs seem fine
Im not sure if my setup is correct on testing. Still confused even after reading the contributing guidelines

Check is present if _client_secret_  parameter is omitted when app is created with/without client secret.
Also other generic tests


Code below:

```
CLEARTEXT_BLANK_SECRET = ""

class TestOAuth2Validator(TransactionTestCase):
    def setUp(self):
        self.user = UserModel.objects.create_user("user", "test@example.com", "123456")
        self.request = mock.MagicMock(wraps=Request)
        self.request.user = self.user
        self.request.grant_type = "not client"
        self.validator = OAuth2Validator()
        self.application = Application.objects.create(
            client_id="client_id",
            client_secret=CLEARTEXT_SECRET,
            user=self.user,
            client_type=Application.CLIENT_PUBLIC,
            authorization_grant_type=Application.GRANT_PASSWORD,
        )
        self.request.client = self.application

        self.blank_secret_request = mock.MagicMock(wraps=Request)
        self.blank_secret_request.user = self.user
        self.blank_secret_request.grant_type = "not client"
        self.blank_secret_application = Application.objects.create(
            client_id="blank_secret_client_id",
            client_secret=CLEARTEXT_BLANK_SECRET,
            user=self.user,
            client_type=Application.CLIENT_PUBLIC,
            authorization_grant_type=Application.GRANT_PASSWORD,
        )
        self.blank_secret_request.client = self.blank_secret_application

    def tearDown(self):
        self.application.delete()

    def test_authenticate_request_body(self):
        self.request.client_id = "client_id"
        self.assertFalse(self.validator._authenticate_request_body(self.request))
        self.request.client_secret = ""
        self.assertFalse(self.validator._authenticate_request_body(self.request))
        self.request.client_secret = "wrong_client_secret"
        self.assertFalse(self.validator._authenticate_request_body(self.request))
        self.request.client_secret = CLEARTEXT_SECRET
        self.assertTrue(self.validator._authenticate_request_body(self.request))

        self.blank_secret_request.client_id = "blank_secret_client_id"
        self.assertTrue(self.validator._authenticate_request_body(self.blank_secret_request))
        self.blank_secret_request.client_secret = CLEARTEXT_BLANK_SECRET
        self.assertTrue(self.validator._authenticate_request_body(self.blank_secret_request))
        self.blank_secret_request.client_secret = "wrong_client_secret"
        self.assertFalse(self.validator._authenticate_request_body(self.blank_secret_request))
```



Please put these changes in a PR so I can review and comment inline. FWIW, you can use `getattr()`:
```diff
1c1
< client_secret = request.client_secret if hasattr(request, "client_secret") else ""
---
> client_secret = getattr(request.client_secret, "")
```

Resolving patch:
diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py
--- a/oauth2_provider/oauth2_validators.py
+++ b/oauth2_provider/oauth2_validators.py
@@ -170,7 +170,7 @@ def _authenticate_request_body(self, request):
         # TODO: check if oauthlib has already unquoted client_id and client_secret
         try:
             client_id = request.client_id
-            client_secret = request.client_secret
+            client_secret = getattr(request, "client_secret", "")
         except AttributeError:
             return False
 


Test patch:
diff --git a/tests/test_oauth2_validators.py b/tests/test_oauth2_validators.py
--- a/tests/test_oauth2_validators.py
+++ b/tests/test_oauth2_validators.py
@@ -30,6 +30,7 @@
 RefreshToken = get_refresh_token_model()
 
 CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz"
+CLEARTEXT_BLANK_SECRET = ""
 
 
 @contextlib.contextmanager
@@ -61,11 +62,25 @@ def setUp(self):
         )
         self.request.client = self.application
 
+        self.blank_secret_request = mock.MagicMock(wraps=Request)
+        self.blank_secret_request.user = self.user
+        self.blank_secret_request.grant_type = "not client"
+        self.blank_secret_application = Application.objects.create(
+            client_id="blank_secret_client_id",
+            client_secret=CLEARTEXT_BLANK_SECRET,
+            user=self.user,
+            client_type=Application.CLIENT_PUBLIC,
+            authorization_grant_type=Application.GRANT_PASSWORD,
+        )
+        self.blank_secret_request.client = self.blank_secret_application
+
     def tearDown(self):
         self.application.delete()
 
     def test_authenticate_request_body(self):
         self.request.client_id = "client_id"
+        self.assertFalse(self.validator._authenticate_request_body(self.request))
+
         self.request.client_secret = ""
         self.assertFalse(self.validator._authenticate_request_body(self.request))
 
@@ -75,6 +90,15 @@ def test_authenticate_request_body(self):
         self.request.client_secret = CLEARTEXT_SECRET
         self.assertTrue(self.validator._authenticate_request_body(self.request))
 
+        self.blank_secret_request.client_id = "blank_secret_client_id"
+        self.assertTrue(self.validator._authenticate_request_body(self.blank_secret_request))
+
+        self.blank_secret_request.client_secret = CLEARTEXT_BLANK_SECRET
+        self.assertTrue(self.validator._authenticate_request_body(self.blank_secret_request))
+
+        self.blank_secret_request.client_secret = "wrong_client_secret"
+        self.assertFalse(self.validator._authenticate_request_body(self.blank_secret_request))
+
     def test_extract_basic_auth(self):
         self.request.headers = {"HTTP_AUTHORIZATION": "Basic 123456"}
         self.assertEqual(self.validator._extract_basic_auth(self.request), "123456")