Skip to content

Commit 4655c03

Browse files
thinkwelltwdwiliamsouzaallissonfvlimashauns
authored
Openid Connect Core support - Round 2 (#859)
* Add OpenID connect hybrid grant type * Add OpenID connect algorithm type to Application model * Add OpenID connect id token model * Add nonce Authorization as required by OpenID connect Implicit Flow * Add body to create_authorization_response to pass nonce and future OpenID parameters to oauthlib.common.Request * Add OpenID connect ID token creation and validation methods and scopes * Add OpenID connect response types * Add OpenID connect authorization code flow test * Add OpenID connect implicit flow tests * Add validate_user_match method to OAuth2Validator * Add RSA_PRIVATE_KEY setting with blank value * Update tox * Add get_jwt_bearer_token to OAuth2Validator * Add validate_jwt_bearer_token to OAuth2Validator * Change OAuth2Validator.validate_id_token default return value to False to avoid validation security breach * Change to use .encode to avoid py2.7 tox test error * Add OpenID connect hybrid flow tests * Change to use .encode to avoid py2.7 tox test error * Add RSA_PRIVATE_KEY to the list of settings that cannot be empt * Add support for oidc connect discovery * Use double quotes for strings * Rename migrations to avoid name and order conflict * Remove commando to install OAuthLib from master and removed jwcrypto duplication * Remove python 2 compatible code * Change errors access_denied/unauthorized_client/consent_required/login_required to be 400 as changed in oauthlib/pull/623 * Change iss claim value to come from settings * Change to use openid connect code server class * Change test to include missing state * Add id_token relation to AbstractAccessToken * Add claims property to AbstractIDToken * Change OAuth2Validator._create_access_token to save id_token to access_token * Add userinfo endpoint * Update migrations and remove oauthlib duplication * Remove old generated migrations * Add new migrations * Fix tests * Add nonce to hybrid tests * Add missing new attributes to test migration * Rebase fixing conflicts and tests * Remove auto generate message * Fix flake8 issues * Fix test doc deps * Add project settings to be ignored in coverage * Tweak migrations to support non-overidden models * OIDC_USERINFO_ENDPOINT is not mandatory * refresh_token grant should be support for OpenID hybrid * Fix the user info view, and remove hard dependency on DRF * Use proper URL generation for OIDC endpoints * Support rich ID tokens and userinfo claims Extend the validator and override get_additional_claims based on your own user model. * Bug fix for at_hash generation See https://openid.net/specs/openid-connect-core-1_0.html#id_token-tokenExample to prove algorithm * OIDC_ISS_ENDPOINT is an optional setting * Support OIDC urls from issuer url if provided * Test for generated OIDC urls * Flake * Rebase on master and migrate url function to re_path * Handle invalid token format exceptions as invalid tokens * Merge migrations and sort imports isort for flake8 lint check Co-authored-by: Wiliam Souza <[email protected]> Co-authored-by: Allisson Azevedo <[email protected]> Co-authored-by: fvlima <[email protected]> Co-authored-by: Shaun Stanworth <[email protected]>
1 parent dbd6128 commit 4655c03

28 files changed

+2897
-259
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ __pycache__
2525
pip-log.txt
2626

2727
# Unit test / coverage reports
28-
.cache
28+
.pytest_cache
2929
.coverage
3030
.tox
3131
.pytest_cache/

oauth2_provider/admin.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from .models import (
44
get_access_token_model, get_application_model,
5-
get_grant_model, get_refresh_token_model
5+
get_grant_model, get_id_token_model, get_refresh_token_model
66
)
77

88

@@ -26,6 +26,11 @@ class AccessTokenAdmin(admin.ModelAdmin):
2626
raw_id_fields = ("user", "source_refresh_token")
2727

2828

29+
class IDTokenAdmin(admin.ModelAdmin):
30+
list_display = ("token", "user", "application", "expires")
31+
raw_id_fields = ("user", )
32+
33+
2934
class RefreshTokenAdmin(admin.ModelAdmin):
3035
list_display = ("token", "user", "application")
3136
raw_id_fields = ("user", "access_token")
@@ -34,9 +39,11 @@ class RefreshTokenAdmin(admin.ModelAdmin):
3439
Application = get_application_model()
3540
Grant = get_grant_model()
3641
AccessToken = get_access_token_model()
42+
IDToken = get_id_token_model()
3743
RefreshToken = get_refresh_token_model()
3844

3945
admin.site.register(Application, ApplicationAdmin)
4046
admin.site.register(Grant, GrantAdmin)
4147
admin.site.register(AccessToken, AccessTokenAdmin)
48+
admin.site.register(IDToken, IDTokenAdmin)
4249
admin.site.register(RefreshToken, RefreshTokenAdmin)

oauth2_provider/forms.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ class AllowForm(forms.Form):
55
allow = forms.BooleanField(required=False)
66
redirect_uri = forms.CharField(widget=forms.HiddenInput())
77
scope = forms.CharField(widget=forms.HiddenInput())
8+
nonce = forms.CharField(required=False, widget=forms.HiddenInput())
89
client_id = forms.CharField(widget=forms.HiddenInput())
910
state = forms.CharField(required=False, widget=forms.HiddenInput())
1011
response_type = forms.CharField(widget=forms.HiddenInput())

oauth2_provider/migrations/0002_auto_20190406_1805.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
# Generated by Django 2.2 on 2019-04-06 18:05
2-
31
from django.db import migrations, models
42

53

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
from django.conf import settings
2+
from django.db import migrations, models
3+
import django.db.models.deletion
4+
5+
from oauth2_provider.settings import oauth2_settings
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12+
('oauth2_provider', '0002_auto_20190406_1805'),
13+
]
14+
15+
operations = [
16+
migrations.AddField(
17+
model_name='application',
18+
name='algorithm',
19+
field=models.CharField(choices=[('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')], default='RS256', max_length=5),
20+
),
21+
migrations.AlterField(
22+
model_name='application',
23+
name='authorization_grant_type',
24+
field=models.CharField(choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials'), ('openid-hybrid', 'OpenID connect hybrid')], max_length=32),
25+
),
26+
migrations.CreateModel(
27+
name='IDToken',
28+
fields=[
29+
('id', models.BigAutoField(primary_key=True, serialize=False)),
30+
('token', models.TextField(unique=True)),
31+
('expires', models.DateTimeField()),
32+
('scope', models.TextField(blank=True)),
33+
('created', models.DateTimeField(auto_now_add=True)),
34+
('updated', models.DateTimeField(auto_now=True)),
35+
('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=oauth2_settings.APPLICATION_MODEL)),
36+
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='oauth2_provider_idtoken', to=settings.AUTH_USER_MODEL)),
37+
],
38+
options={
39+
'abstract': False,
40+
'swappable': 'OAUTH2_PROVIDER_ID_TOKEN_MODEL',
41+
},
42+
),
43+
migrations.AddField(
44+
model_name='accesstoken',
45+
name='id_token',
46+
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_token', to=oauth2_settings.ID_TOKEN_MODEL),
47+
),
48+
]

oauth2_provider/models.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import logging
23
from datetime import timedelta
34
from urllib.parse import parse_qsl, urlparse
@@ -9,6 +10,7 @@
910
from django.urls import reverse
1011
from django.utils import timezone
1112
from django.utils.translation import gettext_lazy as _
13+
from jwcrypto import jwk, jwt
1214

1315
from .generators import generate_client_id, generate_client_secret
1416
from .scopes import get_scopes_backend
@@ -50,11 +52,20 @@ class AbstractApplication(models.Model):
5052
GRANT_IMPLICIT = "implicit"
5153
GRANT_PASSWORD = "password"
5254
GRANT_CLIENT_CREDENTIALS = "client-credentials"
55+
GRANT_OPENID_HYBRID = "openid-hybrid"
5356
GRANT_TYPES = (
5457
(GRANT_AUTHORIZATION_CODE, _("Authorization code")),
5558
(GRANT_IMPLICIT, _("Implicit")),
5659
(GRANT_PASSWORD, _("Resource owner password-based")),
5760
(GRANT_CLIENT_CREDENTIALS, _("Client credentials")),
61+
(GRANT_OPENID_HYBRID, _("OpenID connect hybrid")),
62+
)
63+
64+
RS256_ALGORITHM = "RS256"
65+
HS256_ALGORITHM = "HS256"
66+
ALGORITHM_TYPES = (
67+
(RS256_ALGORITHM, _("RSA with SHA-2 256")),
68+
(HS256_ALGORITHM, _("HMAC with SHA-2 256")),
5869
)
5970

6071
id = models.BigAutoField(primary_key=True)
@@ -82,6 +93,7 @@ class AbstractApplication(models.Model):
8293

8394
created = models.DateTimeField(auto_now_add=True)
8495
updated = models.DateTimeField(auto_now=True)
96+
algorithm = models.CharField(max_length=5, choices=ALGORITHM_TYPES, default=RS256_ALGORITHM)
8597

8698
class Meta:
8799
abstract = True
@@ -282,6 +294,10 @@ class AbstractAccessToken(models.Model):
282294
related_name="refreshed_access_token"
283295
)
284296
token = models.CharField(max_length=255, unique=True, )
297+
id_token = models.OneToOneField(
298+
oauth2_settings.ID_TOKEN_MODEL, on_delete=models.CASCADE, blank=True, null=True,
299+
related_name="access_token"
300+
)
285301
application = models.ForeignKey(
286302
oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE, blank=True, null=True,
287303
)
@@ -415,6 +431,99 @@ class Meta(AbstractRefreshToken.Meta):
415431
swappable = "OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL"
416432

417433

434+
class AbstractIDToken(models.Model):
435+
"""
436+
An IDToken instance represents the actual token to
437+
access user's resources, as in :openid:`2`.
438+
439+
Fields:
440+
441+
* :attr:`user` The Django user representing resources' owner
442+
* :attr:`token` ID token
443+
* :attr:`application` Application instance
444+
* :attr:`expires` Date and time of token expiration, in DateTime format
445+
* :attr:`scope` Allowed scopes
446+
"""
447+
id = models.BigAutoField(primary_key=True)
448+
user = models.ForeignKey(
449+
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, blank=True, null=True,
450+
related_name="%(app_label)s_%(class)s"
451+
)
452+
token = models.TextField(unique=True)
453+
application = models.ForeignKey(
454+
oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE, blank=True, null=True,
455+
)
456+
expires = models.DateTimeField()
457+
scope = models.TextField(blank=True)
458+
459+
created = models.DateTimeField(auto_now_add=True)
460+
updated = models.DateTimeField(auto_now=True)
461+
462+
def is_valid(self, scopes=None):
463+
"""
464+
Checks if the access token is valid.
465+
466+
:param scopes: An iterable containing the scopes to check or None
467+
"""
468+
return not self.is_expired() and self.allow_scopes(scopes)
469+
470+
def is_expired(self):
471+
"""
472+
Check token expiration with timezone awareness
473+
"""
474+
if not self.expires:
475+
return True
476+
477+
return timezone.now() >= self.expires
478+
479+
def allow_scopes(self, scopes):
480+
"""
481+
Check if the token allows the provided scopes
482+
483+
:param scopes: An iterable containing the scopes to check
484+
"""
485+
if not scopes:
486+
return True
487+
488+
provided_scopes = set(self.scope.split())
489+
resource_scopes = set(scopes)
490+
491+
return resource_scopes.issubset(provided_scopes)
492+
493+
def revoke(self):
494+
"""
495+
Convenience method to uniform tokens' interface, for now
496+
simply remove this token from the database in order to revoke it.
497+
"""
498+
self.delete()
499+
500+
@property
501+
def scopes(self):
502+
"""
503+
Returns a dictionary of allowed scope names (as keys) with their descriptions (as values)
504+
"""
505+
all_scopes = get_scopes_backend().get_all_scopes()
506+
token_scopes = self.scope.split()
507+
return {name: desc for name, desc in all_scopes.items() if name in token_scopes}
508+
509+
@property
510+
def claims(self):
511+
key = jwk.JWK.from_pem(oauth2_settings.OIDC_RSA_PRIVATE_KEY.encode("utf8"))
512+
jwt_token = jwt.JWT(key=key, jwt=self.token)
513+
return json.loads(jwt_token.claims)
514+
515+
def __str__(self):
516+
return self.token
517+
518+
class Meta:
519+
abstract = True
520+
521+
522+
class IDToken(AbstractIDToken):
523+
class Meta(AbstractIDToken.Meta):
524+
swappable = "OAUTH2_PROVIDER_ID_TOKEN_MODEL"
525+
526+
418527
def get_application_model():
419528
""" Return the Application model that is active in this project. """
420529
return apps.get_model(oauth2_settings.APPLICATION_MODEL)
@@ -430,6 +539,11 @@ def get_access_token_model():
430539
return apps.get_model(oauth2_settings.ACCESS_TOKEN_MODEL)
431540

432541

542+
def get_id_token_model():
543+
""" Return the AccessToken model that is active in this project. """
544+
return apps.get_model(oauth2_settings.ID_TOKEN_MODEL)
545+
546+
433547
def get_refresh_token_model():
434548
""" Return the RefreshToken model that is active in this project. """
435549
return apps.get_model(oauth2_settings.REFRESH_TOKEN_MODEL)

oauth2_provider/oauth2_backends.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -104,15 +104,16 @@ def validate_authorization_request(self, request):
104104
except oauth2.OAuth2Error as error:
105105
raise OAuthToolkitError(error=error)
106106

107-
def create_authorization_response(self, request, scopes, credentials, allow):
107+
def create_authorization_response(self, uri, request, scopes, credentials, body, allow):
108108
"""
109109
A wrapper method that calls create_authorization_response on `server_class`
110110
instance.
111111
112112
:param request: The current django.http.HttpRequest object
113113
:param scopes: A list of provided scopes
114114
:param credentials: Authorization credentials dictionary containing
115-
`client_id`, `state`, `redirect_uri`, `response_type`
115+
`client_id`, `state`, `redirect_uri` and `response_type`
116+
:param body: Other body parameters not used in credentials dictionary
116117
:param allow: True if the user authorize the client, otherwise False
117118
"""
118119
try:
@@ -124,10 +125,10 @@ def create_authorization_response(self, request, scopes, credentials, allow):
124125
credentials["user"] = request.user
125126

126127
headers, body, status = self.server.create_authorization_response(
127-
uri=credentials["redirect_uri"], scopes=scopes, credentials=credentials)
128-
uri = headers.get("Location", None)
128+
uri=uri, scopes=scopes, credentials=credentials, body=body)
129+
redirect_uri = headers.get("Location", None)
129130

130-
return uri, headers, body, status
131+
return redirect_uri, headers, body, status
131132

132133
except oauth2.FatalClientError as error:
133134
raise FatalClientError(
@@ -166,6 +167,21 @@ def create_revocation_response(self, request):
166167

167168
return uri, headers, body, status
168169

170+
def create_userinfo_response(self, request):
171+
"""
172+
A wrapper method that calls create_userinfo_response on a
173+
`server_class` instance.
174+
175+
:param request: The current django.http.HttpRequest object
176+
"""
177+
uri, http_method, body, headers = self._extract_params(request)
178+
headers, body, status = self.server.create_userinfo_response(
179+
uri, http_method, body, headers
180+
)
181+
uri = headers.get("Location", None)
182+
183+
return uri, headers, body, status
184+
169185
def verify_request(self, request, scopes):
170186
"""
171187
A wrapper method that calls verify_request on `server_class` instance.

0 commit comments

Comments
 (0)