Skip to content

Commit dc429ad

Browse files
Maronatoauvipy
authored andcommitted
PKCE support with oauthlib 3 (#678)
* add basic pkce support * fixed flak8 errors * documented test cases * added new tests and fixed old ones * fixed invalid pkce algorithm test * removed bad quotes * revert to non-nullable charfields * squashed migrations * fix dependencies and tests to work with oauthlib 3 * add support for oauthlib 3's PKCE implementation * added oauthlib dep to docs toxenv * remove sublime file * remove vestigial implementation of code challenge verification * flake errors * pinned oauthlib to a pypi released version * oauthlib 3 * removed broken migration
1 parent fed914c commit dc429ad

File tree

12 files changed

+437
-13
lines changed

12 files changed

+437
-13
lines changed

docs/settings.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,3 +185,10 @@ RESOURCE_SERVER_TOKEN_CACHING_SECONDS
185185
The number of seconds an authorization token received from the introspection endpoint remains valid.
186186
If the expire time of the received token is less than ``RESOURCE_SERVER_TOKEN_CACHING_SECONDS`` the expire time
187187
will be used.
188+
189+
190+
PKCE_REQUIRED
191+
~~~~~~~~~~~~~
192+
Default: ``False``
193+
194+
Whether or not PKCE is required. Can be either a bool or a callable that takes a client id and returns a bool.

oauth2_provider/forms.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,5 @@ class AllowForm(forms.Form):
88
client_id = forms.CharField(widget=forms.HiddenInput())
99
state = forms.CharField(required=False, widget=forms.HiddenInput())
1010
response_type = forms.CharField(widget=forms.HiddenInput())
11+
code_challenge = forms.CharField(required=False, widget=forms.HiddenInput())
12+
code_challenge_method = forms.CharField(required=False, widget=forms.HiddenInput())
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Generated by Django 2.2 on 2019-04-06 18:05
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('oauth2_provider', '0001_initial'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='grant',
15+
name='code_challenge',
16+
field=models.CharField(blank=True, default='', max_length=128),
17+
),
18+
migrations.AddField(
19+
model_name='grant',
20+
name='code_challenge_method',
21+
field=models.CharField(blank=True, choices=[('plain', 'plain'), ('S256', 'S256')], default='', max_length=10),
22+
),
23+
]

oauth2_provider/models.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,16 @@ class AbstractGrant(models.Model):
202202
:data:`settings.AUTHORIZATION_CODE_EXPIRE_SECONDS`
203203
* :attr:`redirect_uri` Self explained
204204
* :attr:`scope` Required scopes, optional
205+
* :attr:`code_challenge` PKCE code challenge
206+
* :attr:`code_challenge_method` PKCE code challenge transform algorithm
205207
"""
208+
CODE_CHALLENGE_PLAIN = "plain"
209+
CODE_CHALLENGE_S256 = "S256"
210+
CODE_CHALLENGE_METHODS = (
211+
(CODE_CHALLENGE_PLAIN, "plain"),
212+
(CODE_CHALLENGE_S256, "S256")
213+
)
214+
206215
id = models.BigAutoField(primary_key=True)
207216
user = models.ForeignKey(
208217
settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
@@ -219,6 +228,10 @@ class AbstractGrant(models.Model):
219228
created = models.DateTimeField(auto_now_add=True)
220229
updated = models.DateTimeField(auto_now=True)
221230

231+
code_challenge = models.CharField(max_length=128, blank=True, default="")
232+
code_challenge_method = models.CharField(
233+
max_length=10, blank=True, default="", choices=CODE_CHALLENGE_METHODS)
234+
222235
def is_expired(self):
223236
"""
224237
Check token expiration with timezone awareness

oauth2_provider/oauth2_backends.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,6 @@ def validate_authorization_request(self, request):
8585
"""
8686
try:
8787
uri, http_method, body, headers = self._extract_params(request)
88-
8988
scopes, credentials = self.server.validate_authorization_request(
9089
uri, http_method=http_method, body=body, headers=headers)
9190

oauth2_provider/oauth2_validators.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -427,12 +427,38 @@ def get_default_scopes(self, client_id, request, *args, **kwargs):
427427
def validate_redirect_uri(self, client_id, redirect_uri, request, *args, **kwargs):
428428
return request.client.redirect_uri_allowed(redirect_uri)
429429

430+
def is_pkce_required(self, client_id, request):
431+
"""
432+
Enables or disables PKCE verification.
433+
434+
Uses the setting PKCE_REQUIRED, which can be either a bool or a callable that
435+
receives the client id and returns a bool.
436+
"""
437+
if callable(oauth2_settings.PKCE_REQUIRED):
438+
return oauth2_settings.PKCE_REQUIRED(client_id)
439+
return oauth2_settings.PKCE_REQUIRED
440+
441+
def get_code_challenge(self, code, request):
442+
grant = Grant.objects.get(code=code, application=request.client)
443+
return grant.code_challenge or None
444+
445+
def get_code_challenge_method(self, code, request):
446+
grant = Grant.objects.get(code=code, application=request.client)
447+
return grant.code_challenge_method or None
448+
430449
def save_authorization_code(self, client_id, code, request, *args, **kwargs):
431450
expires = timezone.now() + timedelta(
432451
seconds=oauth2_settings.AUTHORIZATION_CODE_EXPIRE_SECONDS)
433-
g = Grant(application=request.client, user=request.user, code=code["code"],
434-
expires=expires, redirect_uri=request.redirect_uri,
435-
scope=" ".join(request.scopes))
452+
g = Grant(
453+
application=request.client,
454+
user=request.user,
455+
code=code["code"],
456+
expires=expires,
457+
redirect_uri=request.redirect_uri,
458+
scope=" ".join(request.scopes),
459+
code_challenge=request.code_challenge or "",
460+
code_challenge_method=request.code_challenge_method or ""
461+
)
436462
g.save()
437463

438464
def rotate_refresh_token(self, request):

oauth2_provider/settings.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@
6262
"RESOURCE_SERVER_AUTH_TOKEN": None,
6363
"RESOURCE_SERVER_INTROSPECTION_CREDENTIALS": None,
6464
"RESOURCE_SERVER_TOKEN_CACHING_SECONDS": 36000,
65+
66+
# Whether or not PKCE is required
67+
"PKCE_REQUIRED": False
6568
}
6669

6770
# List of settings that cannot be empty

oauth2_provider/views/base.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ def get_initial(self):
9797
"client_id": self.oauth2_data.get("client_id", None),
9898
"state": self.oauth2_data.get("state", None),
9999
"response_type": self.oauth2_data.get("response_type", None),
100+
"code_challenge": self.oauth2_data.get("code_challenge", None),
101+
"code_challenge_method": self.oauth2_data.get("code_challenge_method", None),
100102
}
101103
return initial_data
102104

@@ -107,8 +109,12 @@ def form_valid(self, form):
107109
"client_id": form.cleaned_data.get("client_id"),
108110
"redirect_uri": form.cleaned_data.get("redirect_uri"),
109111
"response_type": form.cleaned_data.get("response_type", None),
110-
"state": form.cleaned_data.get("state", None),
112+
"state": form.cleaned_data.get("state", None)
111113
}
114+
if form.cleaned_data.get("code_challenge", False):
115+
credentials["code_challenge"] = form.cleaned_data.get("code_challenge")
116+
if form.cleaned_data.get("code_challenge_method", False):
117+
credentials["code_challenge_method"] = form.cleaned_data.get("code_challenge_method")
112118
scopes = form.cleaned_data.get("scope")
113119
allow = form.cleaned_data.get("allow")
114120

@@ -143,6 +149,8 @@ def get(self, request, *args, **kwargs):
143149
kwargs["redirect_uri"] = credentials["redirect_uri"]
144150
kwargs["response_type"] = credentials["response_type"]
145151
kwargs["state"] = credentials["state"]
152+
kwargs["code_challenge"] = credentials.get("code_challenge", None)
153+
kwargs["code_challenge_method"] = credentials.get("code_challenge_method", None)
146154

147155
self.oauth2_data = kwargs
148156
# following two loc are here only because of https://code.djangoproject.com/ticket/17795

setup.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ include_package_data = True
2828
zip_safe = False
2929
install_requires =
3030
django >= 2.0
31-
oauthlib >= 2.0.3, < 3.0.0
3231
requests >= 2.13.0
32+
oauthlib >= 3.0.1
3333

3434
[options.packages.find]
3535
exclude = tests

0 commit comments

Comments
 (0)