From 33fce782b157440659ed6bdd01a14df5e139786c Mon Sep 17 00:00:00 2001 From: Wiliam Souza Date: Sun, 29 Oct 2017 16:44:21 -0200 Subject: [PATCH 01/57] Add OpenID connect hybrid grant type --- .../migrations/0006_auto_20170903_1632.py | 20 +++++++++++++++++++ oauth2_provider/models.py | 2 ++ oauth2_provider/oauth2_validators.py | 4 ++-- 3 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 oauth2_provider/migrations/0006_auto_20170903_1632.py diff --git a/oauth2_provider/migrations/0006_auto_20170903_1632.py b/oauth2_provider/migrations/0006_auto_20170903_1632.py new file mode 100644 index 000000000..dc2d7cbe9 --- /dev/null +++ b/oauth2_provider/migrations/0006_auto_20170903_1632.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-09-03 16:32 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('oauth2_provider', '0005_auto_20170514_1141'), + ] + + operations = [ + migrations.AlterField( + model_name='application', + name='authorization_grant_type', + 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), + ), + ] diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 5676bc0c5..6c573636b 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -50,11 +50,13 @@ class AbstractApplication(models.Model): GRANT_IMPLICIT = "implicit" GRANT_PASSWORD = "password" GRANT_CLIENT_CREDENTIALS = "client-credentials" + GRANT_OPENID_HYBRID = "openid-hybrid" GRANT_TYPES = ( (GRANT_AUTHORIZATION_CODE, _("Authorization code")), (GRANT_IMPLICIT, _("Implicit")), (GRANT_PASSWORD, _("Resource owner password-based")), (GRANT_CLIENT_CREDENTIALS, _("Client credentials")), + (GRANT_OPENID_HYBRID, _("OpenID connect hybrid")), ) id = models.BigAutoField(primary_key=True) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 515353d6f..ea7e88cd1 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -29,14 +29,14 @@ log = logging.getLogger("oauth2_provider") GRANT_TYPE_MAPPING = { - "authorization_code": (AbstractApplication.GRANT_AUTHORIZATION_CODE, ), + "authorization_code": (AbstractApplication.GRANT_AUTHORIZATION_CODE, AbstractApplication.GRANT_OPENID_HYBRID), "password": (AbstractApplication.GRANT_PASSWORD, ), "client_credentials": (AbstractApplication.GRANT_CLIENT_CREDENTIALS, ), "refresh_token": ( AbstractApplication.GRANT_AUTHORIZATION_CODE, AbstractApplication.GRANT_PASSWORD, AbstractApplication.GRANT_CLIENT_CREDENTIALS, - ) + ), } Application = get_application_model() From f48a950c102b81889146feb1c1add53d6f43bbaf Mon Sep 17 00:00:00 2001 From: Wiliam Souza Date: Sun, 29 Oct 2017 16:48:30 -0200 Subject: [PATCH 02/57] Add OpenID connect algorithm type to Application model --- .../migrations/0007_application_algorithm.py | 20 +++++++++++++++++++ oauth2_provider/models.py | 8 ++++++++ oauth2_provider/views/application.py | 4 ++-- tests/test_application_views.py | 1 + 4 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 oauth2_provider/migrations/0007_application_algorithm.py diff --git a/oauth2_provider/migrations/0007_application_algorithm.py b/oauth2_provider/migrations/0007_application_algorithm.py new file mode 100644 index 000000000..319d99ee5 --- /dev/null +++ b/oauth2_provider/migrations/0007_application_algorithm.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-09-16 18:55 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('oauth2_provider', '0006_auto_20170903_1632'), + ] + + operations = [ + migrations.AddField( + model_name='application', + name='algorithm', + field=models.CharField(choices=[('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')], default='RS256', max_length=5), + ), + ] diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 6c573636b..1c092f22a 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -59,6 +59,13 @@ class AbstractApplication(models.Model): (GRANT_OPENID_HYBRID, _("OpenID connect hybrid")), ) + RS256_ALGORITHM = "RS256" + HS256_ALGORITHM = "HS256" + ALGORITHM_TYPES = ( + ("RS256", _("RSA with SHA-2 256")), + ("HS256", _("HMAC with SHA-2 256")), + ) + id = models.BigAutoField(primary_key=True) client_id = models.CharField( max_length=100, unique=True, default=generate_client_id, db_index=True @@ -84,6 +91,7 @@ class AbstractApplication(models.Model): created = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) + algorithm = models.CharField(max_length=5, choices=ALGORITHM_TYPES, default="RS256") class Meta: abstract = True diff --git a/oauth2_provider/views/application.py b/oauth2_provider/views/application.py index c925493f5..b38c907ab 100644 --- a/oauth2_provider/views/application.py +++ b/oauth2_provider/views/application.py @@ -32,7 +32,7 @@ def get_form_class(self): get_application_model(), fields=( "name", "client_id", "client_secret", "client_type", - "authorization_grant_type", "redirect_uris" + "authorization_grant_type", "redirect_uris", "algorithm", ) ) @@ -81,6 +81,6 @@ def get_form_class(self): get_application_model(), fields=( "name", "client_id", "client_secret", "client_type", - "authorization_grant_type", "redirect_uris" + "authorization_grant_type", "redirect_uris", "algorithm", ) ) diff --git a/tests/test_application_views.py b/tests/test_application_views.py index 6130876ce..64e112da3 100644 --- a/tests/test_application_views.py +++ b/tests/test_application_views.py @@ -50,6 +50,7 @@ def test_application_registration_user(self): "client_type": Application.CLIENT_CONFIDENTIAL, "redirect_uris": "http://example.com", "authorization_grant_type": Application.GRANT_AUTHORIZATION_CODE, + "algorithm": "RS256", } response = self.client.post(reverse("oauth2_provider:register"), form_data) From 583adab06bb98ce653c86ac702578981cc9be89a Mon Sep 17 00:00:00 2001 From: Wiliam Souza Date: Sun, 29 Oct 2017 16:55:18 -0200 Subject: [PATCH 03/57] Add OpenID connect id token model --- oauth2_provider/admin.py | 14 +++- oauth2_provider/migrations/0008_idtoken.py | 38 +++++++++ oauth2_provider/models.py | 93 ++++++++++++++++++++++ oauth2_provider/settings.py | 3 + 4 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 oauth2_provider/migrations/0008_idtoken.py diff --git a/oauth2_provider/admin.py b/oauth2_provider/admin.py index 8b963d981..d529f5073 100644 --- a/oauth2_provider/admin.py +++ b/oauth2_provider/admin.py @@ -1,8 +1,11 @@ from django.contrib import admin from .models import ( - get_access_token_model, get_application_model, - get_grant_model, get_refresh_token_model + get_access_token_model, + get_application_model, + get_grant_model, + get_refresh_token_model, + get_id_token_model, ) @@ -26,6 +29,11 @@ class AccessTokenAdmin(admin.ModelAdmin): raw_id_fields = ("user", "source_refresh_token") +class IDTokenAdmin(admin.ModelAdmin): + list_display = ("token", "user", "application", "expires") + raw_id_fields = ("user", ) + + class RefreshTokenAdmin(admin.ModelAdmin): list_display = ("token", "user", "application") raw_id_fields = ("user", "access_token") @@ -34,9 +42,11 @@ class RefreshTokenAdmin(admin.ModelAdmin): Application = get_application_model() Grant = get_grant_model() AccessToken = get_access_token_model() +IDToken = get_id_token_model() RefreshToken = get_refresh_token_model() admin.site.register(Application, ApplicationAdmin) admin.site.register(Grant, GrantAdmin) admin.site.register(AccessToken, AccessTokenAdmin) +admin.site.register(IDToken, IDTokenAdmin) admin.site.register(RefreshToken, RefreshTokenAdmin) diff --git a/oauth2_provider/migrations/0008_idtoken.py b/oauth2_provider/migrations/0008_idtoken.py new file mode 100644 index 000000000..3f0ae10b2 --- /dev/null +++ b/oauth2_provider/migrations/0008_idtoken.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2017-10-01 19:13 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + +from oauth2_provider.settings import oauth2_settings + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + migrations.swappable_dependency(oauth2_settings.APPLICATION_MODEL), + ('oauth2_provider', '0007_application_algorithm'), + ] + + operations = [ + migrations.CreateModel( + name='IDToken', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('token', models.TextField(unique=True)), + ('expires', models.DateTimeField()), + ('scope', models.TextField(blank=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=oauth2_settings.APPLICATION_MODEL)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='oauth2_provider_idtoken', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + 'swappable': 'OAUTH2_PROVIDER_ID_TOKEN_MODEL', + }, + ), + ] diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 1c092f22a..ca835659a 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -425,6 +425,94 @@ class Meta(AbstractRefreshToken.Meta): swappable = "OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL" +@python_2_unicode_compatible +class AbstractIDToken(models.Model): + """ + An IDToken instance represents the actual token to + access user's resources, as in :openid:`2`. + + Fields: + + * :attr:`user` The Django user representing resources' owner + * :attr:`token` ID token + * :attr:`application` Application instance + * :attr:`expires` Date and time of token expiration, in DateTime format + * :attr:`scope` Allowed scopes + """ + id = models.BigAutoField(primary_key=True) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, blank=True, null=True, + related_name="%(app_label)s_%(class)s" + ) + token = models.TextField(unique=True) + application = models.ForeignKey( + oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE, blank=True, null=True, + ) + expires = models.DateTimeField() + scope = models.TextField(blank=True) + + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + + def is_valid(self, scopes=None): + """ + Checks if the access token is valid. + + :param scopes: An iterable containing the scopes to check or None + """ + return not self.is_expired() and self.allow_scopes(scopes) + + def is_expired(self): + """ + Check token expiration with timezone awareness + """ + if not self.expires: + return True + + return timezone.now() >= self.expires + + def allow_scopes(self, scopes): + """ + Check if the token allows the provided scopes + + :param scopes: An iterable containing the scopes to check + """ + if not scopes: + return True + + provided_scopes = set(self.scope.split()) + resource_scopes = set(scopes) + + return resource_scopes.issubset(provided_scopes) + + def revoke(self): + """ + Convenience method to uniform tokens' interface, for now + simply remove this token from the database in order to revoke it. + """ + self.delete() + + @property + def scopes(self): + """ + Returns a dictionary of allowed scope names (as keys) with their descriptions (as values) + """ + all_scopes = get_scopes_backend().get_all_scopes() + token_scopes = self.scope.split() + return {name: desc for name, desc in all_scopes.items() if name in token_scopes} + + def __str__(self): + return self.token + + class Meta: + abstract = True + + +class IDToken(AbstractIDToken): + class Meta(AbstractIDToken.Meta): + swappable = "OAUTH2_PROVIDER_ID_TOKEN_MODEL" + + def get_application_model(): """ Return the Application model that is active in this project. """ return apps.get_model(oauth2_settings.APPLICATION_MODEL) @@ -440,6 +528,11 @@ def get_access_token_model(): return apps.get_model(oauth2_settings.ACCESS_TOKEN_MODEL) +def get_id_token_model(): + """ Return the AccessToken model that is active in this project. """ + return apps.get_model(oauth2_settings.ID_TOKEN_MODEL) + + def get_refresh_token_model(): """ Return the RefreshToken model that is active in this project. """ return apps.get_model(oauth2_settings.REFRESH_TOKEN_MODEL) diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index 858efdbe7..41ad45b57 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -25,6 +25,7 @@ APPLICATION_MODEL = getattr(settings, "OAUTH2_PROVIDER_APPLICATION_MODEL", "oauth2_provider.Application") ACCESS_TOKEN_MODEL = getattr(settings, "OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL", "oauth2_provider.AccessToken") +ID_TOKEN_MODEL = getattr(settings, "OAUTH2_PROVIDER_ID_TOKEN_MODEL", "oauth2_provider.IDToken") GRANT_MODEL = getattr(settings, "OAUTH2_PROVIDER_GRANT_MODEL", "oauth2_provider.Grant") REFRESH_TOKEN_MODEL = getattr(settings, "OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL", "oauth2_provider.RefreshToken") @@ -45,12 +46,14 @@ "WRITE_SCOPE": "write", "AUTHORIZATION_CODE_EXPIRE_SECONDS": 60, "ACCESS_TOKEN_EXPIRE_SECONDS": 36000, + "ID_TOKEN_EXPIRE_SECONDS": 36000, "REFRESH_TOKEN_EXPIRE_SECONDS": None, "REFRESH_TOKEN_GRACE_PERIOD_SECONDS": 0, "ROTATE_REFRESH_TOKEN": True, "ERROR_RESPONSE_WITH_SCOPES": False, "APPLICATION_MODEL": APPLICATION_MODEL, "ACCESS_TOKEN_MODEL": ACCESS_TOKEN_MODEL, + "ID_TOKEN_MODEL": ID_TOKEN_MODEL, "GRANT_MODEL": GRANT_MODEL, "REFRESH_TOKEN_MODEL": REFRESH_TOKEN_MODEL, "REQUEST_APPROVAL_PROMPT": "force", From e381fa25cf0a4ac5c74d1e77b02e924755849529 Mon Sep 17 00:00:00 2001 From: Wiliam Souza Date: Sun, 29 Oct 2017 17:04:04 -0200 Subject: [PATCH 04/57] Add nonce Authorization as required by OpenID connect Implicit Flow --- oauth2_provider/forms.py | 1 + oauth2_provider/views/base.py | 66 ++++++++++++++++++++++++----------- 2 files changed, 47 insertions(+), 20 deletions(-) diff --git a/oauth2_provider/forms.py b/oauth2_provider/forms.py index 2e465959a..41129c449 100644 --- a/oauth2_provider/forms.py +++ b/oauth2_provider/forms.py @@ -5,6 +5,7 @@ class AllowForm(forms.Form): allow = forms.BooleanField(required=False) redirect_uri = forms.CharField(widget=forms.HiddenInput()) scope = forms.CharField(widget=forms.HiddenInput()) + nonce = forms.CharField(required=False, widget=forms.HiddenInput()) client_id = forms.CharField(widget=forms.HiddenInput()) state = forms.CharField(required=False, widget=forms.HiddenInput()) response_type = forms.CharField(widget=forms.HiddenInput()) diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index b9b6ed7f9..cee63ee0f 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -86,6 +86,7 @@ class AuthorizationView(BaseAuthorizationView, FormView): * Authorization code * Implicit grant """ + template_name = "oauth2_provider/authorize.html" form_class = AllowForm @@ -101,11 +102,14 @@ def get_initial(self): initial_data = { "redirect_uri": self.oauth2_data.get("redirect_uri", None), "scope": " ".join(scopes), + "nonce": self.oauth2_data.get("nonce", None), "client_id": self.oauth2_data.get("client_id", None), "state": self.oauth2_data.get("state", None), "response_type": self.oauth2_data.get("response_type", None), "code_challenge": self.oauth2_data.get("code_challenge", None), - "code_challenge_method": self.oauth2_data.get("code_challenge_method", None), + "code_challenge_method": self.oauth2_data.get( + "code_challenge_method", None + ), } return initial_data @@ -116,18 +120,23 @@ def form_valid(self, form): "client_id": form.cleaned_data.get("client_id"), "redirect_uri": form.cleaned_data.get("redirect_uri"), "response_type": form.cleaned_data.get("response_type", None), - "state": form.cleaned_data.get("state", None) + "state": form.cleaned_data.get("state", None), } if form.cleaned_data.get("code_challenge", False): credentials["code_challenge"] = form.cleaned_data.get("code_challenge") if form.cleaned_data.get("code_challenge_method", False): - credentials["code_challenge_method"] = form.cleaned_data.get("code_challenge_method") + credentials["code_challenge_method"] = form.cleaned_data.get( + "code_challenge_method" + ) scopes = form.cleaned_data.get("scope") allow = form.cleaned_data.get("allow") try: uri, headers, body, status = self.create_authorization_response( - request=self.request, scopes=scopes, credentials=credentials, allow=allow + request=self.request, + scopes=scopes, + credentials=credentials, + allow=allow, ) except OAuthToolkitError as error: return self.error_response(error, application) @@ -149,13 +158,21 @@ def get(self, request, *args, **kwargs): # at this point we know an Application instance with such client_id exists in the database # TODO: Cache this! - application = get_application_model().objects.get(client_id=credentials["client_id"]) + application = get_application_model().objects.get( + client_id=credentials["client_id"] + ) + + uri_query = parse.urlparse(self.request.get_raw_uri()).query + uri_query_params = dict( + parse.parse_qsl(uri_query, keep_blank_values=True, strict_parsing=True) + ) kwargs["application"] = application kwargs["client_id"] = credentials["client_id"] kwargs["redirect_uri"] = credentials["redirect_uri"] kwargs["response_type"] = credentials["response_type"] kwargs["state"] = credentials["state"] + kwargs["nonce"] = uri_query_params.get("nonce", None) self.oauth2_data = kwargs # following two loc are here only because of https://code.djangoproject.com/ticket/17795 @@ -164,7 +181,9 @@ def get(self, request, *args, **kwargs): # Check to see if the user has already granted access and return # a successful response depending on "approval_prompt" url parameter - require_approval = request.GET.get("approval_prompt", oauth2_settings.REQUEST_APPROVAL_PROMPT) + require_approval = request.GET.get( + "approval_prompt", oauth2_settings.REQUEST_APPROVAL_PROMPT + ) try: # If skip_authorization field is True, skip the authorization screen even @@ -173,24 +192,32 @@ def get(self, request, *args, **kwargs): # are already approved. if application.skip_authorization: uri, headers, body, status = self.create_authorization_response( - request=self.request, scopes=" ".join(scopes), - credentials=credentials, allow=True + request=self.request, + scopes=" ".join(scopes), + credentials=credentials, + allow=True, ) return self.redirect(uri, application) elif require_approval == "auto": - tokens = get_access_token_model().objects.filter( - user=request.user, - application=kwargs["application"], - expires__gt=timezone.now() - ).all() + tokens = ( + get_access_token_model() + .objects.filter( + user=request.user, + application=kwargs["application"], + expires__gt=timezone.now(), + ) + .all() + ) # check past authorizations regarded the same scopes as the current one for token in tokens: if token.allow_scopes(scopes): uri, headers, body, status = self.create_authorization_response( - request=self.request, scopes=" ".join(scopes), - credentials=credentials, allow=True + request=self.request, + scopes=" ".join(scopes), + credentials=credentials, + allow=True, ) return self.redirect(uri, application, token) @@ -239,6 +266,7 @@ class TokenView(OAuthLibMixin, View): * Password * Client credentials """ + server_class = oauth2_settings.OAUTH2_SERVER_CLASS validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS oauthlib_backend_class = oauth2_settings.OAUTH2_BACKEND_CLASS @@ -249,11 +277,8 @@ def post(self, request, *args, **kwargs): if status == 200: access_token = json.loads(body).get("access_token") if access_token is not None: - token = get_access_token_model().objects.get( - token=access_token) - app_authorized.send( - sender=self, request=request, - token=token) + token = get_access_token_model().objects.get(token=access_token) + app_authorized.send(sender=self, request=request, token=token) response = HttpResponse(content=body, status=status) for k, v in headers.items(): @@ -266,6 +291,7 @@ class RevokeTokenView(OAuthLibMixin, View): """ Implements an endpoint to revoke access or refresh tokens """ + server_class = oauth2_settings.OAUTH2_SERVER_CLASS validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS oauthlib_backend_class = oauth2_settings.OAUTH2_BACKEND_CLASS From c0ffa134464e4d1a18f590f8ef3eeba59f9c4c97 Mon Sep 17 00:00:00 2001 From: Wiliam Souza Date: Sun, 29 Oct 2017 17:09:31 -0200 Subject: [PATCH 05/57] Add body to create_authorization_response to pass nonce and future OpenID parameters to oauthlib.common.Request --- oauth2_provider/oauth2_backends.py | 11 ++++++----- oauth2_provider/views/base.py | 12 +++++++++--- oauth2_provider/views/mixins.py | 7 ++++--- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/oauth2_provider/oauth2_backends.py b/oauth2_provider/oauth2_backends.py index 04264f6a0..f1b1f5b29 100644 --- a/oauth2_provider/oauth2_backends.py +++ b/oauth2_provider/oauth2_backends.py @@ -104,7 +104,7 @@ def validate_authorization_request(self, request): except oauth2.OAuth2Error as error: raise OAuthToolkitError(error=error) - def create_authorization_response(self, request, scopes, credentials, allow): + def create_authorization_response(self, uri, request, scopes, credentials, body, allow): """ A wrapper method that calls create_authorization_response on `server_class` instance. @@ -112,7 +112,8 @@ def create_authorization_response(self, request, scopes, credentials, allow): :param request: The current django.http.HttpRequest object :param scopes: A list of provided scopes :param credentials: Authorization credentials dictionary containing - `client_id`, `state`, `redirect_uri`, `response_type` + `client_id`, `state`, `redirect_uri` and `response_type` + :param body: Other body parameters not used in credentials dictionary :param allow: True if the user authorize the client, otherwise False """ try: @@ -124,10 +125,10 @@ def create_authorization_response(self, request, scopes, credentials, allow): credentials["user"] = request.user headers, body, status = self.server.create_authorization_response( - uri=credentials["redirect_uri"], scopes=scopes, credentials=credentials) - uri = headers.get("Location", None) + uri=uri, scopes=scopes, credentials=credentials, body=body) + redirect_uri = headers.get("Location", None) - return uri, headers, body, status + return redirect_uri, headers, body, status except oauth2.FatalClientError as error: raise FatalClientError( diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index cee63ee0f..62908c9a6 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -128,20 +128,24 @@ def form_valid(self, form): credentials["code_challenge_method"] = form.cleaned_data.get( "code_challenge_method" ) + + body = {"nonce": form.cleaned_data.get("nonce")} scopes = form.cleaned_data.get("scope") allow = form.cleaned_data.get("allow") try: uri, headers, body, status = self.create_authorization_response( + self.request.get_raw_uri(), request=self.request, scopes=scopes, credentials=credentials, + body=body, allow=allow, ) except OAuthToolkitError as error: return self.error_response(error, application) - self.success_url = uri + self.success_url = redirect_uri log.debug("Success url for the request: {0}".format(self.success_url)) return self.redirect(self.success_url, application) @@ -192,12 +196,13 @@ def get(self, request, *args, **kwargs): # are already approved. if application.skip_authorization: uri, headers, body, status = self.create_authorization_response( + self.request.get_raw_uri(), request=self.request, scopes=" ".join(scopes), credentials=credentials, allow=True, ) - return self.redirect(uri, application) + return self.redirect(redirect_uri, application) elif require_approval == "auto": tokens = ( @@ -214,12 +219,13 @@ def get(self, request, *args, **kwargs): for token in tokens: if token.allow_scopes(scopes): uri, headers, body, status = self.create_authorization_response( + self.request.get_raw_uri(), request=self.request, scopes=" ".join(scopes), credentials=credentials, allow=True, ) - return self.redirect(uri, application, token) + return self.redirect(redirect_uri, application) except OAuthToolkitError as error: return self.error_response(error, application) diff --git a/oauth2_provider/views/mixins.py b/oauth2_provider/views/mixins.py index 851ec4cd5..1321e221d 100644 --- a/oauth2_provider/views/mixins.py +++ b/oauth2_provider/views/mixins.py @@ -97,7 +97,7 @@ def validate_authorization_request(self, request): core = self.get_oauthlib_core() return core.validate_authorization_request(request) - def create_authorization_response(self, request, scopes, credentials, allow): + def create_authorization_response(self, uri, request, scopes, credentials, allow, body=None): """ A wrapper method that calls create_authorization_response on `server_class` instance. @@ -105,14 +105,15 @@ def create_authorization_response(self, request, scopes, credentials, allow): :param request: The current django.http.HttpRequest object :param scopes: A space-separated string of provided scopes :param credentials: Authorization credentials dictionary containing - `client_id`, `state`, `redirect_uri`, `response_type` + `client_id`, `state`, `redirect_uri` and `response_type` :param allow: True if the user authorize the client, otherwise False + :param body: Other body parameters not used in credentials dictionary """ # TODO: move this scopes conversion from and to string into a utils function scopes = scopes.split(" ") if scopes else [] core = self.get_oauthlib_core() - return core.create_authorization_response(request, scopes, credentials, allow) + return core.create_authorization_response(uri, request, scopes, credentials, body, allow) def create_token_response(self, request): """ From 571ced5925f39e839196e7672afa9b58163e6d1d Mon Sep 17 00:00:00 2001 From: Wiliam Souza Date: Sun, 29 Oct 2017 17:12:27 -0200 Subject: [PATCH 06/57] Add OpenID connect ID token creation and validation methods and scopes --- oauth2_provider/oauth2_validators.py | 132 ++++++++++++++++++++++++++- setup.cfg | 1 + tox.ini | 1 + 3 files changed, 131 insertions(+), 3 deletions(-) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index ea7e88cd1..8efb29a78 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -1,5 +1,7 @@ import base64 import binascii +import json +import hashlib import http.client import logging from collections import OrderedDict @@ -12,15 +14,24 @@ from django.core.exceptions import ObjectDoesNotExist from django.db import transaction from django.db.models import Q -from django.utils import timezone +from django.utils import dateformat, timezone from django.utils.timezone import make_aware from django.utils.translation import gettext_lazy as _ from oauthlib.oauth2 import RequestValidator +from oauthlib.oauth2.rfc6749 import utils + +from jwcrypto.common import JWException +from jwcrypto import jwk, jwt +from jwcrypto.jwt import JWTExpired from .exceptions import FatalClientError from .models import ( - AbstractApplication, get_access_token_model, - get_application_model, get_grant_model, get_refresh_token_model + AbstractApplication, + get_access_token_model, + get_id_token_model, + get_application_model, + get_grant_model, + get_refresh_token_model, ) from .scopes import get_scopes_backend from .settings import oauth2_settings @@ -41,6 +52,7 @@ Application = get_application_model() AccessToken = get_access_token_model() +IDToken = get_id_token_model() Grant = get_grant_model() RefreshToken = get_refresh_token_model() UserModel = get_user_model() @@ -457,6 +469,24 @@ def get_code_challenge_method(self, code, request): def save_authorization_code(self, client_id, code, request, *args, **kwargs): self._create_authorization_code(request, code) + def get_authorization_code_scopes(self, client_id, code, redirect_uri, request): + scopes = [] + fields = { + "code": code, + } + + if client_id: + fields["application__client_id"] = client_id + + if redirect_uri: + fields["redirect_uri"] = redirect_uri + + grant = Grant.objects.filter(**fields).values() + if grant.exists(): + grant_dict = dict(grant[0]) + scopes = utils.scope_to_list(grant_dict["scope"]) + return scopes + def rotate_refresh_token(self, request): """ Checks if rotate refresh token is enabled @@ -659,3 +689,99 @@ def validate_refresh_token(self, refresh_token, client, request, *args, **kwargs # Temporary store RefreshToken instance to be reused by get_original_scopes and save_bearer_token. request.refresh_token_instance = rt return rt.application == client + + @transaction.atomic + def _save_id_token(self, token, request, expires, *args, **kwargs): + + scopes = request.scope or " ".join(request.scopes) + + if request.grant_type == "client_credentials": + request.user = None + + id_token = IDToken.objects.create( + user=request.user, + scope=scopes, + expires=expires, + token=token.serialize(), + application=request.client, + ) + return id_token + + def get_id_token(self, token, token_handler, request): + + key = jwk.JWK.from_pem(oauth2_settings.RSA_PRIVATE_KEY.encode("utf8")) + + # TODO: http://openid.net/specs/openid-connect-core-1_0.html#HybridIDToken2 + # Save the id_token on database bound to code when the request come to + # Authorization Endpoint and return the same one when request come to + # Token Endpoint + + # TODO: Check if at this point this request parameters are alredy validated + + expiration_time = timezone.now() + timedelta(seconds=oauth2_settings.ID_TOKEN_EXPIRE_SECONDS) + # Required ID Token claims + claims = { + "iss": 'https://id.olist.com', # HTTPS URL + "sub": str(request.user.id), + "aud": request.client_id, + "exp": int(dateformat.format(expiration_time, "U")), + "iat": int(dateformat.format(datetime.utcnow(), "U")), + "auth_time": int(dateformat.format(request.user.last_login, "U")) + } + + nonce = getattr(request, "nonce", None) + if nonce: + claims["nonce"] = nonce + + # TODO: create a function to check if we should add at_hash + # http://openid.net/specs/openid-connect-core-1_0.html#CodeIDToken + # http://openid.net/specs/openid-connect-core-1_0.html#ImplicitIDToken + # if request.grant_type in 'authorization_code' and 'access_token' in token: + if (request.grant_type is "authorization_code" and "access_token" in token) or request.response_type == "code id_token token" or (request.response_type == "id_token token" and "access_token" in token): + acess_token = token["access_token"] + sha256 = hashlib.sha256(acess_token.encode("ascii")) + bits128 = sha256.hexdigest()[:16] + at_hash = base64.urlsafe_b64encode(bytes(bits128, "ascii")) + claims['at_hash'] = at_hash.decode("utf8") + + # TODO: create a function to check if we should include c_hash + # http://openid.net/specs/openid-connect-core-1_0.html#HybridIDToken + if request.response_type in ("code id_token", "code id_token token"): + code = token["code"] + sha256 = hashlib.sha256(code.encode("ascii")) + bits256 = sha256.hexdigest()[:32] + c_hash = base64.urlsafe_b64encode(bytes(bits256, "ascii")) + claims["c_hash"] = c_hash.decode("utf8") + + jwt_token = jwt.JWT(header=json.dumps({"alg": "RS256"}, default=str), claims=json.dumps(claims, default=str)) + jwt_token.make_signed_token(key) + + id_token = self._save_id_token(jwt_token, request, expiration_time) + # this is needed by django rest framework + request.access_token = id_token + request.id_token = id_token + return jwt_token.serialize() + + def validate_id_token(self, token, scopes, request): + """ + When users try to access resources, check that provided id_token is valid + """ + if not token: + return False + + key = jwk.JWK.from_pem(oauth2_settings.RSA_PRIVATE_KEY.encode("utf8")) + + try: + jwt_token = jwt.JWT(key=key, jwt=token) + except (JWException, JWTExpired): + # TODO: This is the base exception of all jwcrypto + return False + + id_token = IDToken.objects.get(token=jwt_token.serialize()) + request.client = id_token.application + request.user = id_token.user + request.scopes = scopes + # this is needed by django rest framework + request.access_token = id_token + + return True diff --git a/setup.cfg b/setup.cfg index 3c4e0badc..fb060f88e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -34,6 +34,7 @@ install_requires = django >= 2.1 requests >= 2.13.0 oauthlib >= 3.1.0 + jwcrypto >= 0.4.2 [options.packages.find] exclude = tests diff --git a/tox.ini b/tox.ini index c984f8b99..9d3dcc455 100644 --- a/tox.ini +++ b/tox.ini @@ -26,6 +26,7 @@ deps = djangomaster: https://github.com/django/django/archive/master.tar.gz djangorestframework oauthlib>=3.1.0 + jwcrypto coverage pytest pytest-cov From 521710f0fe19bb62995dbdde75a45d5026ca12a3 Mon Sep 17 00:00:00 2001 From: Wiliam Souza Date: Sun, 29 Oct 2017 17:13:15 -0200 Subject: [PATCH 07/57] Add OpenID connect response types --- oauth2_provider/oauth2_validators.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 8efb29a78..8371be6dc 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -430,6 +430,16 @@ def validate_response_type(self, client_id, response_type, client, request, *arg return client.allows_grant_type(AbstractApplication.GRANT_AUTHORIZATION_CODE) elif response_type == "token": return client.allows_grant_type(AbstractApplication.GRANT_IMPLICIT) + elif response_type == "id_token": + return client.allows_grant_type(AbstractApplication.GRANT_IMPLICIT) + elif response_type == "id_token token": + return client.allows_grant_type(AbstractApplication.GRANT_IMPLICIT) + elif response_type == "code id_token": + return client.allows_grant_type(AbstractApplication.GRANT_OPENID_HYBRID) + elif response_type == "code token": + return client.allows_grant_type(AbstractApplication.GRANT_OPENID_HYBRID) + elif response_type == "code id_token token": + return client.allows_grant_type(AbstractApplication.GRANT_OPENID_HYBRID) else: return False From ff10cf40edf76d9c806b18c45ca67fd79d79101f Mon Sep 17 00:00:00 2001 From: Wiliam Souza Date: Sun, 29 Oct 2017 17:14:13 -0200 Subject: [PATCH 08/57] Add OpenID connect authorization code flow test --- tests/test_authorization_code.py | 690 ++++++++++++++++++++++++------- 1 file changed, 552 insertions(+), 138 deletions(-) diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index e98f5b041..bfe4170f9 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -13,8 +13,10 @@ from oauthlib.oauth2.rfc6749 import errors as oauthlib_errors from oauth2_provider.models import ( - get_access_token_model, get_application_model, - get_grant_model, get_refresh_token_model + get_access_token_model, + get_application_model, + get_grant_model, + get_refresh_token_model, ) from oauth2_provider.settings import oauth2_settings from oauth2_provider.views import ProtectedResourceView @@ -41,8 +43,12 @@ def get(self, request, *args, **kwargs): class BaseTest(TestCase): def setUp(self): self.factory = RequestFactory() - self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") - self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") + self.test_user = UserModel.objects.create_user( + "test_user", "test@example.com", "123456" + ) + self.dev_user = UserModel.objects.create_user( + "dev_user", "dev@example.com", "123456" + ) oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["http", "custom-scheme"] @@ -57,8 +63,13 @@ def setUp(self): authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) - oauth2_settings._SCOPES = ["read", "write"] + oauth2_settings._SCOPES = ["read", "write", "openid"] oauth2_settings._DEFAULT_SCOPES = ["read", "write"] + oauth2_settings.SCOPES = { + "read": "Reading scope", + "write": "Writing scope", + "openid": "OpenID connect", + } def tearDown(self): self.application.delete() @@ -103,6 +114,25 @@ def test_skip_authorization_completely(self): }) self.assertEqual(response.status_code, 302) + def test_id_token_skip_authorization_completely(self): + """ + If application.skip_authorization = True, should skip the authorization page. + """ + self.client.login(username="test_user", password="123456") + self.application.skip_authorization = True + self.application.save() + + query_data = { + "client_id": self.application.client_id, + "response_type": "code", + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.org", + } + + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) + self.assertEqual(response.status_code, 302) + def test_pre_auth_invalid_client(self): """ Test error for an invalid client_id with response_type: code @@ -147,6 +177,32 @@ def test_pre_auth_valid_client(self): self.assertEqual(form["scope"].value(), "read write") self.assertEqual(form["client_id"].value(), self.application.client_id) + def test_id_token_pre_auth_valid_client(self): + """ + Test response for a valid client_id with response_type: code + """ + self.client.login(username="test_user", password="123456") + + query_data = { + "client_id": self.application.client_id, + "response_type": "code", + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.org", + } + + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) + self.assertEqual(response.status_code, 200) + + # check form is in context and form params are valid + self.assertIn("form", response.context) + + form = response.context["form"] + self.assertEqual(form["redirect_uri"].value(), "http://example.org") + self.assertEqual(form["state"].value(), "random_state_string") + self.assertEqual(form["scope"].value(), "openid") + self.assertEqual(form["client_id"].value(), self.application.client_id) + def test_pre_auth_valid_client_custom_redirect_uri_scheme(self): """ Test response for a valid client_id with response_type: code @@ -176,10 +232,11 @@ def test_pre_auth_valid_client_custom_redirect_uri_scheme(self): def test_pre_auth_approval_prompt(self): tok = AccessToken.objects.create( - user=self.test_user, token="1234567890", + user=self.test_user, + token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) self.client.login(username="test_user", password="123456") @@ -204,10 +261,11 @@ def test_pre_auth_approval_prompt_default(self): self.assertEqual(oauth2_settings.REQUEST_APPROVAL_PROMPT, "force") AccessToken.objects.create( - user=self.test_user, token="1234567890", + user=self.test_user, + token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) self.client.login(username="test_user", password="123456") query_data = { @@ -224,10 +282,11 @@ def test_pre_auth_approval_prompt_default_override(self): oauth2_settings.REQUEST_APPROVAL_PROMPT = "auto" AccessToken.objects.create( - user=self.test_user, token="1234567890", + user=self.test_user, + token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) self.client.login(username="test_user", password="123456") query_data = { @@ -302,7 +361,32 @@ def test_code_post_auth_allow(self): "allow": True, } - response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + response = self.client.post( + reverse("oauth2_provider:authorize"), data=form_data + ) + self.assertEqual(response.status_code, 302) + self.assertIn("http://example.org?", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) + self.assertIn("code=", response["Location"]) + + def test_id_token_code_post_auth_allow(self): + """ + Test authorization code is given for an allowed request with response_type: code + """ + self.client.login(username="test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.org", + "response_type": "code", + "allow": True, + } + + response = self.client.post( + reverse("oauth2_provider:authorize"), data=form_data + ) self.assertEqual(response.status_code, 302) self.assertIn("http://example.org?", response["Location"]) self.assertIn("state=random_state_string", response["Location"]) @@ -323,7 +407,9 @@ def test_code_post_auth_deny(self): "allow": False, } - response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + response = self.client.post( + reverse("oauth2_provider:authorize"), data=form_data + ) self.assertEqual(response.status_code, 302) self.assertIn("error=access_denied", response["Location"]) self.assertIn("state=random_state_string", response["Location"]) @@ -342,7 +428,9 @@ def test_code_post_auth_deny_no_state(self): "allow": False, } - response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + response = self.client.post( + reverse("oauth2_provider:authorize"), data=form_data + ) self.assertEqual(response.status_code, 302) self.assertIn("error=access_denied", response["Location"]) self.assertNotIn("state", response["Location"]) @@ -362,7 +450,9 @@ def test_code_post_auth_bad_responsetype(self): "allow": True, } - response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + response = self.client.post( + reverse("oauth2_provider:authorize"), data=form_data + ) self.assertEqual(response.status_code, 302) self.assertIn("http://example.org?error", response["Location"]) @@ -381,7 +471,9 @@ def test_code_post_auth_forbidden_redirect_uri(self): "allow": True, } - response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + response = self.client.post( + reverse("oauth2_provider:authorize"), data=form_data + ) self.assertEqual(response.status_code, 400) def test_code_post_auth_malicious_redirect_uri(self): @@ -399,7 +491,9 @@ def test_code_post_auth_malicious_redirect_uri(self): "allow": True, } - response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + response = self.client.post( + reverse("oauth2_provider:authorize"), data=form_data + ) self.assertEqual(response.status_code, 400) def test_code_post_auth_allow_custom_redirect_uri_scheme(self): @@ -418,7 +512,9 @@ def test_code_post_auth_allow_custom_redirect_uri_scheme(self): "allow": True, } - response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + response = self.client.post( + reverse("oauth2_provider:authorize"), data=form_data + ) self.assertEqual(response.status_code, 302) self.assertIn("custom-scheme://example.com?", response["Location"]) self.assertIn("state=random_state_string", response["Location"]) @@ -440,7 +536,9 @@ def test_code_post_auth_deny_custom_redirect_uri_scheme(self): "allow": False, } - response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + response = self.client.post( + reverse("oauth2_provider:authorize"), data=form_data + ) self.assertEqual(response.status_code, 302) self.assertIn("custom-scheme://example.com?", response["Location"]) self.assertIn("error=access_denied", response["Location"]) @@ -463,7 +561,9 @@ def test_code_post_auth_redirection_uri_with_querystring(self): "allow": True, } - response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + response = self.client.post( + reverse("oauth2_provider:authorize"), data=form_data + ) self.assertEqual(response.status_code, 302) self.assertIn("http://example.com?foo=bar", response["Location"]) self.assertIn("code=", response["Location"]) @@ -486,7 +586,9 @@ def test_code_post_auth_failing_redirection_uri_with_querystring(self): "allow": False, } - response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + response = self.client.post( + reverse("oauth2_provider:authorize"), data=form_data + ) self.assertEqual(response.status_code, 302) self.assertIn("http://example.com?", response["Location"]) self.assertIn("error=access_denied", response["Location"]) @@ -508,25 +610,29 @@ def test_code_post_auth_fails_when_redirect_uri_path_is_invalid(self): "allow": True, } - response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + response = self.client.post( + reverse("oauth2_provider:authorize"), data=form_data + ) self.assertEqual(response.status_code, 400) class TestAuthorizationCodeTokenView(BaseTest): - def get_auth(self): + def get_auth(self, scope="read write"): """ Helper method to retrieve a valid authorization code """ authcode_data = { "client_id": self.application.client_id, "state": "random_state_string", - "scope": "read write", + "scope": scope, "redirect_uri": "http://example.org", "response_type": "code", "allow": True, } - response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + response = self.client.post( + reverse("oauth2_provider:authorize"), data=authcode_data + ) query_dict = parse_qs(urlparse(response["Location"]).query) return query_dict["code"].pop() @@ -536,9 +642,13 @@ def generate_pkce_codes(self, algorithm, length=43): """ code_verifier = get_random_string(length) if algorithm == "S256": - code_challenge = base64.urlsafe_b64encode( - hashlib.sha256(code_verifier.encode()).digest() - ).decode().rstrip("=") + code_challenge = ( + base64.urlsafe_b64encode( + hashlib.sha256(code_verifier.encode()).digest() + ) + .decode() + .rstrip("=") + ) else: code_challenge = code_verifier return code_verifier, code_challenge @@ -559,7 +669,9 @@ def get_pkce_auth(self, code_challenge, code_challenge_method): "code_challenge_method": code_challenge_method, } - response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + response = self.client.post( + reverse("oauth2_provider:authorize"), data=authcode_data + ) query_dict = parse_qs(urlparse(response["Location"]).query) oauth2_settings.PKCE_REQUIRED = False return query_dict["code"].pop() @@ -574,17 +686,23 @@ def test_basic_auth(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header( + self.application.client_id, self.application.client_secret + ) - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + self.assertEqual( + content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS + ) def test_refresh(self): """ @@ -596,11 +714,15 @@ def test_refresh(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header( + self.application.client_id, self.application.client_secret + ) - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) content = json.loads(response.content.decode("utf-8")) self.assertTrue("refresh_token" in content) @@ -609,23 +731,29 @@ def test_refresh(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) token_request_data = { "grant_type": "refresh_token", "refresh_token": content["refresh_token"], "scope": content["scope"], } - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertTrue("access_token" in content) # check refresh token cannot be used twice - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 400) content = json.loads(response.content.decode("utf-8")) self.assertTrue("invalid_grant" in content.values()) @@ -641,11 +769,15 @@ def test_refresh_with_grace_period(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header( + self.application.client_id, self.application.client_secret + ) - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) content = json.loads(response.content.decode("utf-8")) self.assertTrue("refresh_token" in content) @@ -654,9 +786,11 @@ def test_refresh_with_grace_period(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) token_request_data = { "grant_type": "refresh_token", @@ -664,7 +798,9 @@ def test_refresh_with_grace_period(self): "scope": content["scope"], } - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) @@ -673,7 +809,9 @@ def test_refresh_with_grace_period(self): first_refresh_token = content["refresh_token"] # check access token returns same data if used twice, see #497 - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertTrue("access_token" in content) @@ -693,11 +831,15 @@ def test_refresh_invalidates_old_tokens(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header( + self.application.client_id, self.application.client_secret + ) - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) content = json.loads(response.content.decode("utf-8")) rt = content["refresh_token"] @@ -708,7 +850,9 @@ def test_refresh_invalidates_old_tokens(self): "refresh_token": rt, "scope": content["scope"], } - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 200) refresh_token = RefreshToken.objects.filter(token=rt).first() @@ -725,11 +869,15 @@ def test_refresh_no_scopes(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header( + self.application.client_id, self.application.client_secret + ) - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) content = json.loads(response.content.decode("utf-8")) self.assertTrue("refresh_token" in content) @@ -737,7 +885,9 @@ def test_refresh_no_scopes(self): "grant_type": "refresh_token", "refresh_token": content["refresh_token"], } - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) @@ -753,11 +903,15 @@ def test_refresh_bad_scopes(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header( + self.application.client_id, self.application.client_secret + ) - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) content = json.loads(response.content.decode("utf-8")) self.assertTrue("refresh_token" in content) @@ -766,7 +920,9 @@ def test_refresh_bad_scopes(self): "refresh_token": content["refresh_token"], "scope": "read write nuke", } - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 400) def test_refresh_fail_repeating_requests(self): @@ -779,11 +935,15 @@ def test_refresh_fail_repeating_requests(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header( + self.application.client_id, self.application.client_secret + ) - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) content = json.loads(response.content.decode("utf-8")) self.assertTrue("refresh_token" in content) @@ -792,9 +952,13 @@ def test_refresh_fail_repeating_requests(self): "refresh_token": content["refresh_token"], "scope": content["scope"], } - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 200) - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 400) def test_refresh_repeating_requests(self): @@ -809,11 +973,15 @@ def test_refresh_repeating_requests(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header( + self.application.client_id, self.application.client_secret + ) - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) content = json.loads(response.content.decode("utf-8")) self.assertTrue("refresh_token" in content) @@ -822,18 +990,26 @@ def test_refresh_repeating_requests(self): "refresh_token": content["refresh_token"], "scope": content["scope"], } - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 200) - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 200) # try refreshing outside the refresh window, see #497 rt = RefreshToken.objects.get(token=content["refresh_token"]) self.assertIsNotNone(rt.revoked) - rt.revoked = timezone.now() - datetime.timedelta(minutes=10) # instead of mocking out datetime + rt.revoked = timezone.now() - datetime.timedelta( + minutes=10 + ) # instead of mocking out datetime rt.save() - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 400) oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS = 0 @@ -847,11 +1023,15 @@ def test_refresh_repeating_requests_non_rotating_tokens(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header( + self.application.client_id, self.application.client_secret + ) - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) content = json.loads(response.content.decode("utf-8")) self.assertTrue("refresh_token" in content) @@ -862,9 +1042,13 @@ def test_refresh_repeating_requests_non_rotating_tokens(self): } oauth2_settings.ROTATE_REFRESH_TOKEN = False - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 200) - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 200) oauth2_settings.ROTATE_REFRESH_TOKEN = True @@ -878,11 +1062,15 @@ def test_basic_auth_bad_authcode(self): token_request_data = { "grant_type": "authorization_code", "code": "BLAH", - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header( + self.application.client_id, self.application.client_secret + ) - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 400) def test_basic_auth_bad_granttype(self): @@ -894,11 +1082,15 @@ def test_basic_auth_bad_granttype(self): token_request_data = { "grant_type": "UNKNOWN", "code": "BLAH", - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header( + self.application.client_id, self.application.client_secret + ) - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 400) def test_basic_auth_grant_expired(self): @@ -907,18 +1099,27 @@ def test_basic_auth_grant_expired(self): """ self.client.login(username="test_user", password="123456") g = Grant( - application=self.application, user=self.test_user, code="BLAH", - expires=timezone.now(), redirect_uri="", scope="") + application=self.application, + user=self.test_user, + code="BLAH", + expires=timezone.now(), + redirect_uri="", + scope="", + ) g.save() token_request_data = { "grant_type": "authorization_code", "code": "BLAH", - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header( + self.application.client_id, self.application.client_secret + ) - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 400) def test_basic_auth_bad_secret(self): @@ -931,11 +1132,13 @@ def test_basic_auth_bad_secret(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, "BOOM!") - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 401) def test_basic_auth_wrong_auth_type(self): @@ -948,16 +1151,20 @@ def test_basic_auth_wrong_auth_type(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } - user_pass = "{0}:{1}".format(self.application.client_id, self.application.client_secret) + user_pass = "{0}:{1}".format( + self.application.client_id, self.application.client_secret + ) auth_string = base64.b64encode(user_pass.encode("utf-8")) auth_headers = { "HTTP_AUTHORIZATION": "Wrong " + auth_string.decode("utf-8"), } - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 401) def test_request_body_params(self): @@ -975,13 +1182,17 @@ def test_request_body_params(self): "client_secret": self.application.client_secret, } - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data + ) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + self.assertEqual( + content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS + ) def test_public(self): """ @@ -997,16 +1208,52 @@ def test_public(self): "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", - "client_id": self.application.client_id + "client_id": self.application.client_id, } - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data + ) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + self.assertEqual( + content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS + ) + + def test_id_token_public(self): + """ + Request an access token using client_type: public + """ + self.client.login(username="test_user", password="123456") + + self.application.client_type = Application.CLIENT_PUBLIC + self.application.save() + authorization_code = self.get_auth(scope="openid") + + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org", + "client_id": self.application.client_id, + "scope": "openid", + } + + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data + ) + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["token_type"], "Bearer") + self.assertEqual(content["scope"], "openid") + self.assertIn("access_token", content) + self.assertIn("id_token", content) + self.assertEqual( + content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS + ) def test_public_pkce_S256_authorize_get(self): """ @@ -1082,16 +1329,20 @@ def test_public_pkce_S256(self): "code": authorization_code, "redirect_uri": "http://example.org", "client_id": self.application.client_id, - "code_verifier": code_verifier + "code_verifier": code_verifier, } - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data + ) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + self.assertEqual( + content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS + ) oauth2_settings.PKCE_REQUIRED = False def test_public_pkce_plain(self): @@ -1112,16 +1363,20 @@ def test_public_pkce_plain(self): "code": authorization_code, "redirect_uri": "http://example.org", "client_id": self.application.client_id, - "code_verifier": code_verifier + "code_verifier": code_verifier, } - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data + ) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + self.assertEqual( + content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS + ) oauth2_settings.PKCE_REQUIRED = False def test_public_pkce_invalid_algorithm(self): @@ -1224,10 +1479,12 @@ def test_public_pkce_S256_invalid_code_verifier(self): "code": authorization_code, "redirect_uri": "http://example.org", "client_id": self.application.client_id, - "code_verifier": "invalid" + "code_verifier": "invalid", } - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data + ) self.assertEqual(response.status_code, 400) oauth2_settings.PKCE_REQUIRED = False @@ -1249,10 +1506,12 @@ def test_public_pkce_plain_invalid_code_verifier(self): "code": authorization_code, "redirect_uri": "http://example.org", "client_id": self.application.client_id, - "code_verifier": "invalid" + "code_verifier": "invalid", } - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data + ) self.assertEqual(response.status_code, 400) oauth2_settings.PKCE_REQUIRED = False @@ -1273,10 +1532,12 @@ def test_public_pkce_S256_missing_code_verifier(self): "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", - "client_id": self.application.client_id + "client_id": self.application.client_id, } - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data + ) self.assertEqual(response.status_code, 400) oauth2_settings.PKCE_REQUIRED = False @@ -1297,10 +1558,12 @@ def test_public_pkce_plain_missing_code_verifier(self): "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", - "client_id": self.application.client_id + "client_id": self.application.client_id, } - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data + ) self.assertEqual(response.status_code, 400) oauth2_settings.PKCE_REQUIRED = False @@ -1319,14 +1582,19 @@ def test_malicious_redirect_uri(self): "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "/../", - "client_id": self.application.client_id + "client_id": self.application.client_id, } - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data + ) self.assertEqual(response.status_code, 400) data = response.json() self.assertEqual(data["error"], "invalid_request") - self.assertEqual(data["error_description"], oauthlib_errors.MismatchingRedirectURIError.description) + self.assertEqual( + data["error_description"], + oauthlib_errors.MismatchingRedirectURIError.description, + ) def test_code_exchange_succeed_when_redirect_uri_match(self): """ @@ -1343,7 +1611,9 @@ def test_code_exchange_succeed_when_redirect_uri_match(self): "response_type": "code", "allow": True, } - response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + response = self.client.post( + reverse("oauth2_provider:authorize"), data=authcode_data + ) query_dict = parse_qs(urlparse(response["Location"]).query) authorization_code = query_dict["code"].pop() @@ -1351,17 +1621,23 @@ def test_code_exchange_succeed_when_redirect_uri_match(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org?foo=bar" + "redirect_uri": "http://example.org?foo=bar", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header( + self.application.client_id, self.application.client_secret + ) - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + self.assertEqual( + content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS + ) def test_code_exchange_fails_when_redirect_uri_does_not_match(self): """ @@ -1378,7 +1654,9 @@ def test_code_exchange_fails_when_redirect_uri_does_not_match(self): "response_type": "code", "allow": True, } - response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + response = self.client.post( + reverse("oauth2_provider:authorize"), data=authcode_data + ) query_dict = parse_qs(urlparse(response["Location"]).query) authorization_code = query_dict["code"].pop() @@ -1386,17 +1664,26 @@ def test_code_exchange_fails_when_redirect_uri_does_not_match(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org?foo=baraa" + "redirect_uri": "http://example.org?foo=baraa", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header( + self.application.client_id, self.application.client_secret + ) - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 400) data = response.json() self.assertEqual(data["error"], "invalid_request") - self.assertEqual(data["error_description"], oauthlib_errors.MismatchingRedirectURIError.description) + self.assertEqual( + data["error_description"], + oauthlib_errors.MismatchingRedirectURIError.description, + ) - def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_params(self): + def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_params( + self, + ): """ Tests code exchange succeed when redirect uri matches the one used for code request """ @@ -1413,7 +1700,9 @@ def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_param "response_type": "code", "allow": True, } - response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + response = self.client.post( + reverse("oauth2_provider:authorize"), data=authcode_data + ) query_dict = parse_qs(urlparse(response["Location"]).query) authorization_code = query_dict["code"].pop() @@ -1421,17 +1710,73 @@ def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_param token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.com?bar=baz&foo=bar" + "redirect_uri": "http://example.com?bar=baz&foo=bar", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header( + self.application.client_id, self.application.client_secret + ) - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + self.assertEqual( + content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS + ) + + def test_id_token_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_params( + self, + ): + """ + Tests code exchange succeed when redirect uri matches the one used for code request + """ + self.client.login(username="test_user", password="123456") + self.application.redirect_uris = "http://localhost http://example.com?foo=bar" + self.application.save() + + # retrieve a valid authorization code + authcode_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.com?bar=baz&foo=bar", + "response_type": "code", + "allow": True, + } + response = self.client.post( + reverse("oauth2_provider:authorize"), data=authcode_data + ) + query_dict = parse_qs(urlparse(response["Location"]).query) + authorization_code = query_dict["code"].pop() + + # exchange authorization code for a valid access token + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.com?bar=baz&foo=bar", + } + auth_headers = get_basic_auth_header( + self.application.client_id, self.application.client_secret + ) + + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["token_type"], "Bearer") + self.assertEqual(content["scope"], "openid") + self.assertIn("access_token", content) + self.assertIn("id_token", content) + self.assertEqual( + content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS + ) + def test_oob_as_html(self): """ @@ -1494,7 +1839,9 @@ def test_oob_as_json(self): "allow": True, } - response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + response = self.client.post( + reverse("oauth2_provider:authorize"), data=authcode_data + ) self.assertEqual(response.status_code, 200) self.assertRegex(response["Content-Type"], "^application/json") @@ -1511,13 +1858,18 @@ def test_oob_as_json(self): "client_secret": self.application.client_secret, } - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data + ) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + self.assertEqual( + content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS + ) + class TestAuthorizationCodeProtectedResource(BaseTest): @@ -1533,7 +1885,9 @@ def test_resource_access_allowed(self): "response_type": "code", "allow": True, } - response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + response = self.client.post( + reverse("oauth2_provider:authorize"), data=authcode_data + ) query_dict = parse_qs(urlparse(response["Location"]).query) authorization_code = query_dict["code"].pop() @@ -1541,11 +1895,15 @@ def test_resource_access_allowed(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header( + self.application.client_id, self.application.client_secret + ) - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) content = json.loads(response.content.decode("utf-8")) access_token = content["access_token"] @@ -1560,6 +1918,63 @@ def test_resource_access_allowed(self): response = view(request) self.assertEqual(response, "This is a protected resource") + def test_id_token_resource_access_allowed(self): + self.client.login(username="test_user", password="123456") + + # retrieve a valid authorization code + authcode_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.org", + "response_type": "code", + "allow": True, + } + response = self.client.post( + reverse("oauth2_provider:authorize"), data=authcode_data + ) + query_dict = parse_qs(urlparse(response["Location"]).query) + authorization_code = query_dict["code"].pop() + + # exchange authorization code for a valid access token + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org", + } + auth_headers = get_basic_auth_header( + self.application.client_id, self.application.client_secret + ) + + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) + content = json.loads(response.content.decode("utf-8")) + access_token = content["access_token"] + id_token = content["id_token"] + + # use token to access the resource + auth_headers = { + "HTTP_AUTHORIZATION": "Bearer " + access_token, + } + request = self.factory.get("/fake-resource", **auth_headers) + request.user = self.test_user + + view = ResourceView.as_view() + response = view(request) + self.assertEqual(response, "This is a protected resource") + + # use id_token to access the resource + auth_headers = { + "HTTP_AUTHORIZATION": "Bearer " + id_token, + } + request = self.factory.get("/fake-resource", **auth_headers) + request.user = self.test_user + + view = ResourceView.as_view() + response = view(request) + self.assertEqual(response, "This is a protected resource") + def test_resource_access_deny(self): auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + "faketoken", @@ -1573,7 +1988,6 @@ def test_resource_access_deny(self): class TestDefaultScopes(BaseTest): - def test_pre_auth_default_scopes(self): """ Test response for a valid client_id with response_type: code using default scopes From e28b8e32d56b864cb4b9521b84355b83004e2f30 Mon Sep 17 00:00:00 2001 From: Wiliam Souza Date: Sun, 29 Oct 2017 17:14:40 -0200 Subject: [PATCH 09/57] Add OpenID connect implicit flow tests --- tests/test_implicit.py | 206 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 205 insertions(+), 1 deletion(-) diff --git a/tests/test_implicit.py b/tests/test_implicit.py index b51d0e1da..fcb705f9c 100644 --- a/tests/test_implicit.py +++ b/tests/test_implicit.py @@ -1,9 +1,13 @@ from urllib.parse import parse_qs, urlparse +import json + from django.contrib.auth import get_user_model from django.test import RequestFactory, TestCase from django.urls import reverse +from jwcrypto import jwk, jwt + from oauth2_provider.models import get_application_model from oauth2_provider.settings import oauth2_settings from oauth2_provider.views import ProtectedResourceView @@ -33,8 +37,14 @@ def setUp(self): authorization_grant_type=Application.GRANT_IMPLICIT, ) - oauth2_settings._SCOPES = ["read", "write"] + oauth2_settings._SCOPES = ["read", "write", "openid"] oauth2_settings._DEFAULT_SCOPES = ["read"] + oauth2_settings.SCOPES = { + "read": "Reading scope", + "write": "Writing scope", + "openid": "OpenID connect" + } + self.key = jwk.JWK.from_pem(oauth2_settings.RSA_PRIVATE_KEY.encode("utf8")) def tearDown(self): self.application.delete() @@ -265,3 +275,197 @@ def test_resource_access_allowed(self): view = ResourceView.as_view() response = view(request) self.assertEqual(response, "This is a protected resource") + + +class TestOpenIDConnectImplicitFlow(BaseTest): + def test_id_token_post_auth_allow(self): + """ + Test authorization code is given for an allowed request with response_type: id_token + """ + self.client.login(username="test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "nonce": "random_nonce_string", + "scope": "openid", + "redirect_uri": "http://example.org", + "response_type": "id_token", + "allow": True, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("http://example.org#", response["Location"]) + self.assertNotIn("access_token=", response["Location"]) + self.assertIn("id_token=", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) + + uri_query = urlparse(response["Location"]).fragment + uri_query_params = dict(parse_qs(uri_query, keep_blank_values=True, strict_parsing=True)) + id_token = uri_query_params["id_token"][0] + jwt_token = jwt.JWT(key=self.key, jwt=id_token) + claims = json.loads(jwt_token.claims) + self.assertIn("nonce", claims) + self.assertNotIn("at_hash", claims) + + def test_id_token_skip_authorization_completely(self): + """ + If application.skip_authorization = True, should skip the authorization page. + """ + self.client.login(username="test_user", password="123456") + self.application.skip_authorization = True + self.application.save() + + query_string = urlencode({ + "client_id": self.application.client_id, + "response_type": "id_token", + "state": "random_state_string", + "nonce": "random_nonce_string", + "scope": "openid", + "redirect_uri": "http://example.org", + }) + + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 302) + self.assertIn("http://example.org#", response["Location"]) + self.assertNotIn("access_token=", response["Location"]) + self.assertIn("id_token=", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) + + uri_query = urlparse(response["Location"]).fragment + uri_query_params = dict(parse_qs(uri_query, keep_blank_values=True, strict_parsing=True)) + id_token = uri_query_params["id_token"][0] + jwt_token = jwt.JWT(key=self.key, jwt=id_token) + claims = json.loads(jwt_token.claims) + self.assertIn("nonce", claims) + self.assertNotIn("at_hash", claims) + + def test_id_token_skip_authorization_completely_missing_nonce(self): + """ + If application.skip_authorization = True, should skip the authorization page. + """ + self.client.login(username="test_user", password="123456") + self.application.skip_authorization = True + self.application.save() + + query_string = urlencode({ + "client_id": self.application.client_id, + "response_type": "id_token", + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.org", + }) + + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 302) + self.assertIn("error=invalid_request", response["Location"]) + self.assertIn("error_description=Request+is+missing+mandatory+nonce+paramete", response["Location"]) + + def test_id_token_post_auth_deny(self): + """ + Test error when resource owner deny access + """ + self.client.login(username="test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "nonce": "random_nonce_string", + "scope": "openid", + "redirect_uri": "http://example.org", + "response_type": "id_token", + "allow": False, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("error=access_denied", response["Location"]) + + def test_access_token_and_id_token_post_auth_allow(self): + """ + Test authorization code is given for an allowed request with response_type: token + """ + self.client.login(username="test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "nonce": "random_nonce_string", + "scope": "openid", + "redirect_uri": "http://example.org", + "response_type": "id_token token", + "allow": True, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("http://example.org#", response["Location"]) + self.assertIn("access_token=", response["Location"]) + self.assertIn("id_token=", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) + + uri_query = urlparse(response["Location"]).fragment + uri_query_params = dict(parse_qs(uri_query, keep_blank_values=True, strict_parsing=True)) + id_token = uri_query_params["id_token"][0] + jwt_token = jwt.JWT(key=self.key, jwt=id_token) + claims = json.loads(jwt_token.claims) + self.assertIn("nonce", claims) + self.assertIn("at_hash", claims) + + def test_access_token_and_id_token_skip_authorization_completely(self): + """ + If application.skip_authorization = True, should skip the authorization page. + """ + self.client.login(username="test_user", password="123456") + self.application.skip_authorization = True + self.application.save() + + query_string = urlencode({ + "client_id": self.application.client_id, + "response_type": "id_token token", + "state": "random_state_string", + "nonce": "random_nonce_string", + "scope": "openid", + "redirect_uri": "http://example.org", + }) + + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 302) + self.assertIn("http://example.org#", response["Location"]) + self.assertIn("access_token=", response["Location"]) + self.assertIn("id_token=", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) + + uri_query = urlparse(response["Location"]).fragment + uri_query_params = dict(parse_qs(uri_query, keep_blank_values=True, strict_parsing=True)) + id_token = uri_query_params["id_token"][0] + jwt_token = jwt.JWT(key=self.key, jwt=id_token) + claims = json.loads(jwt_token.claims) + self.assertIn("nonce", claims) + self.assertIn("at_hash", claims) + + def test_access_token_and_id_token_post_auth_deny(self): + """ + Test error when resource owner deny access + """ + self.client.login(username="test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.org", + "response_type": "id_token token", + "allow": False, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("error=access_denied", response["Location"]) From 9680cc38412d9b2ddc6191d264e41c4418fe7601 Mon Sep 17 00:00:00 2001 From: Wiliam Souza Date: Sun, 29 Oct 2017 17:17:01 -0200 Subject: [PATCH 10/57] Add validate_user_match method to OAuth2Validator --- oauth2_provider/oauth2_validators.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 8371be6dc..a59181621 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -795,3 +795,9 @@ def validate_id_token(self, token, scopes, request): request.access_token = id_token return True + + def validate_user_match(self, id_token_hint, scopes, claims, request): + # TODO: Fix to validate when necessary acording + # https://github.com/idan/oauthlib/blob/master/oauthlib/oauth2/rfc6749/request_validator.py#L556 + # http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest id_token_hint section + return True From f35889f60a32379595e7c2559edb89af9ab766b7 Mon Sep 17 00:00:00 2001 From: Wiliam Souza Date: Sun, 29 Oct 2017 17:17:43 -0200 Subject: [PATCH 11/57] Add RSA_PRIVATE_KEY setting with blank value --- oauth2_provider/settings.py | 1 + 1 file changed, 1 insertion(+) diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index 41ad45b57..2f6d858c0 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -58,6 +58,7 @@ "REFRESH_TOKEN_MODEL": REFRESH_TOKEN_MODEL, "REQUEST_APPROVAL_PROMPT": "force", "ALLOWED_REDIRECT_URI_SCHEMES": ["http", "https"], + "RSA_PRIVATE_KEY": "", # Special settings that will be evaluated at runtime "_SCOPES": [], From c773602a9e5a48ee7f42a4dc6ed0fbbba776faaa Mon Sep 17 00:00:00 2001 From: Wiliam Souza Date: Sun, 29 Oct 2017 17:17:58 -0200 Subject: [PATCH 12/57] Update tox --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index 9d3dcc455..972655ddd 100644 --- a/tox.ini +++ b/tox.ini @@ -34,6 +34,7 @@ deps = pytest-xdist py27: mock requests + jwcrypto [testenv:py37-docs] basepython = python From a9fbceee2b9d8c8ee3fb83cd4081465284d81a7d Mon Sep 17 00:00:00 2001 From: Wiliam Souza Date: Tue, 19 Dec 2017 15:55:06 -0200 Subject: [PATCH 13/57] Add get_jwt_bearer_token to OAuth2Validator --- oauth2_provider/oauth2_validators.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index a59181621..e7e19e082 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -717,6 +717,9 @@ def _save_id_token(self, token, request, expires, *args, **kwargs): ) return id_token + def get_jwt_bearer_token(self, token, token_handler, request): + return self.get_id_token(token, token_handler, request) + def get_id_token(self, token, token_handler, request): key = jwk.JWK.from_pem(oauth2_settings.RSA_PRIVATE_KEY.encode("utf8")) From a482133c0c937d57bc10e00f2e8be73f1d9f2598 Mon Sep 17 00:00:00 2001 From: Wiliam Souza Date: Tue, 19 Dec 2017 15:55:35 -0200 Subject: [PATCH 14/57] Add validate_jwt_bearer_token to OAuth2Validator --- oauth2_provider/oauth2_validators.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index e7e19e082..1a5ca452f 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -775,6 +775,9 @@ def get_id_token(self, token, token_handler, request): request.id_token = id_token return jwt_token.serialize() + def validate_jwt_bearer_token(self, token, scopes, request): + return self.validate_id_token(token, scopes, request) + def validate_id_token(self, token, scopes, request): """ When users try to access resources, check that provided id_token is valid From ee1e8642401bce4cfcf4b0bcf1eae568e3db68dd Mon Sep 17 00:00:00 2001 From: Wiliam Souza Date: Tue, 19 Dec 2017 15:58:19 -0200 Subject: [PATCH 15/57] Change OAuth2Validator.validate_id_token default return value to False to avoid validation security breach --- oauth2_provider/oauth2_validators.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 1a5ca452f..f025b826e 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -789,18 +789,18 @@ def validate_id_token(self, token, scopes, request): try: jwt_token = jwt.JWT(key=key, jwt=token) + id_token = IDToken.objects.get(token=jwt_token.serialize()) + request.client = id_token.application + request.user = id_token.user + request.scopes = scopes + # this is needed by django rest framework + request.access_token = id_token + return True except (JWException, JWTExpired): # TODO: This is the base exception of all jwcrypto return False - id_token = IDToken.objects.get(token=jwt_token.serialize()) - request.client = id_token.application - request.user = id_token.user - request.scopes = scopes - # this is needed by django rest framework - request.access_token = id_token - - return True + return False def validate_user_match(self, id_token_hint, scopes, claims, request): # TODO: Fix to validate when necessary acording From c41b412bb876f0257c72c3518860b8bb9afdb09b Mon Sep 17 00:00:00 2001 From: Wiliam Souza Date: Mon, 15 Jan 2018 18:19:32 -0200 Subject: [PATCH 16/57] Change to use .encode to avoid py2.7 tox test error --- oauth2_provider/oauth2_validators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index f025b826e..12755e8f7 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -754,7 +754,7 @@ def get_id_token(self, token, token_handler, request): acess_token = token["access_token"] sha256 = hashlib.sha256(acess_token.encode("ascii")) bits128 = sha256.hexdigest()[:16] - at_hash = base64.urlsafe_b64encode(bytes(bits128, "ascii")) + at_hash = base64.urlsafe_b64encode(bits128.encode("ascii")) claims['at_hash'] = at_hash.decode("utf8") # TODO: create a function to check if we should include c_hash From e16c1d2ba6d7fa696241c2016650c85e3a989d56 Mon Sep 17 00:00:00 2001 From: Wiliam Souza Date: Mon, 15 Jan 2018 18:20:46 -0200 Subject: [PATCH 17/57] Add OpenID connect hybrid flow tests --- tests/test_hybrid.py | 1253 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1253 insertions(+) create mode 100644 tests/test_hybrid.py diff --git a/tests/test_hybrid.py b/tests/test_hybrid.py new file mode 100644 index 000000000..43f748316 --- /dev/null +++ b/tests/test_hybrid.py @@ -0,0 +1,1253 @@ +from __future__ import unicode_literals + +import base64 +import datetime +import json + +from django.contrib.auth import get_user_model +from django.test import RequestFactory, TestCase +from django.urls import reverse +from django.utils import timezone +from oauthlib.oauth2.rfc6749 import errors as oauthlib_errors + +from oauth2_provider.compat import parse_qs, urlencode, urlparse +from oauth2_provider.models import ( + get_access_token_model, get_application_model, + get_grant_model, get_refresh_token_model +) +from oauth2_provider.settings import oauth2_settings +from oauth2_provider.views import ProtectedResourceView + +from .utils import get_basic_auth_header + + +Application = get_application_model() +AccessToken = get_access_token_model() +Grant = get_grant_model() +RefreshToken = get_refresh_token_model() +UserModel = get_user_model() + + +# mocking a protected resource view +class ResourceView(ProtectedResourceView): + def get(self, request, *args, **kwargs): + return "This is a protected resource" + + +class BaseTest(TestCase): + def setUp(self): + self.factory = RequestFactory() + self.hy_test_user = UserModel.objects.create_user("hy_test_user", "test_hy@example.com", "123456") + self.hy_dev_user = UserModel.objects.create_user("hy_dev_user", "dev_hy@example.com", "123456") + + oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["http", "custom-scheme"] + + self.application = Application( + name="Hybrid Test Application", + redirect_uris=( + "http://localhost http://example.com http://example.org custom-scheme://example.com" + ), + user=self.hy_dev_user, + client_type=Application.CLIENT_CONFIDENTIAL, + authorization_grant_type=Application.GRANT_OPENID_HYBRID, + ) + self.application.save() + + oauth2_settings._SCOPES = ["read", "write", "openid"] + oauth2_settings._DEFAULT_SCOPES = ["read", "write"] + oauth2_settings.SCOPES = { + "read": "Reading scope", + "write": "Writing scope", + "openid": "OpenID connect" + } + + def tearDown(self): + self.application.delete() + self.hy_test_user.delete() + self.hy_dev_user.delete() + + +class TestRegressionIssue315Hybrid(BaseTest): + """ + Test to avoid regression for the issue 315: request object + was being reassigned when getting AuthorizationView + """ + + def test_request_is_not_overwritten_code_token(self): + self.client.login(username="hy_test_user", password="123456") + query_string = urlencode({ + "client_id": self.application.client_id, + "response_type": "code token", + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "http://example.org", + }) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + assert "request" not in response.context_data + + def test_request_is_not_overwritten_code_id_token(self): + self.client.login(username="hy_test_user", password="123456") + query_string = urlencode({ + "client_id": self.application.client_id, + "response_type": "code id_token", + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "http://example.org", + }) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + assert "request" not in response.context_data + + def test_request_is_not_overwritten_code_id_token_token(self): + self.client.login(username="hy_test_user", password="123456") + query_string = urlencode({ + "client_id": self.application.client_id, + "response_type": "code id_token token", + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "http://example.org", + }) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + assert "request" not in response.context_data + + +class TestHybridView(BaseTest): + def test_skip_authorization_completely(self): + """ + If application.skip_authorization = True, should skip the authorization page. + """ + self.client.login(username="hy_test_user", password="123456") + self.application.skip_authorization = True + self.application.save() + + query_string = urlencode({ + "client_id": self.application.client_id, + "response_type": "code", + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + }) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 302) + + def test_id_token_skip_authorization_completely(self): + """ + If application.skip_authorization = True, should skip the authorization page. + """ + self.client.login(username="hy_test_user", password="123456") + self.application.skip_authorization = True + self.application.save() + + query_string = urlencode({ + "client_id": self.application.client_id, + "response_type": "code", + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.org", + }) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 302) + + def test_pre_auth_invalid_client(self): + """ + Test error for an invalid client_id with response_type: code + """ + self.client.login(username="hy_test_user", password="123456") + + query_string = urlencode({ + "client_id": "fakeclientid", + "response_type": "code", + }) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.context_data["url"], + "?error=invalid_request&error_description=Invalid+client_id+parameter+value." + ) + + def test_pre_auth_valid_client(self): + """ + Test response for a valid client_id with response_type: code + """ + self.client.login(username="hy_test_user", password="123456") + + query_string = urlencode({ + "client_id": self.application.client_id, + "response_type": "code id_token", + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + }) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + # check form is in context and form params are valid + self.assertIn("form", response.context) + + form = response.context["form"] + self.assertEqual(form["redirect_uri"].value(), "http://example.org") + self.assertEqual(form["state"].value(), "random_state_string") + self.assertEqual(form["scope"].value(), "read write") + self.assertEqual(form["client_id"].value(), self.application.client_id) + + def test_id_token_pre_auth_valid_client(self): + """ + Test response for a valid client_id with response_type: code + """ + self.client.login(username="hy_test_user", password="123456") + + query_string = urlencode({ + "client_id": self.application.client_id, + "response_type": "code id_token", + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.org", + }) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + # check form is in context and form params are valid + self.assertIn("form", response.context) + + form = response.context["form"] + self.assertEqual(form["redirect_uri"].value(), "http://example.org") + self.assertEqual(form["state"].value(), "random_state_string") + self.assertEqual(form["scope"].value(), "openid") + self.assertEqual(form["client_id"].value(), self.application.client_id) + + def test_pre_auth_valid_client_custom_redirect_uri_scheme(self): + """ + Test response for a valid client_id with response_type: code + using a non-standard, but allowed, redirect_uri scheme. + """ + self.client.login(username="hy_test_user", password="123456") + + query_string = urlencode({ + "client_id": self.application.client_id, + "response_type": "code id_token", + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "custom-scheme://example.com", + }) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + # check form is in context and form params are valid + self.assertIn("form", response.context) + + form = response.context["form"] + self.assertEqual(form["redirect_uri"].value(), "custom-scheme://example.com") + self.assertEqual(form["state"].value(), "random_state_string") + self.assertEqual(form["scope"].value(), "read write") + self.assertEqual(form["client_id"].value(), self.application.client_id) + + def test_pre_auth_approval_prompt(self): + tok = AccessToken.objects.create( + user=self.hy_test_user, token="1234567890", + application=self.application, + expires=timezone.now() + datetime.timedelta(days=1), + scope="read write" + ) + self.client.login(username="hy_test_user", password="123456") + query_string = urlencode({ + "client_id": self.application.client_id, + "response_type": "code id_token", + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + "approval_prompt": "auto", + }) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + response = self.client.get(url) + self.assertEqual(response.status_code, 302) + # user already authorized the application, but with different scopes: prompt them. + tok.scope = "read" + tok.save() + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + def test_pre_auth_approval_prompt_default(self): + oauth2_settings.REQUEST_APPROVAL_PROMPT = "force" + self.assertEqual(oauth2_settings.REQUEST_APPROVAL_PROMPT, "force") + + AccessToken.objects.create( + user=self.hy_test_user, token="1234567890", + application=self.application, + expires=timezone.now() + datetime.timedelta(days=1), + scope="read write" + ) + self.client.login(username="hy_test_user", password="123456") + query_string = urlencode({ + "client_id": self.application.client_id, + "response_type": "code id_token", + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + }) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + def test_pre_auth_approval_prompt_default_override(self): + oauth2_settings.REQUEST_APPROVAL_PROMPT = "auto" + + AccessToken.objects.create( + user=self.hy_test_user, token="1234567890", + application=self.application, + expires=timezone.now() + datetime.timedelta(days=1), + scope="read write" + ) + self.client.login(username="hy_test_user", password="123456") + query_string = urlencode({ + "client_id": self.application.client_id, + "response_type": "code", + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + }) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + response = self.client.get(url) + self.assertEqual(response.status_code, 302) + + def test_pre_auth_default_redirect(self): + """ + Test for default redirect uri if omitted from query string with response_type: code + """ + self.client.login(username="hy_test_user", password="123456") + + query_string = urlencode({ + "client_id": self.application.client_id, + "response_type": "code id_token", + }) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + form = response.context["form"] + self.assertEqual(form["redirect_uri"].value(), "http://localhost") + + def test_pre_auth_forbibben_redirect(self): + """ + Test error when passing a forbidden redirect_uri in query string with response_type: code + """ + self.client.login(username="hy_test_user", password="123456") + + query_string = urlencode({ + "client_id": self.application.client_id, + "response_type": "code", + "redirect_uri": "http://forbidden.it", + }) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 400) + + def test_pre_auth_wrong_response_type(self): + """ + Test error when passing a wrong response_type in query string + """ + self.client.login(username="hy_test_user", password="123456") + + query_string = urlencode({ + "client_id": self.application.client_id, + "response_type": "WRONG", + }) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 302) + self.assertIn("error=unsupported_response_type", response["Location"]) + + def test_code_post_auth_allow_code_token(self): + """ + Test authorization code is given for an allowed request with response_type: code + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "http://example.org", + "response_type": "code token", + "allow": True, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("http://example.org", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) + self.assertIn("code=", response["Location"]) + self.assertIn("access_token=", response["Location"]) + + def test_code_post_auth_allow_code_id_token(self): + """ + Test authorization code is given for an allowed request with response_type: code + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "http://example.org", + "response_type": "code id_token", + "allow": True, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("http://example.org", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) + self.assertIn("code=", response["Location"]) + self.assertIn("id_token=", response["Location"]) + + def test_code_post_auth_allow_code_id_token_token(self): + """ + Test authorization code is given for an allowed request with response_type: code + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "http://example.org", + "response_type": "code id_token token", + "allow": True, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("http://example.org", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) + self.assertIn("code=", response["Location"]) + self.assertIn("id_token=", response["Location"]) + self.assertIn("access_token=", response["Location"]) + + def test_id_token_code_post_auth_allow(self): + """ + Test authorization code is given for an allowed request with response_type: code + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.org", + "response_type": "code id_token", + "allow": True, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("http://example.org", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) + self.assertIn("code=", response["Location"]) + self.assertIn("id_token=", response["Location"]) + + def test_code_post_auth_deny(self): + """ + Test error when resource owner deny access + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + "response_type": "code", + "allow": False, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("error=access_denied", response["Location"]) + + def test_code_post_auth_bad_responsetype(self): + """ + Test authorization code is given for an allowed request with a response_type not supported + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + "response_type": "UNKNOWN", + "allow": True, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("http://example.org?error", response["Location"]) + + def test_code_post_auth_forbidden_redirect_uri(self): + """ + Test authorization code is given for an allowed request with a forbidden redirect_uri + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://forbidden.it", + "response_type": "code", + "allow": True, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 400) + + def test_code_post_auth_malicious_redirect_uri(self): + """ + Test validation of a malicious redirect_uri + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "/../", + "response_type": "code", + "allow": True, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 400) + + def test_code_post_auth_allow_custom_redirect_uri_scheme_code_token(self): + """ + Test authorization code is given for an allowed request with response_type: code + using a non-standard, but allowed, redirect_uri scheme. + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "custom-scheme://example.com", + "response_type": "code token", + "allow": True, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("custom-scheme://example.com", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) + self.assertIn("code=", response["Location"]) + self.assertIn("access_token=", response["Location"]) + + def test_code_post_auth_allow_custom_redirect_uri_scheme_code_id_token(self): + """ + Test authorization code is given for an allowed request with response_type: code + using a non-standard, but allowed, redirect_uri scheme. + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "custom-scheme://example.com", + "response_type": "code id_token", + "allow": True, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("custom-scheme://example.com", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) + self.assertIn("code=", response["Location"]) + self.assertIn("id_token=", response["Location"]) + + def test_code_post_auth_allow_custom_redirect_uri_scheme_code_id_token_token(self): + """ + Test authorization code is given for an allowed request with response_type: code + using a non-standard, but allowed, redirect_uri scheme. + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "custom-scheme://example.com", + "response_type": "code id_token token", + "allow": True, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("custom-scheme://example.com", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) + self.assertIn("code=", response["Location"]) + self.assertIn("id_token=", response["Location"]) + self.assertIn("access_token=", response["Location"]) + + def test_code_post_auth_deny_custom_redirect_uri_scheme(self): + """ + Test error when resource owner deny access + using a non-standard, but allowed, redirect_uri scheme. + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "custom-scheme://example.com", + "response_type": "code", + "allow": False, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("custom-scheme://example.com?", response["Location"]) + self.assertIn("error=access_denied", response["Location"]) + + def test_code_post_auth_redirection_uri_with_querystring_code_token(self): + """ + Tests that a redirection uri with query string is allowed + and query string is retained on redirection. + See http://tools.ietf.org/html/rfc6749#section-3.1.2 + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "http://example.com?foo=bar", + "response_type": "code token", + "allow": True, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("http://example.com?foo=bar", response["Location"]) + self.assertIn("code=", response["Location"]) + self.assertIn("access_token=", response["Location"]) + + def test_code_post_auth_redirection_uri_with_querystring_code_id_token(self): + """ + Tests that a redirection uri with query string is allowed + and query string is retained on redirection. + See http://tools.ietf.org/html/rfc6749#section-3.1.2 + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "http://example.com?foo=bar", + "response_type": "code id_token", + "allow": True, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("http://example.com?foo=bar", response["Location"]) + self.assertIn("code=", response["Location"]) + self.assertIn("id_token=", response["Location"]) + + def test_code_post_auth_redirection_uri_with_querystring_code_id_token_token(self): + """ + Tests that a redirection uri with query string is allowed + and query string is retained on redirection. + See http://tools.ietf.org/html/rfc6749#section-3.1.2 + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "http://example.com?foo=bar", + "response_type": "code id_token token", + "allow": True, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("http://example.com?foo=bar", response["Location"]) + self.assertIn("code=", response["Location"]) + self.assertIn("id_token=", response["Location"]) + self.assertIn("access_token=", response["Location"]) + + def test_code_post_auth_failing_redirection_uri_with_querystring(self): + """ + Test that in case of error the querystring of the redirection uri is preserved + + See https://github.com/evonove/django-oauth-toolkit/issues/238 + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.com?foo=bar", + "response_type": "code", + "allow": False, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertEqual("http://example.com?foo=bar&error=access_denied", response["Location"]) + + def test_code_post_auth_fails_when_redirect_uri_path_is_invalid(self): + """ + Tests that a redirection uri is matched using scheme + netloc + path + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.com/a?foo=bar", + "response_type": "code", + "allow": True, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 400) + + +class TestHybridTokenView(BaseTest): + def get_auth(self, scope="read write"): + """ + Helper method to retrieve a valid authorization code + """ + authcode_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": scope, + "redirect_uri": "http://example.org", + "response_type": "code id_token", + "allow": True, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + fragment_dict = parse_qs(urlparse(response["Location"]).fragment) + return fragment_dict["code"].pop() + + def test_basic_auth(self): + """ + Request an access token using basic authentication for client authentication + """ + self.client.login(username="hy_test_user", password="123456") + authorization_code = self.get_auth() + + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org" + } + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["token_type"], "Bearer") + self.assertEqual(content["scope"], "read write") + self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + + def test_basic_auth_bad_authcode(self): + """ + Request an access token using a bad authorization code + """ + self.client.login(username="hy_test_user", password="123456") + + token_request_data = { + "grant_type": "authorization_code", + "code": "BLAH", + "redirect_uri": "http://example.org" + } + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 401) + + def test_basic_auth_bad_granttype(self): + """ + Request an access token using a bad grant_type string + """ + self.client.login(username="hy_test_user", password="123456") + + token_request_data = { + "grant_type": "UNKNOWN", + "code": "BLAH", + "redirect_uri": "http://example.org" + } + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 400) + + def test_basic_auth_grant_expired(self): + """ + Request an access token using an expired grant token + """ + self.client.login(username="hy_test_user", password="123456") + g = Grant( + application=self.application, user=self.hy_test_user, code="BLAH", + expires=timezone.now(), redirect_uri="", scope="") + g.save() + + token_request_data = { + "grant_type": "authorization_code", + "code": "BLAH", + "redirect_uri": "http://example.org" + } + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 401) + + def test_basic_auth_bad_secret(self): + """ + Request an access token using basic authentication for client authentication + """ + self.client.login(username="hy_test_user", password="123456") + authorization_code = self.get_auth() + + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org" + } + auth_headers = get_basic_auth_header(self.application.client_id, "BOOM!") + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 401) + + def test_basic_auth_wrong_auth_type(self): + """ + Request an access token using basic authentication for client authentication + """ + self.client.login(username="hy_test_user", password="123456") + authorization_code = self.get_auth() + + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org" + } + + user_pass = "{0}:{1}".format(self.application.client_id, self.application.client_secret) + auth_string = base64.b64encode(user_pass.encode("utf-8")) + auth_headers = { + "HTTP_AUTHORIZATION": "Wrong " + auth_string.decode("utf-8"), + } + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 401) + + def test_request_body_params(self): + """ + Request an access token using client_type: public + """ + self.client.login(username="hy_test_user", password="123456") + authorization_code = self.get_auth() + + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org", + "client_id": self.application.client_id, + "client_secret": self.application.client_secret, + } + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["token_type"], "Bearer") + self.assertEqual(content["scope"], "read write") + self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + + def test_public(self): + """ + Request an access token using client_type: public + """ + self.client.login(username="hy_test_user", password="123456") + + self.application.client_type = Application.CLIENT_PUBLIC + self.application.save() + authorization_code = self.get_auth() + + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org", + "client_id": self.application.client_id + } + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["token_type"], "Bearer") + self.assertEqual(content["scope"], "read write") + self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + + def test_id_token_public(self): + """ + Request an access token using client_type: public + """ + self.client.login(username="hy_test_user", password="123456") + + self.application.client_type = Application.CLIENT_PUBLIC + self.application.save() + authorization_code = self.get_auth(scope="openid") + + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org", + "client_id": self.application.client_id, + "scope": "openid", + } + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["token_type"], "Bearer") + self.assertEqual(content["scope"], "openid") + self.assertIn("access_token", content) + self.assertIn("id_token", content) + self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + + def test_malicious_redirect_uri(self): + """ + Request an access token using client_type: public and ensure redirect_uri is + properly validated. + """ + self.client.login(username="hy_test_user", password="123456") + + self.application.client_type = Application.CLIENT_PUBLIC + self.application.save() + authorization_code = self.get_auth() + + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "/../", + "client_id": self.application.client_id + } + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + self.assertEqual(response.status_code, 400) + data = response.json() + self.assertEqual(data["error"], "invalid_request") + self.assertEqual(data["error_description"], oauthlib_errors.MismatchingRedirectURIError.description) + + def test_code_exchange_succeed_when_redirect_uri_match(self): + """ + Tests code exchange succeed when redirect uri matches the one used for code request + """ + self.client.login(username="hy_test_user", password="123456") + + # retrieve a valid authorization code + authcode_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "http://example.org?foo=bar", + "response_type": "code token", + "allow": True, + } + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + fragment_dict = parse_qs(urlparse(response["Location"]).fragment) + authorization_code = fragment_dict["code"].pop() + + # exchange authorization code for a valid access token + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org?foo=bar" + } + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["token_type"], "Bearer") + self.assertEqual(content["scope"], "openid read write") + self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + + def test_code_exchange_fails_when_redirect_uri_does_not_match(self): + """ + Tests code exchange fails when redirect uri does not match the one used for code request + """ + self.client.login(username="hy_test_user", password="123456") + + # retrieve a valid authorization code + authcode_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "http://example.org?foo=bar", + "response_type": "code token", + "allow": True, + } + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + query_dict = parse_qs(urlparse(response["Location"]).fragment) + authorization_code = query_dict["code"].pop() + + # exchange authorization code for a valid access token + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org?foo=baraa" + } + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 400) + data = response.json() + self.assertEqual(data["error"], "invalid_request") + self.assertEqual(data["error_description"], oauthlib_errors.MismatchingRedirectURIError.description) + + def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_params(self): + """ + Tests code exchange succeed when redirect uri matches the one used for code request + """ + self.client.login(username="hy_test_user", password="123456") + self.application.redirect_uris = "http://localhost http://example.com?foo=bar" + self.application.save() + + # retrieve a valid authorization code + authcode_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "http://example.com?bar=baz&foo=bar", + "response_type": "code token", + "allow": True, + } + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + fragment_dict = parse_qs(urlparse(response["Location"]).fragment) + authorization_code = fragment_dict["code"].pop() + + # exchange authorization code for a valid access token + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.com?bar=baz&foo=bar" + } + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["token_type"], "Bearer") + self.assertEqual(content["scope"], "openid read write") + self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + + def test_id_token_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_params(self): + """ + Tests code exchange succeed when redirect uri matches the one used for code request + """ + self.client.login(username="hy_test_user", password="123456") + self.application.redirect_uris = "http://localhost http://example.com?foo=bar" + self.application.save() + + # retrieve a valid authorization code + authcode_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.com?bar=baz&foo=bar", + "response_type": "code token", + "allow": True, + } + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + fragment_dict = parse_qs(urlparse(response["Location"]).fragment) + authorization_code = fragment_dict["code"].pop() + + # exchange authorization code for a valid access token + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.com?bar=baz&foo=bar", + } + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["token_type"], "Bearer") + self.assertEqual(content["scope"], "openid") + self.assertIn("access_token", content) + self.assertIn("id_token", content) + self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + + +class TestHybridProtectedResource(BaseTest): + def test_resource_access_allowed(self): + self.client.login(username="hy_test_user", password="123456") + + # retrieve a valid authorization code + authcode_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "http://example.org", + "response_type": "code token", + "allow": True, + } + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + fragment_dict = parse_qs(urlparse(response["Location"]).fragment) + authorization_code = fragment_dict["code"].pop() + + # exchange authorization code for a valid access token + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org" + } + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + content = json.loads(response.content.decode("utf-8")) + access_token = content["access_token"] + + # use token to access the resource + auth_headers = { + "HTTP_AUTHORIZATION": "Bearer " + access_token, + } + request = self.factory.get("/fake-resource", **auth_headers) + request.user = self.hy_test_user + + view = ResourceView.as_view() + response = view(request) + self.assertEqual(response, "This is a protected resource") + + def test_id_token_resource_access_allowed(self): + self.client.login(username="hy_test_user", password="123456") + + # retrieve a valid authorization code + authcode_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.org", + "response_type": "code token", + "allow": True, + } + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + fragment_dict = parse_qs(urlparse(response["Location"]).fragment) + authorization_code = fragment_dict["code"].pop() + + # exchange authorization code for a valid access token + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org", + } + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + content = json.loads(response.content.decode("utf-8")) + access_token = content["access_token"] + id_token = content["id_token"] + + # use token to access the resource + auth_headers = { + "HTTP_AUTHORIZATION": "Bearer " + access_token, + } + request = self.factory.get("/fake-resource", **auth_headers) + request.user = self.hy_test_user + + view = ResourceView.as_view() + response = view(request) + self.assertEqual(response, "This is a protected resource") + + # use id_token to access the resource + auth_headers = { + "HTTP_AUTHORIZATION": "Bearer " + id_token, + } + request = self.factory.get("/fake-resource", **auth_headers) + request.user = self.hy_test_user + + view = ResourceView.as_view() + response = view(request) + self.assertEqual(response, "This is a protected resource") + + def test_resource_access_deny(self): + auth_headers = { + "HTTP_AUTHORIZATION": "Bearer " + "faketoken", + } + request = self.factory.get("/fake-resource", **auth_headers) + request.user = self.hy_test_user + + view = ResourceView.as_view() + response = view(request) + self.assertEqual(response.status_code, 403) + + +class TestDefaultScopesHybrid(BaseTest): + + def test_pre_auth_default_scopes(self): + """ + Test response for a valid client_id with response_type: code using default scopes + """ + self.client.login(username="hy_test_user", password="123456") + oauth2_settings._DEFAULT_SCOPES = ["read"] + + query_string = urlencode({ + "client_id": self.application.client_id, + "response_type": "code token", + "state": "random_state_string", + "redirect_uri": "http://example.org", + }) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + # check form is in context and form params are valid + self.assertIn("form", response.context) + + form = response.context["form"] + self.assertEqual(form["redirect_uri"].value(), "http://example.org") + self.assertEqual(form["state"].value(), "random_state_string") + self.assertEqual(form["scope"].value(), "read") + self.assertEqual(form["client_id"].value(), self.application.client_id) + oauth2_settings._DEFAULT_SCOPES = ["read", "write"] From effe8162c3209d8dcd2ea355c8f06fd08c884177 Mon Sep 17 00:00:00 2001 From: Wiliam Souza Date: Tue, 16 Jan 2018 17:28:22 -0200 Subject: [PATCH 18/57] Change to use .encode to avoid py2.7 tox test error --- oauth2_provider/oauth2_validators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 12755e8f7..1a5637300 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -763,7 +763,7 @@ def get_id_token(self, token, token_handler, request): code = token["code"] sha256 = hashlib.sha256(code.encode("ascii")) bits256 = sha256.hexdigest()[:32] - c_hash = base64.urlsafe_b64encode(bytes(bits256, "ascii")) + c_hash = base64.urlsafe_b64encode(bits256.encode("ascii")) claims["c_hash"] = c_hash.decode("utf8") jwt_token = jwt.JWT(header=json.dumps({"alg": "RS256"}, default=str), claims=json.dumps(claims, default=str)) From 94cef5994881ba4528777497464838dd29e829a0 Mon Sep 17 00:00:00 2001 From: Wiliam Souza Date: Thu, 18 Jan 2018 11:27:54 -0200 Subject: [PATCH 19/57] Add RSA_PRIVATE_KEY to the list of settings that cannot be empt --- oauth2_provider/settings.py | 1 + 1 file changed, 1 insertion(+) diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index 2f6d858c0..37e8986b0 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -83,6 +83,7 @@ "OAUTH2_BACKEND_CLASS", "SCOPES", "ALLOWED_REDIRECT_URI_SCHEMES", + "RSA_PRIVATE_KEY", ) # List of settings that may be in string import notation. From eb04c7584e9d81b7c9a9ae182364567bd713bab8 Mon Sep 17 00:00:00 2001 From: Allisson Azevedo Date: Wed, 14 Mar 2018 10:36:21 -0300 Subject: [PATCH 20/57] Add support for oidc connect discovery --- .gitignore | 2 +- oauth2_provider/models.py | 6 ++-- oauth2_provider/oauth2_validators.py | 4 +-- oauth2_provider/settings.py | 24 +++++++++++-- oauth2_provider/urls.py | 7 +++- oauth2_provider/views/__init__.py | 16 +++++---- oauth2_provider/views/oidc.py | 51 ++++++++++++++++++++++++++++ tests/settings.py | 6 ++++ tests/test_implicit.py | 2 +- tests/test_oidc_views.py | 47 +++++++++++++++++++++++++ tox.ini | 4 ++- 11 files changed, 152 insertions(+), 17 deletions(-) create mode 100644 oauth2_provider/views/oidc.py create mode 100644 tests/test_oidc_views.py diff --git a/.gitignore b/.gitignore index af644d1e3..c22ef00fa 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,7 @@ __pycache__ pip-log.txt # Unit test / coverage reports -.cache +.pytest_cache .coverage .tox .pytest_cache/ diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index ca835659a..cf4c8eb03 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -62,8 +62,8 @@ class AbstractApplication(models.Model): RS256_ALGORITHM = "RS256" HS256_ALGORITHM = "HS256" ALGORITHM_TYPES = ( - ("RS256", _("RSA with SHA-2 256")), - ("HS256", _("HMAC with SHA-2 256")), + (RS256_ALGORITHM, _("RSA with SHA-2 256")), + (HS256_ALGORITHM, _("HMAC with SHA-2 256")), ) id = models.BigAutoField(primary_key=True) @@ -91,7 +91,7 @@ class AbstractApplication(models.Model): created = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) - algorithm = models.CharField(max_length=5, choices=ALGORITHM_TYPES, default="RS256") + algorithm = models.CharField(max_length=5, choices=ALGORITHM_TYPES, default=RS256_ALGORITHM) class Meta: abstract = True diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 1a5637300..84213df83 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -722,7 +722,7 @@ def get_jwt_bearer_token(self, token, token_handler, request): def get_id_token(self, token, token_handler, request): - key = jwk.JWK.from_pem(oauth2_settings.RSA_PRIVATE_KEY.encode("utf8")) + key = jwk.JWK.from_pem(oauth2_settings.OIDC_RSA_PRIVATE_KEY.encode("utf8")) # TODO: http://openid.net/specs/openid-connect-core-1_0.html#HybridIDToken2 # Save the id_token on database bound to code when the request come to @@ -785,7 +785,7 @@ def validate_id_token(self, token, scopes, request): if not token: return False - key = jwk.JWK.from_pem(oauth2_settings.RSA_PRIVATE_KEY.encode("utf8")) + key = jwk.JWK.from_pem(oauth2_settings.OIDC_RSA_PRIVATE_KEY.encode("utf8")) try: jwt_token = jwt.JWT(key=key, jwt=token) diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index 37e8986b0..a1f56a70b 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -58,7 +58,21 @@ "REFRESH_TOKEN_MODEL": REFRESH_TOKEN_MODEL, "REQUEST_APPROVAL_PROMPT": "force", "ALLOWED_REDIRECT_URI_SCHEMES": ["http", "https"], - "RSA_PRIVATE_KEY": "", + "OIDC_ISS_ENDPOINT": "", + "OIDC_USERINFO_ENDPOINT": "", + "OIDC_RSA_PRIVATE_KEY": "", + "OIDC_RESPONSE_TYPES_SUPPORTED": [ + "code", + "token", + "id_token", + "id_token token", + "code token", + "code id_token", + "code id_token token", + ], + "OIDC_SUBJECT_TYPES_SUPPORTED": ["public"], + "OIDC_ID_TOKEN_SIGNING_ALG_VALUES_SUPPORTED": ["RS256", "HS256"], + "OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED": ["client_secret_post", "client_secret_basic"], # Special settings that will be evaluated at runtime "_SCOPES": [], @@ -83,7 +97,13 @@ "OAUTH2_BACKEND_CLASS", "SCOPES", "ALLOWED_REDIRECT_URI_SCHEMES", - "RSA_PRIVATE_KEY", + "OIDC_ISS_ENDPOINT", + "OIDC_USERINFO_ENDPOINT", + "OIDC_RSA_PRIVATE_KEY", + "OIDC_RESPONSE_TYPES_SUPPORTED", + "OIDC_SUBJECT_TYPES_SUPPORTED", + "OIDC_ID_TOKEN_SIGNING_ALG_VALUES_SUPPORTED", + "OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED" ) # List of settings that may be in string import notation. diff --git a/oauth2_provider/urls.py b/oauth2_provider/urls.py index 4cf6d4c6d..8097ce21d 100644 --- a/oauth2_provider/urls.py +++ b/oauth2_provider/urls.py @@ -27,5 +27,10 @@ name="authorized-token-delete"), ] +oidc_urlpatterns = [ + url(r"^\.well-known/openid-configuration/$", views.ConnectDiscoveryInfoView.as_view(), name="oidc-connect-discovery-info"), + url(r"^jwks/$", views.JwksInfoView.as_view(), name="jwks-info") +] + -urlpatterns = base_urlpatterns + management_urlpatterns +urlpatterns = base_urlpatterns + management_urlpatterns + oidc_urlpatterns diff --git a/oauth2_provider/views/__init__.py b/oauth2_provider/views/__init__.py index 7636bd9c7..2124dc7c2 100644 --- a/oauth2_provider/views/__init__.py +++ b/oauth2_provider/views/__init__.py @@ -1,9 +1,13 @@ # flake8: noqa -from .base import AuthorizationView, TokenView, RevokeTokenView -from .application import ApplicationRegistration, ApplicationDetail, ApplicationList, \ - ApplicationDelete, ApplicationUpdate +from .application import ( + ApplicationDelete, ApplicationDetail, ApplicationList, + ApplicationRegistration, ApplicationUpdate +) +from .base import AuthorizationView, RevokeTokenView, TokenView from .generic import ( - ProtectedResourceView, ScopedProtectedResourceView, ReadWriteScopedResourceView, - ClientProtectedResourceView, ClientProtectedScopedResourceView) -from .token import AuthorizedTokensListView, AuthorizedTokenDeleteView + ProtectedResourceView, ReadWriteScopedResourceView, + ScopedProtectedResourceView +) from .introspect import IntrospectTokenView +from .oidc import ConnectDiscoveryInfoView, JwksInfoView +from .token import AuthorizedTokenDeleteView, AuthorizedTokensListView diff --git a/oauth2_provider/views/oidc.py b/oauth2_provider/views/oidc.py new file mode 100644 index 000000000..6ba608b5a --- /dev/null +++ b/oauth2_provider/views/oidc.py @@ -0,0 +1,51 @@ +from __future__ import absolute_import, unicode_literals + +import json + +from django.http import JsonResponse +from django.urls import reverse_lazy +from django.views.generic import View +from jwcrypto import jwk + +from ..settings import oauth2_settings + + +class ConnectDiscoveryInfoView(View): + """ + View used to show oidc provider configuration information + """ + def get(self, request, *args, **kwargs): + issuer_url = oauth2_settings.OIDC_ISS_ENDPOINT + data = { + "issuer": issuer_url, + "authorization_endpoint": "{}{}".format(issuer_url, reverse_lazy('oauth2_provider:authorize')), + "token_endpoint": "{}{}".format(issuer_url, reverse_lazy('oauth2_provider:token')), + "userinfo_endpoint": oauth2_settings.OIDC_USERINFO_ENDPOINT, + "jwks_uri": "{}{}".format(issuer_url, reverse_lazy('oauth2_provider:jwks-info')), + "response_types_supported": oauth2_settings.OIDC_RESPONSE_TYPES_SUPPORTED, + "subject_types_supported": oauth2_settings.OIDC_SUBJECT_TYPES_SUPPORTED, + "id_token_signing_alg_values_supported": oauth2_settings.OIDC_ID_TOKEN_SIGNING_ALG_VALUES_SUPPORTED, + "token_endpoint_auth_methods_supported": oauth2_settings.OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED, + } + response = JsonResponse(data) + response['Access-Control-Allow-Origin'] = '*' + return response + + +class JwksInfoView(View): + """ + View used to show oidc json web key set document + """ + def get(self, request, *args, **kwargs): + key = jwk.JWK.from_pem(oauth2_settings.OIDC_RSA_PRIVATE_KEY.encode("utf8")) + data = { + 'keys': [{ + 'alg': 'RS256', + 'use': 'sig', + 'kid': key.thumbprint() + }] + } + data['keys'][0].update(json.loads(key.export_public())) + response = JsonResponse(data) + response['Access-Control-Allow-Origin'] = '*' + return response diff --git a/tests/settings.py b/tests/settings.py index 40eef5ebd..43cce99c2 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -130,3 +130,9 @@ }, } } + +OAUTH2_PROVIDER = { + "OIDC_ISS_ENDPOINT": "http://localhost", + "OIDC_USERINFO_ENDPOINT": "http://localhost/userinfo/", + "OIDC_RSA_PRIVATE_KEY": "-----BEGIN RSA PRIVATE KEY-----\nMIICXQIBAAKBgQCbCYh5h2NmQuBqVO6G+/CO+cHm9VBzsb0MeA6bbQfDnbhstVOT\nj0hcnZJzDjYc6ajBZZf6gxVP9xrdm9Uh599VI3X5PFXLbMHrmzTAMzCGIyg+/fnP\n0gocYxmCX2+XKyj/Zvt1pUX8VAN2AhrJSfxNDKUHERTVEV9bRBJg4F0C3wIDAQAB\nAoGAP+i4nNw+Ec/8oWh8YSFm4xE6qKG0NdTtSMAOyWwy+KTB+vHuT1QPsLn1vj77\n+IQrX/moogg6F1oV9YdA3vat3U7rwt1sBGsRrLhA+Spp9WEQtglguNo4+QfVo2ju\nYBa2rG+h75qjiA3xnU//F3rvwnAsOWv0NUVdVeguyR+u6okCQQDBUmgWeH2WHmUn\n2nLNCz+9wj28rqhfOr9Ptem2gqk+ywJmuIr4Y5S1OdavOr2UZxOcEwncJ/MLVYQq\nMH+x4V5HAkEAzU2GMR5OdVLcxfVTjzuIC76paoHVWnLibd1cdANpPmE6SM+pf5el\nfVSwuH9Fmlizu8GiPCxbJUoXB/J1tGEKqQJBALhClEU+qOzpoZ6/voYi/6kdN3zc\nuEy0EN6n09AKb8gS9QH1STgAqh+ltjMkeMe3C2DKYK5/QU9/Pc58lWl1FkcCQG67\nZamQgxjcvJ85FvymS1aqW45KwNysIlzHjFo2jMlMf7dN6kobbPMQftDENLJvLWIT\nqoFyGycdsxZiPAIyZSECQQCZFn3Dl6hnJxWZH8Fsa9hj79kZ/WVkIXGmtdgt0fNr\ndTnvCVtA59ne4LEVie/PMH/odQWY0SxVm/76uBZv/1vY\n-----END RSA PRIVATE KEY-----" +} diff --git a/tests/test_implicit.py b/tests/test_implicit.py index fcb705f9c..a0069e2f9 100644 --- a/tests/test_implicit.py +++ b/tests/test_implicit.py @@ -44,7 +44,7 @@ def setUp(self): "write": "Writing scope", "openid": "OpenID connect" } - self.key = jwk.JWK.from_pem(oauth2_settings.RSA_PRIVATE_KEY.encode("utf8")) + self.key = jwk.JWK.from_pem(oauth2_settings.OIDC_RSA_PRIVATE_KEY.encode("utf8")) def tearDown(self): self.application.delete() diff --git a/tests/test_oidc_views.py b/tests/test_oidc_views.py new file mode 100644 index 000000000..4977b6253 --- /dev/null +++ b/tests/test_oidc_views.py @@ -0,0 +1,47 @@ +from __future__ import unicode_literals + +from django.test import TestCase +from django.urls import reverse + + +class TestConnectDiscoveryInfoView(TestCase): + def test_get_connect_discovery_info(self): + expected_response = { + 'issuer': 'http://localhost', + 'authorization_endpoint': 'http://localhost/o/authorize/', + 'token_endpoint': 'http://localhost/o/token/', + 'userinfo_endpoint': 'http://localhost/userinfo/', + 'jwks_uri': 'http://localhost/o/jwks/', + 'response_types_supported': [ + 'code', + 'token', + 'id_token', + 'id_token token', + 'code token', + 'code id_token', + 'code id_token token' + ], + 'subject_types_supported': ['public'], + 'id_token_signing_alg_values_supported': ['RS256', 'HS256'], + 'token_endpoint_auth_methods_supported': ['client_secret_post', 'client_secret_basic'] + } + response = self.client.get(reverse('oauth2_provider:oidc-connect-discovery-info')) + self.assertEqual(response.status_code, 200) + assert response.json() == expected_response + + +class TestJwksInfoView(TestCase): + def test_get_jwks_info(self): + expected_response = { + 'keys': [{ + 'alg': 'RS256', + 'use': 'sig', + 'kid': 's4a1o8mFEd1tATAIH96caMlu4hOxzBUaI2QTqbYNBHs', + 'e': 'AQAB', + 'kty': 'RSA', + 'n': 'mwmIeYdjZkLgalTuhvvwjvnB5vVQc7G9DHgOm20Hw524bLVTk49IXJ2Scw42HOmowWWX-oMVT_ca3ZvVIeffVSN1-TxVy2zB65s0wDMwhiMoPv35z9IKHGMZgl9vlyso_2b7daVF_FQDdgIayUn8TQylBxEU1RFfW0QSYOBdAt8' + }] + } + response = self.client.get(reverse('oauth2_provider:jwks-info')) + self.assertEqual(response.status_code, 200) + assert response.json() == expected_response diff --git a/tox.ini b/tox.ini index 972655ddd..81ffdb8cc 100644 --- a/tox.ini +++ b/tox.ini @@ -14,7 +14,9 @@ envlist = django_find_project = false [testenv] -commands = pytest --cov=oauth2_provider --cov-report= --cov-append {posargs} +commands = + pip install https://github.com/oauthlib/oauthlib/archive/master.tar.gz + pytest --cov=oauth2_provider --cov-report= --cov-append {posargs} setenv = DJANGO_SETTINGS_MODULE = tests.settings PYTHONPATH = {toxinidir} From d80872389414c87d2f31dac38994ca3c1fba935b Mon Sep 17 00:00:00 2001 From: Allisson Azevedo Date: Wed, 14 Mar 2018 10:40:56 -0300 Subject: [PATCH 21/57] Use double quotes for strings --- oauth2_provider/views/oidc.py | 20 +++++++------- tests/test_oidc_views.py | 50 +++++++++++++++++------------------ 2 files changed, 35 insertions(+), 35 deletions(-) diff --git a/oauth2_provider/views/oidc.py b/oauth2_provider/views/oidc.py index 6ba608b5a..8c20908dd 100644 --- a/oauth2_provider/views/oidc.py +++ b/oauth2_provider/views/oidc.py @@ -18,17 +18,17 @@ def get(self, request, *args, **kwargs): issuer_url = oauth2_settings.OIDC_ISS_ENDPOINT data = { "issuer": issuer_url, - "authorization_endpoint": "{}{}".format(issuer_url, reverse_lazy('oauth2_provider:authorize')), - "token_endpoint": "{}{}".format(issuer_url, reverse_lazy('oauth2_provider:token')), + "authorization_endpoint": "{}{}".format(issuer_url, reverse_lazy("oauth2_provider:authorize")), + "token_endpoint": "{}{}".format(issuer_url, reverse_lazy("oauth2_provider:token")), "userinfo_endpoint": oauth2_settings.OIDC_USERINFO_ENDPOINT, - "jwks_uri": "{}{}".format(issuer_url, reverse_lazy('oauth2_provider:jwks-info')), + "jwks_uri": "{}{}".format(issuer_url, reverse_lazy("oauth2_provider:jwks-info")), "response_types_supported": oauth2_settings.OIDC_RESPONSE_TYPES_SUPPORTED, "subject_types_supported": oauth2_settings.OIDC_SUBJECT_TYPES_SUPPORTED, "id_token_signing_alg_values_supported": oauth2_settings.OIDC_ID_TOKEN_SIGNING_ALG_VALUES_SUPPORTED, "token_endpoint_auth_methods_supported": oauth2_settings.OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED, } response = JsonResponse(data) - response['Access-Control-Allow-Origin'] = '*' + response["Access-Control-Allow-Origin"] = "*" return response @@ -39,13 +39,13 @@ class JwksInfoView(View): def get(self, request, *args, **kwargs): key = jwk.JWK.from_pem(oauth2_settings.OIDC_RSA_PRIVATE_KEY.encode("utf8")) data = { - 'keys': [{ - 'alg': 'RS256', - 'use': 'sig', - 'kid': key.thumbprint() + "keys": [{ + "alg": "RS256", + "use": "sig", + "kid": key.thumbprint() }] } - data['keys'][0].update(json.loads(key.export_public())) + data["keys"][0].update(json.loads(key.export_public())) response = JsonResponse(data) - response['Access-Control-Allow-Origin'] = '*' + response["Access-Control-Allow-Origin"] = "*" return response diff --git a/tests/test_oidc_views.py b/tests/test_oidc_views.py index 4977b6253..2105b4fd0 100644 --- a/tests/test_oidc_views.py +++ b/tests/test_oidc_views.py @@ -7,25 +7,25 @@ class TestConnectDiscoveryInfoView(TestCase): def test_get_connect_discovery_info(self): expected_response = { - 'issuer': 'http://localhost', - 'authorization_endpoint': 'http://localhost/o/authorize/', - 'token_endpoint': 'http://localhost/o/token/', - 'userinfo_endpoint': 'http://localhost/userinfo/', - 'jwks_uri': 'http://localhost/o/jwks/', - 'response_types_supported': [ - 'code', - 'token', - 'id_token', - 'id_token token', - 'code token', - 'code id_token', - 'code id_token token' + "issuer": "http://localhost", + "authorization_endpoint": "http://localhost/o/authorize/", + "token_endpoint": "http://localhost/o/token/", + "userinfo_endpoint": "http://localhost/userinfo/", + "jwks_uri": "http://localhost/o/jwks/", + "response_types_supported": [ + "code", + "token", + "id_token", + "id_token token", + "code token", + "code id_token", + "code id_token token" ], - 'subject_types_supported': ['public'], - 'id_token_signing_alg_values_supported': ['RS256', 'HS256'], - 'token_endpoint_auth_methods_supported': ['client_secret_post', 'client_secret_basic'] + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["RS256", "HS256"], + "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"] } - response = self.client.get(reverse('oauth2_provider:oidc-connect-discovery-info')) + response = self.client.get(reverse("oauth2_provider:oidc-connect-discovery-info")) self.assertEqual(response.status_code, 200) assert response.json() == expected_response @@ -33,15 +33,15 @@ def test_get_connect_discovery_info(self): class TestJwksInfoView(TestCase): def test_get_jwks_info(self): expected_response = { - 'keys': [{ - 'alg': 'RS256', - 'use': 'sig', - 'kid': 's4a1o8mFEd1tATAIH96caMlu4hOxzBUaI2QTqbYNBHs', - 'e': 'AQAB', - 'kty': 'RSA', - 'n': 'mwmIeYdjZkLgalTuhvvwjvnB5vVQc7G9DHgOm20Hw524bLVTk49IXJ2Scw42HOmowWWX-oMVT_ca3ZvVIeffVSN1-TxVy2zB65s0wDMwhiMoPv35z9IKHGMZgl9vlyso_2b7daVF_FQDdgIayUn8TQylBxEU1RFfW0QSYOBdAt8' + "keys": [{ + "alg": "RS256", + "use": "sig", + "kid": "s4a1o8mFEd1tATAIH96caMlu4hOxzBUaI2QTqbYNBHs", + "e": "AQAB", + "kty": "RSA", + "n": "mwmIeYdjZkLgalTuhvvwjvnB5vVQc7G9DHgOm20Hw524bLVTk49IXJ2Scw42HOmowWWX-oMVT_ca3ZvVIeffVSN1-TxVy2zB65s0wDMwhiMoPv35z9IKHGMZgl9vlyso_2b7daVF_FQDdgIayUn8TQylBxEU1RFfW0QSYOBdAt8" }] } - response = self.client.get(reverse('oauth2_provider:jwks-info')) + response = self.client.get(reverse("oauth2_provider:jwks-info")) self.assertEqual(response.status_code, 200) assert response.json() == expected_response From d9e45115ff5eda1a7860736eb9d6a6675a3aad4b Mon Sep 17 00:00:00 2001 From: Wiliam Souza Date: Wed, 16 Jan 2019 10:26:44 -0200 Subject: [PATCH 22/57] Rename migrations to avoid name and order conflict --- ...uto_20170903_1632.py => 0007_authorization_grant_type.py} | 5 +---- ...pplication_algorithm.py => 0008_application_algorithm.py} | 5 +---- .../migrations/{0008_idtoken.py => 0009_idtoken.py} | 5 +---- 3 files changed, 3 insertions(+), 12 deletions(-) rename oauth2_provider/migrations/{0006_auto_20170903_1632.py => 0007_authorization_grant_type.py} (82%) rename oauth2_provider/migrations/{0007_application_algorithm.py => 0008_application_algorithm.py} (78%) rename oauth2_provider/migrations/{0008_idtoken.py => 0009_idtoken.py} (91%) diff --git a/oauth2_provider/migrations/0006_auto_20170903_1632.py b/oauth2_provider/migrations/0007_authorization_grant_type.py similarity index 82% rename from oauth2_provider/migrations/0006_auto_20170903_1632.py rename to oauth2_provider/migrations/0007_authorization_grant_type.py index dc2d7cbe9..3db25bf42 100644 --- a/oauth2_provider/migrations/0006_auto_20170903_1632.py +++ b/oauth2_provider/migrations/0007_authorization_grant_type.py @@ -1,14 +1,11 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.4 on 2017-09-03 16:32 -from __future__ import unicode_literals - from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('oauth2_provider', '0005_auto_20170514_1141'), + ('oauth2_provider', '0006_auto_20171214_2232'), ] operations = [ diff --git a/oauth2_provider/migrations/0007_application_algorithm.py b/oauth2_provider/migrations/0008_application_algorithm.py similarity index 78% rename from oauth2_provider/migrations/0007_application_algorithm.py rename to oauth2_provider/migrations/0008_application_algorithm.py index 319d99ee5..c4e80aae0 100644 --- a/oauth2_provider/migrations/0007_application_algorithm.py +++ b/oauth2_provider/migrations/0008_application_algorithm.py @@ -1,14 +1,11 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.4 on 2017-09-16 18:55 -from __future__ import unicode_literals - from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('oauth2_provider', '0006_auto_20170903_1632'), + ('oauth2_provider', '0007_authorization_grant_type'), ] operations = [ diff --git a/oauth2_provider/migrations/0008_idtoken.py b/oauth2_provider/migrations/0009_idtoken.py similarity index 91% rename from oauth2_provider/migrations/0008_idtoken.py rename to oauth2_provider/migrations/0009_idtoken.py index 3f0ae10b2..e87fe3b99 100644 --- a/oauth2_provider/migrations/0008_idtoken.py +++ b/oauth2_provider/migrations/0009_idtoken.py @@ -1,7 +1,4 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.4 on 2017-10-01 19:13 -from __future__ import unicode_literals - from django.conf import settings from django.db import migrations, models import django.db.models.deletion @@ -14,7 +11,7 @@ class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(oauth2_settings.APPLICATION_MODEL), - ('oauth2_provider', '0007_application_algorithm'), + ('oauth2_provider', '0008_application_algorithm'), ] operations = [ From 8167ed525acd2e45536120720f1bcbf2eec40059 Mon Sep 17 00:00:00 2001 From: Wiliam Souza Date: Wed, 16 Jan 2019 10:33:30 -0200 Subject: [PATCH 23/57] Remove commando to install OAuthLib from master and removed jwcrypto duplication --- tox.ini | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tox.ini b/tox.ini index 81ffdb8cc..78a1d8a9b 100644 --- a/tox.ini +++ b/tox.ini @@ -14,9 +14,8 @@ envlist = django_find_project = false [testenv] -commands = - pip install https://github.com/oauthlib/oauthlib/archive/master.tar.gz - pytest --cov=oauth2_provider --cov-report= --cov-append {posargs} +commands = + pytest --cov=oauth2_provider --cov-report= --cov-append {posargs} -s setenv = DJANGO_SETTINGS_MODULE = tests.settings PYTHONPATH = {toxinidir} From 80296dbaeee52edd9a4a9687c436e42bd32255a4 Mon Sep 17 00:00:00 2001 From: Wiliam Souza Date: Wed, 16 Jan 2019 10:36:44 -0200 Subject: [PATCH 24/57] Remove python 2 compatible code --- oauth2_provider/models.py | 1 - tests/test_hybrid.py | 5 ++--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index cf4c8eb03..0e9c42631 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -425,7 +425,6 @@ class Meta(AbstractRefreshToken.Meta): swappable = "OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL" -@python_2_unicode_compatible class AbstractIDToken(models.Model): """ An IDToken instance represents the actual token to diff --git a/tests/test_hybrid.py b/tests/test_hybrid.py index 43f748316..493c4bb1e 100644 --- a/tests/test_hybrid.py +++ b/tests/test_hybrid.py @@ -1,16 +1,15 @@ -from __future__ import unicode_literals - import base64 import datetime import json +from urllib.parse import parse_qs, urlencode, urlparse + from django.contrib.auth import get_user_model from django.test import RequestFactory, TestCase from django.urls import reverse from django.utils import timezone from oauthlib.oauth2.rfc6749 import errors as oauthlib_errors -from oauth2_provider.compat import parse_qs, urlencode, urlparse from oauth2_provider.models import ( get_access_token_model, get_application_model, get_grant_model, get_refresh_token_model From bb7b7734ad6231b4df897e604561928496aff3ac Mon Sep 17 00:00:00 2001 From: Wiliam Souza Date: Wed, 16 Jan 2019 10:41:56 -0200 Subject: [PATCH 25/57] Change errors access_denied/unauthorized_client/consent_required/login_required to be 400 as changed in oauthlib/pull/623 --- tests/test_hybrid.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_hybrid.py b/tests/test_hybrid.py index 493c4bb1e..9141f7efb 100644 --- a/tests/test_hybrid.py +++ b/tests/test_hybrid.py @@ -796,7 +796,7 @@ def test_basic_auth_bad_authcode(self): auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) - self.assertEqual(response.status_code, 401) + self.assertEqual(response.status_code, 400) def test_basic_auth_bad_granttype(self): """ @@ -832,7 +832,7 @@ def test_basic_auth_grant_expired(self): auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) - self.assertEqual(response.status_code, 401) + self.assertEqual(response.status_code, 400) def test_basic_auth_bad_secret(self): """ From 10cb7ef5c7379608700c0e4a709dd7e5af4e6ca5 Mon Sep 17 00:00:00 2001 From: Wiliam Souza Date: Wed, 16 Jan 2019 10:42:51 -0200 Subject: [PATCH 26/57] Change iss claim value to come from settings --- oauth2_provider/oauth2_validators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 84213df83..819ecc394 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -734,7 +734,7 @@ def get_id_token(self, token, token_handler, request): expiration_time = timezone.now() + timedelta(seconds=oauth2_settings.ID_TOKEN_EXPIRE_SECONDS) # Required ID Token claims claims = { - "iss": 'https://id.olist.com', # HTTPS URL + "iss": oauth2_settings.OIDC_ISS_ENDPOINT, "sub": str(request.user.id), "aud": request.client_id, "exp": int(dateformat.format(expiration_time, "U")), From 6359fc5e1ccddef69b0ce8d12c8a3f68a5db7836 Mon Sep 17 00:00:00 2001 From: Wiliam Souza Date: Wed, 16 Jan 2019 10:43:53 -0200 Subject: [PATCH 27/57] Change to use openid connect code server class --- oauth2_provider/settings.py | 45 ++++++++++++++++++++++++----------- tests/test_oauth2_backends.py | 2 +- 2 files changed, 32 insertions(+), 15 deletions(-) diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index a1f56a70b..d770cbd56 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -23,11 +23,19 @@ USER_SETTINGS = getattr(settings, "OAUTH2_PROVIDER", None) -APPLICATION_MODEL = getattr(settings, "OAUTH2_PROVIDER_APPLICATION_MODEL", "oauth2_provider.Application") -ACCESS_TOKEN_MODEL = getattr(settings, "OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL", "oauth2_provider.AccessToken") -ID_TOKEN_MODEL = getattr(settings, "OAUTH2_PROVIDER_ID_TOKEN_MODEL", "oauth2_provider.IDToken") +APPLICATION_MODEL = getattr( + settings, "OAUTH2_PROVIDER_APPLICATION_MODEL", "oauth2_provider.Application" +) +ACCESS_TOKEN_MODEL = getattr( + settings, "OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL", "oauth2_provider.AccessToken" +) +ID_TOKEN_MODEL = getattr( + settings, "OAUTH2_PROVIDER_ID_TOKEN_MODEL", "oauth2_provider.IDToken" +) GRANT_MODEL = getattr(settings, "OAUTH2_PROVIDER_GRANT_MODEL", "oauth2_provider.Grant") -REFRESH_TOKEN_MODEL = getattr(settings, "OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL", "oauth2_provider.RefreshToken") +REFRESH_TOKEN_MODEL = getattr( + settings, "OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL", "oauth2_provider.RefreshToken" +) DEFAULTS = { "CLIENT_ID_GENERATOR_CLASS": "oauth2_provider.generators.ClientIdGenerator", @@ -36,7 +44,7 @@ "ACCESS_TOKEN_GENERATOR": None, "REFRESH_TOKEN_GENERATOR": None, "EXTRA_SERVER_KWARGS": {}, - "OAUTH2_SERVER_CLASS": "oauthlib.oauth2.Server", + "OAUTH2_SERVER_CLASS": "oauthlib.openid.connect.core.endpoints.pre_configured.Server", "OAUTH2_VALIDATOR_CLASS": "oauth2_provider.oauth2_validators.OAuth2Validator", "OAUTH2_BACKEND_CLASS": "oauth2_provider.oauth2_backends.OAuthLibCore", "SCOPES": {"read": "Reading scope", "write": "Writing scope"}, @@ -72,20 +80,20 @@ ], "OIDC_SUBJECT_TYPES_SUPPORTED": ["public"], "OIDC_ID_TOKEN_SIGNING_ALG_VALUES_SUPPORTED": ["RS256", "HS256"], - "OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED": ["client_secret_post", "client_secret_basic"], - + "OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED": [ + "client_secret_post", + "client_secret_basic", + ], # Special settings that will be evaluated at runtime "_SCOPES": [], "_DEFAULT_SCOPES": [], - # Resource Server with Token Introspection "RESOURCE_SERVER_INTROSPECTION_URL": None, "RESOURCE_SERVER_AUTH_TOKEN": None, "RESOURCE_SERVER_INTROSPECTION_CREDENTIALS": None, "RESOURCE_SERVER_TOKEN_CACHING_SECONDS": 36000, - # Whether or not PKCE is required - "PKCE_REQUIRED": False + "PKCE_REQUIRED": False, } # List of settings that cannot be empty @@ -103,7 +111,7 @@ "OIDC_RESPONSE_TYPES_SUPPORTED", "OIDC_SUBJECT_TYPES_SUPPORTED", "OIDC_ID_TOKEN_SIGNING_ALG_VALUES_SUPPORTED", - "OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED" + "OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED", ) # List of settings that may be in string import notation. @@ -142,7 +150,12 @@ def import_from_string(val, setting_name): module = importlib.import_module(module_path) return getattr(module, class_name) except ImportError as e: - msg = "Could not import %r for setting %r. %s: %s." % (val, setting_name, e.__class__.__name__, e) + msg = "Could not import %r for setting %r. %s: %s." % ( + val, + setting_name, + e.__class__.__name__, + e, + ) raise ImportError(msg) @@ -154,7 +167,9 @@ class OAuth2ProviderSettings(object): and return the class, rather than the string literal. """ - def __init__(self, user_settings=None, defaults=None, import_strings=None, mandatory=None): + def __init__( + self, user_settings=None, defaults=None, import_strings=None, mandatory=None + ): self.user_settings = user_settings or {} self.defaults = defaults or {} self.import_strings = import_strings or () @@ -189,7 +204,9 @@ def __getattr__(self, attr): if scope in self._SCOPES: val.append(scope) else: - raise ImproperlyConfigured("Defined DEFAULT_SCOPES not present in SCOPES") + raise ImproperlyConfigured( + "Defined DEFAULT_SCOPES not present in SCOPES" + ) self.validate_setting(attr, val) diff --git a/tests/test_oauth2_backends.py b/tests/test_oauth2_backends.py index d844da5f4..2381e9cdc 100644 --- a/tests/test_oauth2_backends.py +++ b/tests/test_oauth2_backends.py @@ -65,7 +65,7 @@ def test_create_token_response_gets_extra_credentials(self): payload = "grant_type=password&username=john&password=123456" request = self.factory.post("/o/token/", payload, content_type="application/x-www-form-urlencoded") - with mock.patch("oauthlib.oauth2.Server.create_token_response") as create_token_response: + with mock.patch("oauthlib.openid.connect.core.endpoints.pre_configured.Server.create_token_response") as create_token_response: mocked = mock.MagicMock() create_token_response.return_value = mocked, mocked, mocked core = self.MyOAuthLibCore() From 3432d49c093ddd487862f80b908c7f2ddaa4f53e Mon Sep 17 00:00:00 2001 From: Wiliam Souza Date: Wed, 16 Jan 2019 10:44:30 -0200 Subject: [PATCH 28/57] Change test to include missing state --- tests/test_hybrid.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_hybrid.py b/tests/test_hybrid.py index 9141f7efb..206b4e282 100644 --- a/tests/test_hybrid.py +++ b/tests/test_hybrid.py @@ -721,7 +721,7 @@ def test_code_post_auth_failing_redirection_uri_with_querystring(self): response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) - self.assertEqual("http://example.com?foo=bar&error=access_denied", response["Location"]) + self.assertEqual("http://example.com?foo=bar&error=access_denied&state=random_state_string", response["Location"]) def test_code_post_auth_fails_when_redirect_uri_path_is_invalid(self): """ From 1abc97b0fcb4c96359ced07228d1227f2f260e67 Mon Sep 17 00:00:00 2001 From: Wiliam Souza Date: Mon, 28 Jan 2019 22:59:24 -0200 Subject: [PATCH 29/57] Add id_token relation to AbstractAccessToken --- .../migrations/0010_accesstoken_id_token.py | 21 +++++++++++++++++++ oauth2_provider/models.py | 7 +++++++ 2 files changed, 28 insertions(+) create mode 100644 oauth2_provider/migrations/0010_accesstoken_id_token.py diff --git a/oauth2_provider/migrations/0010_accesstoken_id_token.py b/oauth2_provider/migrations/0010_accesstoken_id_token.py new file mode 100644 index 000000000..262610139 --- /dev/null +++ b/oauth2_provider/migrations/0010_accesstoken_id_token.py @@ -0,0 +1,21 @@ +# Generated by Django 2.1.5 on 2019-01-28 18:46 + +from django.db import migrations, models +import django.db.models.deletion + +from oauth2_provider.settings import oauth2_settings + + +class Migration(migrations.Migration): + + dependencies = [ + ('oauth2_provider', '0009_idtoken'), + ] + + operations = [ + migrations.AddField( + model_name='accesstoken', + name='id_token', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_token', to=oauth2_settings.ID_TOKEN_MODEL), + ), + ] diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 0e9c42631..9f4e72ef6 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -1,7 +1,10 @@ import logging +import json from datetime import timedelta from urllib.parse import parse_qsl, urlparse +from jwcrypto import jwk, jwt + from django.apps import apps from django.conf import settings from django.core.exceptions import ImproperlyConfigured @@ -292,6 +295,10 @@ class AbstractAccessToken(models.Model): related_name="refreshed_access_token" ) token = models.CharField(max_length=255, unique=True, ) + id_token = models.OneToOneField( + oauth2_settings.ID_TOKEN_MODEL, on_delete=models.CASCADE, blank=True, null=True, + related_name="access_token" + ) application = models.ForeignKey( oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE, blank=True, null=True, ) From 5ebdf6416ae3cf048c337acbb3b4e2f206c6b6d9 Mon Sep 17 00:00:00 2001 From: Wiliam Souza Date: Mon, 28 Jan 2019 23:00:01 -0200 Subject: [PATCH 30/57] Add claims property to AbstractIDToken --- oauth2_provider/models.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 9f4e72ef6..1027ab515 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -507,6 +507,12 @@ def scopes(self): token_scopes = self.scope.split() return {name: desc for name, desc in all_scopes.items() if name in token_scopes} + @property + def claims(self): + key = jwk.JWK.from_pem(oauth2_settings.OIDC_RSA_PRIVATE_KEY.encode("utf8")) + jwt_token = jwt.JWT(key=key, jwt=self.token) + return json.loads(jwt_token.claims) + def __str__(self): return self.token From 18c1fd3865355619ec61a911677de85e125305d6 Mon Sep 17 00:00:00 2001 From: Wiliam Souza Date: Mon, 28 Jan 2019 23:01:03 -0200 Subject: [PATCH 31/57] Change OAuth2Validator._create_access_token to save id_token to access_token --- oauth2_provider/oauth2_validators.py | 169 ++++++++++++++++++--------- 1 file changed, 116 insertions(+), 53 deletions(-) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 819ecc394..c05ca75d3 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -40,9 +40,12 @@ log = logging.getLogger("oauth2_provider") GRANT_TYPE_MAPPING = { - "authorization_code": (AbstractApplication.GRANT_AUTHORIZATION_CODE, AbstractApplication.GRANT_OPENID_HYBRID), - "password": (AbstractApplication.GRANT_PASSWORD, ), - "client_credentials": (AbstractApplication.GRANT_CLIENT_CREDENTIALS, ), + "authorization_code": ( + AbstractApplication.GRANT_AUTHORIZATION_CODE, + AbstractApplication.GRANT_OPENID_HYBRID, + ), + "password": (AbstractApplication.GRANT_PASSWORD,), + "client_credentials": (AbstractApplication.GRANT_CLIENT_CREDENTIALS,), "refresh_token": ( AbstractApplication.GRANT_AUTHORIZATION_CODE, AbstractApplication.GRANT_PASSWORD, @@ -105,12 +108,15 @@ def _authenticate_basic_auth(self, request): except UnicodeDecodeError: log.debug( "Failed basic auth: %r can't be decoded as unicode by %r", - auth_string, encoding + auth_string, + encoding, ) return False try: - client_id, client_secret = map(unquote_plus, auth_string_decoded.split(":", 1)) + client_id, client_secret = map( + unquote_plus, auth_string_decoded.split(":", 1) + ) except ValueError: log.debug("Failed basic auth, Invalid base64 encoding.") return False @@ -159,35 +165,54 @@ def _load_application(self, client_id, request): """ # we want to be sure that request has the client attribute! - assert hasattr(request, "client"), '"request" instance has no "client" attribute' + assert hasattr( + request, "client" + ), '"request" instance has no "client" attribute' try: - request.client = request.client or Application.objects.get(client_id=client_id) + request.client = request.client or Application.objects.get( + client_id=client_id + ) # Check that the application can be used (defaults to always True) if not request.client.is_usable(request): - log.debug("Failed body authentication: Application %r is disabled" % (client_id)) + log.debug( + "Failed body authentication: Application %r is disabled" + % (client_id) + ) return None return request.client except Application.DoesNotExist: - log.debug("Failed body authentication: Application %r does not exist" % (client_id)) + log.debug( + "Failed body authentication: Application %r does not exist" + % (client_id) + ) return None def _set_oauth2_error_on_request(self, request, access_token, scopes): if access_token is None: - error = OrderedDict([ - ("error", "invalid_token", ), - ("error_description", _("The access token is invalid."), ), - ]) + error = OrderedDict( + [ + ("error", "invalid_token",), + ("error_description", _("The access token is invalid."),), + ] + ) elif access_token.is_expired(): - error = OrderedDict([ - ("error", "invalid_token", ), - ("error_description", _("The access token has expired."), ), - ]) + error = OrderedDict( + [ + ("error", "invalid_token",), + ("error_description", _("The access token has expired."),), + ] + ) elif not access_token.allow_scopes(scopes): - error = OrderedDict([ - ("error", "insufficient_scope", ), - ("error_description", _("The access token is valid but does not have enough scope."), ), - ]) + error = OrderedDict( + [ + ("error", "insufficient_scope",), + ( + "error_description", + _("The access token is valid but does not have enough scope."), + ), + ] + ) else: log.warning("OAuth2 access token is invalid for an unknown reason.") error = OrderedDict([ @@ -253,11 +278,15 @@ def authenticate_client_id(self, client_id, request, *args, **kwargs): proceed only if the client exists and is not of type "Confidential". """ if self._load_application(client_id, request) is not None: - log.debug("Application %r has type %r" % (client_id, request.client.client_type)) + log.debug( + "Application %r has type %r" % (client_id, request.client.client_type) + ) return request.client.client_type != AbstractApplication.CLIENT_CONFIDENTIAL return False - def confirm_redirect_uri(self, client_id, code, redirect_uri, client, *args, **kwargs): + def confirm_redirect_uri( + self, client_id, code, redirect_uri, client, *args, **kwargs + ): """ Ensure the redirect_uri is listed in the Application instance redirect_uris field """ @@ -282,7 +311,7 @@ def get_default_redirect_uri(self, client_id, request, *args, **kwargs): return request.client.default_redirect_uri def _get_token_from_authentication_server( - self, token, introspection_url, introspection_token, introspection_credentials + self, token, introspection_url, introspection_token, introspection_credentials ): """Use external introspection endpoint to "crack open" the token. :param introspection_url: introspection endpoint URL @@ -310,11 +339,12 @@ def _get_token_from_authentication_server( try: response = requests.post( - introspection_url, - data={"token": token}, headers=headers + introspection_url, data={"token": token}, headers=headers ) except requests.exceptions.RequestException: - log.exception("Introspection: Failed POST to %r in token lookup", introspection_url) + log.exception( + "Introspection: Failed POST to %r in token lookup", introspection_url + ) return None # Log an exception when response from auth server is not successful @@ -360,7 +390,8 @@ def _get_token_from_authentication_server( "application": None, "scope": scope, "expires": expires, - }) + }, + ) return access_token @@ -373,10 +404,14 @@ def validate_bearer_token(self, token, scopes, request): introspection_url = oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL introspection_token = oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN - introspection_credentials = oauth2_settings.RESOURCE_SERVER_INTROSPECTION_CREDENTIALS + introspection_credentials = ( + oauth2_settings.RESOURCE_SERVER_INTROSPECTION_CREDENTIALS + ) try: - access_token = AccessToken.objects.select_related("application", "user").get(token=token) + access_token = AccessToken.objects.select_related( + "application", "user" + ).get(token=token) except AccessToken.DoesNotExist: access_token = None @@ -387,7 +422,7 @@ def validate_bearer_token(self, token, scopes, request): token, introspection_url, introspection_token, - introspection_credentials + introspection_credentials, ) if access_token and access_token.is_valid(scopes): @@ -414,20 +449,26 @@ def validate_code(self, client_id, code, client, request, *args, **kwargs): except Grant.DoesNotExist: return False - def validate_grant_type(self, client_id, grant_type, client, request, *args, **kwargs): + def validate_grant_type( + self, client_id, grant_type, client, request, *args, **kwargs + ): """ Validate both grant_type is a valid string and grant_type is allowed for current workflow """ - assert(grant_type in GRANT_TYPE_MAPPING) # mapping misconfiguration + assert grant_type in GRANT_TYPE_MAPPING # mapping misconfiguration return request.client.allows_grant_type(*GRANT_TYPE_MAPPING[grant_type]) - def validate_response_type(self, client_id, response_type, client, request, *args, **kwargs): + def validate_response_type( + self, client_id, response_type, client, request, *args, **kwargs + ): """ We currently do not support the Authorization Endpoint Response Types registry as in rfc:`8.4`, so validate the response_type only if it matches "code" or "token" """ if response_type == "code": - return client.allows_grant_type(AbstractApplication.GRANT_AUTHORIZATION_CODE) + return client.allows_grant_type( + AbstractApplication.GRANT_AUTHORIZATION_CODE + ) elif response_type == "token": return client.allows_grant_type(AbstractApplication.GRANT_IMPLICIT) elif response_type == "id_token": @@ -447,11 +488,15 @@ def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs): """ Ensure required scopes are permitted (as specified in the settings file) """ - available_scopes = get_scopes_backend().get_available_scopes(application=client, request=request) + available_scopes = get_scopes_backend().get_available_scopes( + application=client, request=request + ) return set(scopes).issubset(set(available_scopes)) def get_default_scopes(self, client_id, request, *args, **kwargs): - default_scopes = get_scopes_backend().get_default_scopes(application=request.client, request=request) + default_scopes = get_scopes_backend().get_default_scopes( + application=request.client, request=request + ) return default_scopes def validate_redirect_uri(self, client_id, redirect_uri, request, *args, **kwargs): @@ -537,9 +582,11 @@ def save_bearer_token(self, token, request, *args, **kwargs): refresh_token_instance = getattr(request, "refresh_token_instance", None) # If we are to reuse tokens, and we can: do so - if not self.rotate_refresh_token(request) and \ - isinstance(refresh_token_instance, RefreshToken) and \ - refresh_token_instance.access_token: + if ( + not self.rotate_refresh_token(request) + and isinstance(refresh_token_instance, RefreshToken) + and refresh_token_instance.access_token + ): access_token = AccessToken.objects.select_for_update().get( pk=refresh_token_instance.access_token.pk @@ -586,14 +633,18 @@ def save_bearer_token(self, token, request, *args, **kwargs): source_refresh_token=refresh_token_instance, ) - self._create_refresh_token(request, refresh_token_code, access_token) + self._create_refresh_token( + request, refresh_token_code, access_token + ) else: # make sure that the token data we're returning matches # the existing token token["access_token"] = previous_access_token.token - token["refresh_token"] = RefreshToken.objects.filter( - access_token=previous_access_token - ).first().token + token["refresh_token"] = ( + RefreshToken.objects.filter(access_token=previous_access_token) + .first() + .token + ) token["scope"] = previous_access_token.scope # No refresh token should be created, just access token @@ -601,11 +652,15 @@ def save_bearer_token(self, token, request, *args, **kwargs): self._create_access_token(expires, request, token) def _create_access_token(self, expires, request, token, source_refresh_token=None): + id_token = token.get("id_token", None) + if id_token: + id_token = IDToken.objects.get(token=id_token) return AccessToken.objects.create( user=request.user, scope=token["scope"], expires=expires, token=token["access_token"], + id_token=id_token, application=request.client, source_refresh_token=source_refresh_token, ) @@ -630,7 +685,7 @@ def _create_refresh_token(self, request, refresh_token_code, access_token): user=request.user, token=refresh_token_code, application=request.client, - access_token=access_token + access_token=access_token, ) def revoke_token(self, token, token_type_hint, request, *args, **kwargs): @@ -683,9 +738,8 @@ def validate_refresh_token(self, refresh_token, client, request, *args, **kwargs """ null_or_recent = Q(revoked__isnull=True) | Q( - revoked__gt=timezone.now() - timedelta( - seconds=oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS - ) + revoked__gt=timezone.now() + - timedelta(seconds=oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS) ) rt = RefreshToken.objects.filter(null_or_recent, token=refresh_token).select_related( "access_token" @@ -731,7 +785,9 @@ def get_id_token(self, token, token_handler, request): # TODO: Check if at this point this request parameters are alredy validated - expiration_time = timezone.now() + timedelta(seconds=oauth2_settings.ID_TOKEN_EXPIRE_SECONDS) + expiration_time = timezone.now() + timedelta( + seconds=oauth2_settings.ID_TOKEN_EXPIRE_SECONDS + ) # Required ID Token claims claims = { "iss": oauth2_settings.OIDC_ISS_ENDPOINT, @@ -739,7 +795,7 @@ def get_id_token(self, token, token_handler, request): "aud": request.client_id, "exp": int(dateformat.format(expiration_time, "U")), "iat": int(dateformat.format(datetime.utcnow(), "U")), - "auth_time": int(dateformat.format(request.user.last_login, "U")) + "auth_time": int(dateformat.format(request.user.last_login, "U")), } nonce = getattr(request, "nonce", None) @@ -750,12 +806,16 @@ def get_id_token(self, token, token_handler, request): # http://openid.net/specs/openid-connect-core-1_0.html#CodeIDToken # http://openid.net/specs/openid-connect-core-1_0.html#ImplicitIDToken # if request.grant_type in 'authorization_code' and 'access_token' in token: - if (request.grant_type is "authorization_code" and "access_token" in token) or request.response_type == "code id_token token" or (request.response_type == "id_token token" and "access_token" in token): + if ( + (request.grant_type is "authorization_code" and "access_token" in token) + or request.response_type == "code id_token token" + or (request.response_type == "id_token token" and "access_token" in token) + ): acess_token = token["access_token"] sha256 = hashlib.sha256(acess_token.encode("ascii")) bits128 = sha256.hexdigest()[:16] at_hash = base64.urlsafe_b64encode(bits128.encode("ascii")) - claims['at_hash'] = at_hash.decode("utf8") + claims["at_hash"] = at_hash.decode("utf8") # TODO: create a function to check if we should include c_hash # http://openid.net/specs/openid-connect-core-1_0.html#HybridIDToken @@ -766,7 +826,10 @@ def get_id_token(self, token, token_handler, request): c_hash = base64.urlsafe_b64encode(bits256.encode("ascii")) claims["c_hash"] = c_hash.decode("utf8") - jwt_token = jwt.JWT(header=json.dumps({"alg": "RS256"}, default=str), claims=json.dumps(claims, default=str)) + jwt_token = jwt.JWT( + header=json.dumps({"alg": "RS256"}, default=str), + claims=json.dumps(claims, default=str), + ) jwt_token.make_signed_token(key) id_token = self._save_id_token(jwt_token, request, expiration_time) From 42469772f6d58fb5b6ea644b83cfd7c41ddefab9 Mon Sep 17 00:00:00 2001 From: Wiliam Souza Date: Mon, 28 Jan 2019 23:01:27 -0200 Subject: [PATCH 32/57] Add userinfo endpoint --- oauth2_provider/urls.py | 3 ++- oauth2_provider/views/__init__.py | 2 +- oauth2_provider/views/oidc.py | 13 +++++++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/oauth2_provider/urls.py b/oauth2_provider/urls.py index 8097ce21d..aa29687be 100644 --- a/oauth2_provider/urls.py +++ b/oauth2_provider/urls.py @@ -29,7 +29,8 @@ oidc_urlpatterns = [ url(r"^\.well-known/openid-configuration/$", views.ConnectDiscoveryInfoView.as_view(), name="oidc-connect-discovery-info"), - url(r"^jwks/$", views.JwksInfoView.as_view(), name="jwks-info") + url(r"^jwks/$", views.JwksInfoView.as_view(), name="jwks-info"), + url(r"^userinfo/$", views.UserInfoView.as_view(), name="user-info") ] diff --git a/oauth2_provider/views/__init__.py b/oauth2_provider/views/__init__.py index 2124dc7c2..9f2ac4ff7 100644 --- a/oauth2_provider/views/__init__.py +++ b/oauth2_provider/views/__init__.py @@ -9,5 +9,5 @@ ScopedProtectedResourceView ) from .introspect import IntrospectTokenView -from .oidc import ConnectDiscoveryInfoView, JwksInfoView +from .oidc import ConnectDiscoveryInfoView, JwksInfoView, UserInfoView from .token import AuthorizedTokenDeleteView, AuthorizedTokensListView diff --git a/oauth2_provider/views/oidc.py b/oauth2_provider/views/oidc.py index 8c20908dd..7f3c9d5d4 100644 --- a/oauth2_provider/views/oidc.py +++ b/oauth2_provider/views/oidc.py @@ -5,6 +5,9 @@ from django.http import JsonResponse from django.urls import reverse_lazy from django.views.generic import View + +from rest_framework.views import APIView + from jwcrypto import jwk from ..settings import oauth2_settings @@ -49,3 +52,13 @@ def get(self, request, *args, **kwargs): response = JsonResponse(data) response["Access-Control-Allow-Origin"] = "*" return response + + +class UserInfoView(APIView): + """ + View used to show Claims about the authenticated End-User + """ + def get(self, request, *args, **kwargs): + response = JsonResponse(request.auth.id_token.claims) + response["Access-Control-Allow-Origin"] = "*" + return response From 508e9fc1139d202bd0cda4d683ec97cf60d764c1 Mon Sep 17 00:00:00 2001 From: Wiliam Souza Date: Tue, 9 Apr 2019 22:34:30 -0300 Subject: [PATCH 33/57] Update migrations and remove oauthlib duplication --- ...orization_grant_type.py => 0003_authorization_grant_type.py} | 2 +- ...8_application_algorithm.py => 0004_application_algorithm.py} | 2 +- oauth2_provider/migrations/{0009_idtoken.py => 0005_idtoken.py} | 2 +- ...010_accesstoken_id_token.py => 0006_accesstoken_id_token.py} | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) rename oauth2_provider/migrations/{0007_authorization_grant_type.py => 0003_authorization_grant_type.py} (91%) rename oauth2_provider/migrations/{0008_application_algorithm.py => 0004_application_algorithm.py} (87%) rename oauth2_provider/migrations/{0009_idtoken.py => 0005_idtoken.py} (95%) rename oauth2_provider/migrations/{0010_accesstoken_id_token.py => 0006_accesstoken_id_token.py} (92%) diff --git a/oauth2_provider/migrations/0007_authorization_grant_type.py b/oauth2_provider/migrations/0003_authorization_grant_type.py similarity index 91% rename from oauth2_provider/migrations/0007_authorization_grant_type.py rename to oauth2_provider/migrations/0003_authorization_grant_type.py index 3db25bf42..d2939122f 100644 --- a/oauth2_provider/migrations/0007_authorization_grant_type.py +++ b/oauth2_provider/migrations/0003_authorization_grant_type.py @@ -5,7 +5,7 @@ class Migration(migrations.Migration): dependencies = [ - ('oauth2_provider', '0006_auto_20171214_2232'), + ('oauth2_provider', '0002_auto_20190406_1805'), ] operations = [ diff --git a/oauth2_provider/migrations/0008_application_algorithm.py b/oauth2_provider/migrations/0004_application_algorithm.py similarity index 87% rename from oauth2_provider/migrations/0008_application_algorithm.py rename to oauth2_provider/migrations/0004_application_algorithm.py index c4e80aae0..da36aedfa 100644 --- a/oauth2_provider/migrations/0008_application_algorithm.py +++ b/oauth2_provider/migrations/0004_application_algorithm.py @@ -5,7 +5,7 @@ class Migration(migrations.Migration): dependencies = [ - ('oauth2_provider', '0007_authorization_grant_type'), + ('oauth2_provider', '0003_authorization_grant_type'), ] operations = [ diff --git a/oauth2_provider/migrations/0009_idtoken.py b/oauth2_provider/migrations/0005_idtoken.py similarity index 95% rename from oauth2_provider/migrations/0009_idtoken.py rename to oauth2_provider/migrations/0005_idtoken.py index e87fe3b99..ac93dc468 100644 --- a/oauth2_provider/migrations/0009_idtoken.py +++ b/oauth2_provider/migrations/0005_idtoken.py @@ -11,7 +11,7 @@ class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(oauth2_settings.APPLICATION_MODEL), - ('oauth2_provider', '0008_application_algorithm'), + ('oauth2_provider', '0004_application_algorithm'), ] operations = [ diff --git a/oauth2_provider/migrations/0010_accesstoken_id_token.py b/oauth2_provider/migrations/0006_accesstoken_id_token.py similarity index 92% rename from oauth2_provider/migrations/0010_accesstoken_id_token.py rename to oauth2_provider/migrations/0006_accesstoken_id_token.py index 262610139..428ae1d7f 100644 --- a/oauth2_provider/migrations/0010_accesstoken_id_token.py +++ b/oauth2_provider/migrations/0006_accesstoken_id_token.py @@ -9,7 +9,7 @@ class Migration(migrations.Migration): dependencies = [ - ('oauth2_provider', '0009_idtoken'), + ('oauth2_provider', '0005_idtoken'), ] operations = [ From 71505adfb98cde3bcfafcf1bdd35a833d490b50e Mon Sep 17 00:00:00 2001 From: Wiliam Souza Date: Tue, 16 Apr 2019 11:41:45 -0300 Subject: [PATCH 34/57] Remove old generated migrations --- .../0003_authorization_grant_type.py | 17 --------- .../migrations/0004_application_algorithm.py | 17 --------- oauth2_provider/migrations/0005_idtoken.py | 35 ------------------- .../migrations/0006_accesstoken_id_token.py | 21 ----------- 4 files changed, 90 deletions(-) delete mode 100644 oauth2_provider/migrations/0003_authorization_grant_type.py delete mode 100644 oauth2_provider/migrations/0004_application_algorithm.py delete mode 100644 oauth2_provider/migrations/0005_idtoken.py delete mode 100644 oauth2_provider/migrations/0006_accesstoken_id_token.py diff --git a/oauth2_provider/migrations/0003_authorization_grant_type.py b/oauth2_provider/migrations/0003_authorization_grant_type.py deleted file mode 100644 index d2939122f..000000000 --- a/oauth2_provider/migrations/0003_authorization_grant_type.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 1.11.4 on 2017-09-03 16:32 -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('oauth2_provider', '0002_auto_20190406_1805'), - ] - - operations = [ - migrations.AlterField( - model_name='application', - name='authorization_grant_type', - 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), - ), - ] diff --git a/oauth2_provider/migrations/0004_application_algorithm.py b/oauth2_provider/migrations/0004_application_algorithm.py deleted file mode 100644 index da36aedfa..000000000 --- a/oauth2_provider/migrations/0004_application_algorithm.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 1.11.4 on 2017-09-16 18:55 -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('oauth2_provider', '0003_authorization_grant_type'), - ] - - operations = [ - migrations.AddField( - model_name='application', - name='algorithm', - field=models.CharField(choices=[('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')], default='RS256', max_length=5), - ), - ] diff --git a/oauth2_provider/migrations/0005_idtoken.py b/oauth2_provider/migrations/0005_idtoken.py deleted file mode 100644 index ac93dc468..000000000 --- a/oauth2_provider/migrations/0005_idtoken.py +++ /dev/null @@ -1,35 +0,0 @@ -# Generated by Django 1.11.4 on 2017-10-01 19:13 -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - -from oauth2_provider.settings import oauth2_settings - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - migrations.swappable_dependency(oauth2_settings.APPLICATION_MODEL), - ('oauth2_provider', '0004_application_algorithm'), - ] - - operations = [ - migrations.CreateModel( - name='IDToken', - fields=[ - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('token', models.TextField(unique=True)), - ('expires', models.DateTimeField()), - ('scope', models.TextField(blank=True)), - ('created', models.DateTimeField(auto_now_add=True)), - ('updated', models.DateTimeField(auto_now=True)), - ('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=oauth2_settings.APPLICATION_MODEL)), - ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='oauth2_provider_idtoken', to=settings.AUTH_USER_MODEL)), - ], - options={ - 'abstract': False, - 'swappable': 'OAUTH2_PROVIDER_ID_TOKEN_MODEL', - }, - ), - ] diff --git a/oauth2_provider/migrations/0006_accesstoken_id_token.py b/oauth2_provider/migrations/0006_accesstoken_id_token.py deleted file mode 100644 index 428ae1d7f..000000000 --- a/oauth2_provider/migrations/0006_accesstoken_id_token.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 2.1.5 on 2019-01-28 18:46 - -from django.db import migrations, models -import django.db.models.deletion - -from oauth2_provider.settings import oauth2_settings - - -class Migration(migrations.Migration): - - dependencies = [ - ('oauth2_provider', '0005_idtoken'), - ] - - operations = [ - migrations.AddField( - model_name='accesstoken', - name='id_token', - field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_token', to=oauth2_settings.ID_TOKEN_MODEL), - ), - ] From 78df470c1b5086254214a3702abff4afdc2b2408 Mon Sep 17 00:00:00 2001 From: Wiliam Souza Date: Tue, 16 Apr 2019 11:42:39 -0300 Subject: [PATCH 35/57] Add new migrations --- .../migrations/0003_auto_20190413_2007.py | 23 +++++++++++++ oauth2_provider/migrations/0004_idtoken.py | 33 +++++++++++++++++++ .../migrations/0005_accesstoken_id_token.py | 20 +++++++++++ 3 files changed, 76 insertions(+) create mode 100644 oauth2_provider/migrations/0003_auto_20190413_2007.py create mode 100644 oauth2_provider/migrations/0004_idtoken.py create mode 100644 oauth2_provider/migrations/0005_accesstoken_id_token.py diff --git a/oauth2_provider/migrations/0003_auto_20190413_2007.py b/oauth2_provider/migrations/0003_auto_20190413_2007.py new file mode 100644 index 000000000..472886147 --- /dev/null +++ b/oauth2_provider/migrations/0003_auto_20190413_2007.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2 on 2019-04-13 20:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('oauth2_provider', '0002_auto_20190406_1805'), + ] + + operations = [ + migrations.AddField( + model_name='application', + name='algorithm', + field=models.CharField(choices=[('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')], default='RS256', max_length=5), + ), + migrations.AlterField( + model_name='application', + name='authorization_grant_type', + 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), + ), + ] diff --git a/oauth2_provider/migrations/0004_idtoken.py b/oauth2_provider/migrations/0004_idtoken.py new file mode 100644 index 000000000..e0d43b2dc --- /dev/null +++ b/oauth2_provider/migrations/0004_idtoken.py @@ -0,0 +1,33 @@ +# Generated by Django 2.2 on 2019-04-16 14:36 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('oauth2_provider', '0003_auto_20190413_2007'), + ] + + operations = [ + migrations.CreateModel( + name='IDToken', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('token', models.TextField(unique=True)), + ('expires', models.DateTimeField()), + ('scope', models.TextField(blank=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='oauth2_provider_idtoken', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + 'swappable': 'OAUTH2_PROVIDER_ID_TOKEN_MODEL', + }, + ), + ] diff --git a/oauth2_provider/migrations/0005_accesstoken_id_token.py b/oauth2_provider/migrations/0005_accesstoken_id_token.py new file mode 100644 index 000000000..a6ca7dd25 --- /dev/null +++ b/oauth2_provider/migrations/0005_accesstoken_id_token.py @@ -0,0 +1,20 @@ +# Generated by Django 2.2 on 2019-04-16 14:39 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('oauth2_provider', '0004_idtoken'), + ] + + operations = [ + migrations.AddField( + model_name='accesstoken', + name='id_token', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_token', to=settings.OAUTH2_PROVIDER_ID_TOKEN_MODEL), + ), + ] From bd8f3f54d3f88881537603cd5ae5f7075b500ad6 Mon Sep 17 00:00:00 2001 From: Wiliam Souza Date: Tue, 16 Apr 2019 17:00:15 -0300 Subject: [PATCH 36/57] Fix tests --- tests/settings.py | 5 ++ tests/test_models.py | 136 ++++++++++++++++++++++++++++++------------- 2 files changed, 99 insertions(+), 42 deletions(-) diff --git a/tests/settings.py b/tests/settings.py index 43cce99c2..19f95e31f 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -136,3 +136,8 @@ "OIDC_USERINFO_ENDPOINT": "http://localhost/userinfo/", "OIDC_RSA_PRIVATE_KEY": "-----BEGIN RSA PRIVATE KEY-----\nMIICXQIBAAKBgQCbCYh5h2NmQuBqVO6G+/CO+cHm9VBzsb0MeA6bbQfDnbhstVOT\nj0hcnZJzDjYc6ajBZZf6gxVP9xrdm9Uh599VI3X5PFXLbMHrmzTAMzCGIyg+/fnP\n0gocYxmCX2+XKyj/Zvt1pUX8VAN2AhrJSfxNDKUHERTVEV9bRBJg4F0C3wIDAQAB\nAoGAP+i4nNw+Ec/8oWh8YSFm4xE6qKG0NdTtSMAOyWwy+KTB+vHuT1QPsLn1vj77\n+IQrX/moogg6F1oV9YdA3vat3U7rwt1sBGsRrLhA+Spp9WEQtglguNo4+QfVo2ju\nYBa2rG+h75qjiA3xnU//F3rvwnAsOWv0NUVdVeguyR+u6okCQQDBUmgWeH2WHmUn\n2nLNCz+9wj28rqhfOr9Ptem2gqk+ywJmuIr4Y5S1OdavOr2UZxOcEwncJ/MLVYQq\nMH+x4V5HAkEAzU2GMR5OdVLcxfVTjzuIC76paoHVWnLibd1cdANpPmE6SM+pf5el\nfVSwuH9Fmlizu8GiPCxbJUoXB/J1tGEKqQJBALhClEU+qOzpoZ6/voYi/6kdN3zc\nuEy0EN6n09AKb8gS9QH1STgAqh+ltjMkeMe3C2DKYK5/QU9/Pc58lWl1FkcCQG67\nZamQgxjcvJ85FvymS1aqW45KwNysIlzHjFo2jMlMf7dN6kobbPMQftDENLJvLWIT\nqoFyGycdsxZiPAIyZSECQQCZFn3Dl6hnJxWZH8Fsa9hj79kZ/WVkIXGmtdgt0fNr\ndTnvCVtA59ne4LEVie/PMH/odQWY0SxVm/76uBZv/1vY\n-----END RSA PRIVATE KEY-----" } + +OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = 'oauth2_provider.AccessToken' +OAUTH2_PROVIDER_APPLICATION_MODEL = 'oauth2_provider.Application' +OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = 'oauth2_provider.RefreshToken' +OAUTH2_PROVIDER_ID_TOKEN_MODEL = 'oauth2_provider.IDToken' diff --git a/tests/test_models.py b/tests/test_models.py index 95e8eb414..dc8dce56e 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,3 +1,5 @@ +from datetime import datetime as dt + import pytest from django.contrib.auth import get_user_model from django.core.exceptions import ImproperlyConfigured, ValidationError @@ -6,23 +8,30 @@ from django.utils import timezone from oauth2_provider.models import ( - clear_expired, get_access_token_model, get_application_model, - get_grant_model, get_refresh_token_model + clear_expired, + get_access_token_model, + get_application_model, + get_grant_model, + get_refresh_token_model, + get_id_token_model, ) from oauth2_provider.settings import oauth2_settings +from .models import SampleRefreshToken Application = get_application_model() Grant = get_grant_model() AccessToken = get_access_token_model() RefreshToken = get_refresh_token_model() UserModel = get_user_model() +IDToken = get_id_token_model() class TestModels(TestCase): - def setUp(self): - self.user = UserModel.objects.create_user("test_user", "test@example.com", "123456") + self.user = UserModel.objects.create_user( + "test_user", "test@example.com", "123456" + ) def test_allow_scopes(self): self.client.login(username="test_user", password="123456") @@ -35,11 +44,7 @@ def test_allow_scopes(self): ) access_token = AccessToken( - user=self.user, - scope="read write", - expires=0, - token="", - application=app + user=self.user, scope="read write", expires=0, token="", application=app ) self.assertTrue(access_token.allow_scopes(["read", "write"])) @@ -94,22 +99,16 @@ def test_scopes_property(self): ) access_token = AccessToken( - user=self.user, - scope="read write", - expires=0, - token="", - application=app + user=self.user, scope="read write", expires=0, token="", application=app ) access_token2 = AccessToken( - user=self.user, - scope="write", - expires=0, - token="", - application=app + user=self.user, scope="write", expires=0, token="", application=app ) - self.assertEqual(access_token.scopes, {"read": "Reading scope", "write": "Writing scope"}) + self.assertEqual( + access_token.scopes, {"read": "Reading scope", "write": "Writing scope"} + ) self.assertEqual(access_token2.scopes, {"write": "Writing scope"}) @@ -117,12 +116,13 @@ def test_scopes_property(self): OAUTH2_PROVIDER_APPLICATION_MODEL="tests.SampleApplication", OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL="tests.SampleAccessToken", OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL="tests.SampleRefreshToken", - OAUTH2_PROVIDER_GRANT_MODEL="tests.SampleGrant" + OAUTH2_PROVIDER_GRANT_MODEL="tests.SampleGrant", ) class TestCustomModels(TestCase): - def setUp(self): - self.user = UserModel.objects.create_user("test_user", "test@example.com", "123456") + self.user = UserModel.objects.create_user( + "test_user", "test@example.com", "123456" + ) def test_custom_application_model(self): """ @@ -132,7 +132,8 @@ def test_custom_application_model(self): See issue #90 (https://github.com/jazzband/django-oauth-toolkit/issues/90) """ related_object_names = [ - f.name for f in UserModel._meta.get_fields() + f.name + for f in UserModel._meta.get_fields() if (f.one_to_many or f.one_to_one) and f.auto_created and not f.concrete ] self.assertNotIn("oauth2_provider:application", related_object_names) @@ -163,7 +164,8 @@ def test_custom_access_token_model(self): """ # Django internals caches the related objects. related_object_names = [ - f.name for f in UserModel._meta.get_fields() + f.name + for f in UserModel._meta.get_fields() if (f.one_to_many or f.one_to_one) and f.auto_created and not f.concrete ] self.assertNotIn("oauth2_provider:access_token", related_object_names) @@ -194,7 +196,8 @@ def test_custom_refresh_token_model(self): """ # Django internals caches the related objects. related_object_names = [ - f.name for f in UserModel._meta.get_fields() + f.name + for f in UserModel._meta.get_fields() if (f.one_to_many or f.one_to_one) and f.auto_created and not f.concrete ] self.assertNotIn("oauth2_provider:refresh_token", related_object_names) @@ -225,7 +228,8 @@ def test_custom_grant_model(self): """ # Django internals caches the related objects. related_object_names = [ - f.name for f in UserModel._meta.get_fields() + f.name + for f in UserModel._meta.get_fields() if (f.one_to_many or f.one_to_one) and f.auto_created and not f.concrete ] self.assertNotIn("oauth2_provider:grant", related_object_names) @@ -251,7 +255,6 @@ def test_custom_grant_model_not_installed(self): class TestGrantModel(TestCase): - def test_str(self): grant = Grant(code="test_code") self.assertEqual("%s" % grant, grant.code) @@ -263,9 +266,10 @@ def test_expires_can_be_none(self): class TestAccessTokenModel(TestCase): - def setUp(self): - self.user = UserModel.objects.create_user("test_user", "test@example.com", "123456") + self.user = UserModel.objects.create_user( + "test_user", "test@example.com", "123456" + ) def test_str(self): access_token = AccessToken(token="test_token") @@ -279,7 +283,9 @@ def test_user_can_be_none(self): client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) - access_token = AccessToken.objects.create(token="test_token", application=app, expires=timezone.now()) + access_token = AccessToken.objects.create( + token="test_token", application=app, expires=timezone.now() + ) self.assertIsNone(access_token.user) def test_expires_can_be_none(self): @@ -289,16 +295,58 @@ def test_expires_can_be_none(self): class TestRefreshTokenModel(TestCase): - def test_str(self): refresh_token = RefreshToken(token="test_token") self.assertEqual("%s" % refresh_token, refresh_token.token) class TestClearExpired(TestCase): - def setUp(self): - self.user = UserModel.objects.create_user("test_user", "test@example.com", "123456") + self.user = UserModel.objects.create_user( + "test_user", "test@example.com", "123456" + ) + app1 = Application.objects.create( + name="Test Application", + redirect_uris=( + "http://localhost http://example.com http://example.org custom-scheme://example.com" + ), + user=self.user, + client_type=Application.CLIENT_CONFIDENTIAL, + authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + ) + app2 = Application.objects.create( + name="Test Application", + redirect_uris=( + "http://localhost http://example.com http://example.org custom-scheme://example.com" + ), + user=self.user, + client_type=Application.CLIENT_CONFIDENTIAL, + authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + ) + id1 = IDToken.objects.create( + token="666", + expires=dt.now(), + scope=2, + application=app1, + user=self.user, + created=dt.now(), + updated=dt.now(), + ) + id2 = IDToken.objects.create( + token="999", + expires=dt.now(), + scope=2, + application=app2, + user=self.user, + created=dt.now(), + updated=dt.now(), + ) + refresh_token1 = SampleRefreshToken.objects.create( + token="test_token", application=app1, user=self.user, + ) + refresh_token2 = SampleRefreshToken.objects.create( + token="test_token2", application=app2, user=self.user, + ) # Insert two tokens on database. app = Application.objects.create( name="test_app", @@ -311,20 +359,24 @@ def setUp(self): token="555", expires=timezone.now(), scope=2, - application=app, + application=app1, + id_token=id1, user=self.user, - created=timezone.now(), - updated=timezone.now(), - ) + created=dt.now(), + updated=dt.now(), + refresh_token=refresh_token1, + ) AccessToken.objects.create( token="666", expires=timezone.now(), scope=2, - application=app, + application=app2, user=self.user, - created=timezone.now(), - updated=timezone.now(), - ) + id_token=id2, + created=dt.now(), + updated=dt.now(), + refresh_token=refresh_token2, + ) def test_clear_expired_tokens(self): oauth2_settings.REFRESH_TOKEN_EXPIRE_SECONDS = 60 From b5d05f63547a41cdc622873e510ee526f5b873fb Mon Sep 17 00:00:00 2001 From: Wiliam Souza Date: Fri, 12 Jul 2019 21:15:28 -0300 Subject: [PATCH 37/57] Add nonce to hybrid tests --- tests/test_hybrid.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_hybrid.py b/tests/test_hybrid.py index 206b4e282..da3d0b5e6 100644 --- a/tests/test_hybrid.py +++ b/tests/test_hybrid.py @@ -95,6 +95,7 @@ def test_request_is_not_overwritten_code_id_token(self): "state": "random_state_string", "scope": "openid read write", "redirect_uri": "http://example.org", + "nonce": "nonce", }) url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) @@ -110,6 +111,7 @@ def test_request_is_not_overwritten_code_id_token_token(self): "state": "random_state_string", "scope": "openid read write", "redirect_uri": "http://example.org", + "nonce": "nonce", }) url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) @@ -217,6 +219,7 @@ def test_id_token_pre_auth_valid_client(self): "state": "random_state_string", "scope": "openid", "redirect_uri": "http://example.org", + "nonce": "nonce", }) url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) @@ -413,6 +416,7 @@ def test_code_post_auth_allow_code_id_token(self): "redirect_uri": "http://example.org", "response_type": "code id_token", "allow": True, + "nonce": "nonce", } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) @@ -435,6 +439,7 @@ def test_code_post_auth_allow_code_id_token_token(self): "redirect_uri": "http://example.org", "response_type": "code id_token token", "allow": True, + "nonce": "nonce", } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) @@ -458,6 +463,7 @@ def test_id_token_code_post_auth_allow(self): "redirect_uri": "http://example.org", "response_type": "code id_token", "allow": True, + "nonce": "nonce", } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) @@ -578,6 +584,7 @@ def test_code_post_auth_allow_custom_redirect_uri_scheme_code_id_token(self): "redirect_uri": "custom-scheme://example.com", "response_type": "code id_token", "allow": True, + "nonce": "nonce", } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) @@ -601,6 +608,7 @@ def test_code_post_auth_allow_custom_redirect_uri_scheme_code_id_token_token(sel "redirect_uri": "custom-scheme://example.com", "response_type": "code id_token token", "allow": True, + "nonce": "nonce", } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) @@ -670,6 +678,7 @@ def test_code_post_auth_redirection_uri_with_querystring_code_id_token(self): "redirect_uri": "http://example.com?foo=bar", "response_type": "code id_token", "allow": True, + "nonce": "nonce", } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) @@ -693,6 +702,7 @@ def test_code_post_auth_redirection_uri_with_querystring_code_id_token_token(sel "redirect_uri": "http://example.com?foo=bar", "response_type": "code id_token token", "allow": True, + "nonce": "nonce", } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) @@ -754,6 +764,7 @@ def get_auth(self, scope="read write"): "redirect_uri": "http://example.org", "response_type": "code id_token", "allow": True, + "nonce": "nonce", } response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) From 87d300a3971ebff7bee50627b8654dc649654672 Mon Sep 17 00:00:00 2001 From: fvlima Date: Sun, 1 Mar 2020 15:08:09 -0300 Subject: [PATCH 38/57] Add missing new attributes to test migration --- tests/migrations/0001_initial.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/migrations/0001_initial.py b/tests/migrations/0001_initial.py index 60b17f2ae..eef6dbab5 100644 --- a/tests/migrations/0001_initial.py +++ b/tests/migrations/0001_initial.py @@ -45,7 +45,7 @@ class Migration(migrations.Migration): ('client_id', models.CharField(db_index=True, default=oauth2_provider.generators.generate_client_id, max_length=100, unique=True)), ('redirect_uris', models.TextField(blank=True, help_text='Allowed URIs list, space separated')), ('client_type', models.CharField(choices=[('confidential', 'Confidential'), ('public', 'Public')], max_length=32)), - ('authorization_grant_type', models.CharField(choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials')], max_length=32)), + ('authorization_grant_type', 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)), ('client_secret', models.CharField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, max_length=255)), ('name', models.CharField(blank=True, max_length=255)), ('skip_authorization', models.BooleanField(default=False)), @@ -53,6 +53,7 @@ class Migration(migrations.Migration): ('updated', models.DateTimeField(auto_now=True)), ('custom_field', models.CharField(max_length=255)), ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tests_sampleapplication', to=settings.AUTH_USER_MODEL)), + ('algorithm', models.CharField(max_length=5, choices=[('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')], default='RS256')), ], options={ 'abstract': False, @@ -71,6 +72,7 @@ class Migration(migrations.Migration): ('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), ('source_refresh_token', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='s_refreshed_access_token', to=settings.OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL)), ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tests_sampleaccesstoken', to=settings.AUTH_USER_MODEL)), + ('id_token', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_token', to=settings.OAUTH2_PROVIDER_ID_TOKEN_MODEL)), ], options={ 'abstract': False, @@ -83,7 +85,7 @@ class Migration(migrations.Migration): ('client_id', models.CharField(db_index=True, default=oauth2_provider.generators.generate_client_id, max_length=100, unique=True)), ('redirect_uris', models.TextField(blank=True, help_text='Allowed URIs list, space separated')), ('client_type', models.CharField(choices=[('confidential', 'Confidential'), ('public', 'Public')], max_length=32)), - ('authorization_grant_type', models.CharField(choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials')], max_length=32)), + ('authorization_grant_type', 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)), ('client_secret', models.CharField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, max_length=255)), ('name', models.CharField(blank=True, max_length=255)), ('skip_authorization', models.BooleanField(default=False)), @@ -91,6 +93,7 @@ class Migration(migrations.Migration): ('updated', models.DateTimeField(auto_now=True)), ('allowed_schemes', models.TextField(blank=True)), ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tests_basetestapplication', to=settings.AUTH_USER_MODEL)), + ('algorithm', models.CharField(max_length=5, choices=[('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')], default='RS256')), ], options={ 'abstract': False, From 2f386901a7a5b27bb455cdba633264cd31ab50c7 Mon Sep 17 00:00:00 2001 From: Wiliam Souza Date: Sun, 1 Mar 2020 23:39:53 -0300 Subject: [PATCH 39/57] Rebase fixing conflicts and tests --- oauth2_provider/oauth2_validators.py | 21 +++++ oauth2_provider/views/base.py | 10 +- tests/test_models.py | 136 +++++++++------------------ 3 files changed, 68 insertions(+), 99 deletions(-) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index c05ca75d3..de10cde7a 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -870,3 +870,24 @@ def validate_user_match(self, id_token_hint, scopes, claims, request): # https://github.com/idan/oauthlib/blob/master/oauthlib/oauth2/rfc6749/request_validator.py#L556 # http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest id_token_hint section return True + + def get_authorization_code_nonce(self, client_id, code, redirect_uri, request): + """ Extracts nonce from saved authorization code. + If present in the Authentication Request, Authorization + Servers MUST include a nonce Claim in the ID Token with the + Claim Value being the nonce value sent in the Authentication + Request. Authorization Servers SHOULD perform no other + processing on nonce values used. The nonce value is a + case-sensitive string. + Only code param should be sufficient to retrieve grant code from + any storage you are using. However, `client_id` and `redirect_uri` + have been validated and can be used also. + :param client_id: Unicode client identifier + :param code: Unicode authorization code grant + :param redirect_uri: Unicode absolute URI + :return: Unicode nonce + Method is used by: + - Authorization Token Grant Dispatcher + """ + # TODO: Fix this ;) + return "" diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index 62908c9a6..eb825c307 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -145,7 +145,7 @@ def form_valid(self, form): except OAuthToolkitError as error: return self.error_response(error, application) - self.success_url = redirect_uri + self.success_url = uri log.debug("Success url for the request: {0}".format(self.success_url)) return self.redirect(self.success_url, application) @@ -166,9 +166,9 @@ def get(self, request, *args, **kwargs): client_id=credentials["client_id"] ) - uri_query = parse.urlparse(self.request.get_raw_uri()).query + uri_query = urllib.parse.urlparse(self.request.get_raw_uri()).query uri_query_params = dict( - parse.parse_qsl(uri_query, keep_blank_values=True, strict_parsing=True) + urllib.parse.parse_qsl(uri_query, keep_blank_values=True, strict_parsing=True) ) kwargs["application"] = application @@ -202,7 +202,7 @@ def get(self, request, *args, **kwargs): credentials=credentials, allow=True, ) - return self.redirect(redirect_uri, application) + return self.redirect(uri, application) elif require_approval == "auto": tokens = ( @@ -225,7 +225,7 @@ def get(self, request, *args, **kwargs): credentials=credentials, allow=True, ) - return self.redirect(redirect_uri, application) + return self.redirect(uri, application) except OAuthToolkitError as error: return self.error_response(error, application) diff --git a/tests/test_models.py b/tests/test_models.py index dc8dce56e..95e8eb414 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,5 +1,3 @@ -from datetime import datetime as dt - import pytest from django.contrib.auth import get_user_model from django.core.exceptions import ImproperlyConfigured, ValidationError @@ -8,30 +6,23 @@ from django.utils import timezone from oauth2_provider.models import ( - clear_expired, - get_access_token_model, - get_application_model, - get_grant_model, - get_refresh_token_model, - get_id_token_model, + clear_expired, get_access_token_model, get_application_model, + get_grant_model, get_refresh_token_model ) from oauth2_provider.settings import oauth2_settings -from .models import SampleRefreshToken Application = get_application_model() Grant = get_grant_model() AccessToken = get_access_token_model() RefreshToken = get_refresh_token_model() UserModel = get_user_model() -IDToken = get_id_token_model() class TestModels(TestCase): + def setUp(self): - self.user = UserModel.objects.create_user( - "test_user", "test@example.com", "123456" - ) + self.user = UserModel.objects.create_user("test_user", "test@example.com", "123456") def test_allow_scopes(self): self.client.login(username="test_user", password="123456") @@ -44,7 +35,11 @@ def test_allow_scopes(self): ) access_token = AccessToken( - user=self.user, scope="read write", expires=0, token="", application=app + user=self.user, + scope="read write", + expires=0, + token="", + application=app ) self.assertTrue(access_token.allow_scopes(["read", "write"])) @@ -99,16 +94,22 @@ def test_scopes_property(self): ) access_token = AccessToken( - user=self.user, scope="read write", expires=0, token="", application=app + user=self.user, + scope="read write", + expires=0, + token="", + application=app ) access_token2 = AccessToken( - user=self.user, scope="write", expires=0, token="", application=app + user=self.user, + scope="write", + expires=0, + token="", + application=app ) - self.assertEqual( - access_token.scopes, {"read": "Reading scope", "write": "Writing scope"} - ) + self.assertEqual(access_token.scopes, {"read": "Reading scope", "write": "Writing scope"}) self.assertEqual(access_token2.scopes, {"write": "Writing scope"}) @@ -116,13 +117,12 @@ def test_scopes_property(self): OAUTH2_PROVIDER_APPLICATION_MODEL="tests.SampleApplication", OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL="tests.SampleAccessToken", OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL="tests.SampleRefreshToken", - OAUTH2_PROVIDER_GRANT_MODEL="tests.SampleGrant", + OAUTH2_PROVIDER_GRANT_MODEL="tests.SampleGrant" ) class TestCustomModels(TestCase): + def setUp(self): - self.user = UserModel.objects.create_user( - "test_user", "test@example.com", "123456" - ) + self.user = UserModel.objects.create_user("test_user", "test@example.com", "123456") def test_custom_application_model(self): """ @@ -132,8 +132,7 @@ def test_custom_application_model(self): See issue #90 (https://github.com/jazzband/django-oauth-toolkit/issues/90) """ related_object_names = [ - f.name - for f in UserModel._meta.get_fields() + f.name for f in UserModel._meta.get_fields() if (f.one_to_many or f.one_to_one) and f.auto_created and not f.concrete ] self.assertNotIn("oauth2_provider:application", related_object_names) @@ -164,8 +163,7 @@ def test_custom_access_token_model(self): """ # Django internals caches the related objects. related_object_names = [ - f.name - for f in UserModel._meta.get_fields() + f.name for f in UserModel._meta.get_fields() if (f.one_to_many or f.one_to_one) and f.auto_created and not f.concrete ] self.assertNotIn("oauth2_provider:access_token", related_object_names) @@ -196,8 +194,7 @@ def test_custom_refresh_token_model(self): """ # Django internals caches the related objects. related_object_names = [ - f.name - for f in UserModel._meta.get_fields() + f.name for f in UserModel._meta.get_fields() if (f.one_to_many or f.one_to_one) and f.auto_created and not f.concrete ] self.assertNotIn("oauth2_provider:refresh_token", related_object_names) @@ -228,8 +225,7 @@ def test_custom_grant_model(self): """ # Django internals caches the related objects. related_object_names = [ - f.name - for f in UserModel._meta.get_fields() + f.name for f in UserModel._meta.get_fields() if (f.one_to_many or f.one_to_one) and f.auto_created and not f.concrete ] self.assertNotIn("oauth2_provider:grant", related_object_names) @@ -255,6 +251,7 @@ def test_custom_grant_model_not_installed(self): class TestGrantModel(TestCase): + def test_str(self): grant = Grant(code="test_code") self.assertEqual("%s" % grant, grant.code) @@ -266,10 +263,9 @@ def test_expires_can_be_none(self): class TestAccessTokenModel(TestCase): + def setUp(self): - self.user = UserModel.objects.create_user( - "test_user", "test@example.com", "123456" - ) + self.user = UserModel.objects.create_user("test_user", "test@example.com", "123456") def test_str(self): access_token = AccessToken(token="test_token") @@ -283,9 +279,7 @@ def test_user_can_be_none(self): client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) - access_token = AccessToken.objects.create( - token="test_token", application=app, expires=timezone.now() - ) + access_token = AccessToken.objects.create(token="test_token", application=app, expires=timezone.now()) self.assertIsNone(access_token.user) def test_expires_can_be_none(self): @@ -295,58 +289,16 @@ def test_expires_can_be_none(self): class TestRefreshTokenModel(TestCase): + def test_str(self): refresh_token = RefreshToken(token="test_token") self.assertEqual("%s" % refresh_token, refresh_token.token) class TestClearExpired(TestCase): + def setUp(self): - self.user = UserModel.objects.create_user( - "test_user", "test@example.com", "123456" - ) - app1 = Application.objects.create( - name="Test Application", - redirect_uris=( - "http://localhost http://example.com http://example.org custom-scheme://example.com" - ), - user=self.user, - client_type=Application.CLIENT_CONFIDENTIAL, - authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, - ) - app2 = Application.objects.create( - name="Test Application", - redirect_uris=( - "http://localhost http://example.com http://example.org custom-scheme://example.com" - ), - user=self.user, - client_type=Application.CLIENT_CONFIDENTIAL, - authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, - ) - id1 = IDToken.objects.create( - token="666", - expires=dt.now(), - scope=2, - application=app1, - user=self.user, - created=dt.now(), - updated=dt.now(), - ) - id2 = IDToken.objects.create( - token="999", - expires=dt.now(), - scope=2, - application=app2, - user=self.user, - created=dt.now(), - updated=dt.now(), - ) - refresh_token1 = SampleRefreshToken.objects.create( - token="test_token", application=app1, user=self.user, - ) - refresh_token2 = SampleRefreshToken.objects.create( - token="test_token2", application=app2, user=self.user, - ) + self.user = UserModel.objects.create_user("test_user", "test@example.com", "123456") # Insert two tokens on database. app = Application.objects.create( name="test_app", @@ -359,24 +311,20 @@ def setUp(self): token="555", expires=timezone.now(), scope=2, - application=app1, - id_token=id1, + application=app, user=self.user, - created=dt.now(), - updated=dt.now(), - refresh_token=refresh_token1, - ) + created=timezone.now(), + updated=timezone.now(), + ) AccessToken.objects.create( token="666", expires=timezone.now(), scope=2, - application=app2, + application=app, user=self.user, - id_token=id2, - created=dt.now(), - updated=dt.now(), - refresh_token=refresh_token2, - ) + created=timezone.now(), + updated=timezone.now(), + ) def test_clear_expired_tokens(self): oauth2_settings.REFRESH_TOKEN_EXPIRE_SECONDS = 60 From bc065e9a492d09767e7d866e90353a7796010a53 Mon Sep 17 00:00:00 2001 From: Wiliam Souza Date: Mon, 2 Mar 2020 00:35:40 -0300 Subject: [PATCH 40/57] Remove auto generate message --- oauth2_provider/migrations/0002_auto_20190406_1805.py | 2 -- oauth2_provider/migrations/0003_auto_20190413_2007.py | 2 -- oauth2_provider/migrations/0004_idtoken.py | 2 -- oauth2_provider/migrations/0005_accesstoken_id_token.py | 2 -- 4 files changed, 8 deletions(-) diff --git a/oauth2_provider/migrations/0002_auto_20190406_1805.py b/oauth2_provider/migrations/0002_auto_20190406_1805.py index 8ca177abf..bcacc23ce 100644 --- a/oauth2_provider/migrations/0002_auto_20190406_1805.py +++ b/oauth2_provider/migrations/0002_auto_20190406_1805.py @@ -1,5 +1,3 @@ -# Generated by Django 2.2 on 2019-04-06 18:05 - from django.db import migrations, models diff --git a/oauth2_provider/migrations/0003_auto_20190413_2007.py b/oauth2_provider/migrations/0003_auto_20190413_2007.py index 472886147..b27bd4ebb 100644 --- a/oauth2_provider/migrations/0003_auto_20190413_2007.py +++ b/oauth2_provider/migrations/0003_auto_20190413_2007.py @@ -1,5 +1,3 @@ -# Generated by Django 2.2 on 2019-04-13 20:07 - from django.db import migrations, models diff --git a/oauth2_provider/migrations/0004_idtoken.py b/oauth2_provider/migrations/0004_idtoken.py index e0d43b2dc..853a7089f 100644 --- a/oauth2_provider/migrations/0004_idtoken.py +++ b/oauth2_provider/migrations/0004_idtoken.py @@ -1,5 +1,3 @@ -# Generated by Django 2.2 on 2019-04-16 14:36 - from django.conf import settings from django.db import migrations, models import django.db.models.deletion diff --git a/oauth2_provider/migrations/0005_accesstoken_id_token.py b/oauth2_provider/migrations/0005_accesstoken_id_token.py index a6ca7dd25..0a14a058c 100644 --- a/oauth2_provider/migrations/0005_accesstoken_id_token.py +++ b/oauth2_provider/migrations/0005_accesstoken_id_token.py @@ -1,5 +1,3 @@ -# Generated by Django 2.2 on 2019-04-16 14:39 - from django.conf import settings from django.db import migrations, models import django.db.models.deletion From dd47f202c367511997d891ebde0416fa834da749 Mon Sep 17 00:00:00 2001 From: fvlima Date: Sun, 26 Apr 2020 19:00:49 -0300 Subject: [PATCH 41/57] Fix flake8 issues --- oauth2_provider/admin.py | 7 ++----- oauth2_provider/models.py | 5 ++--- oauth2_provider/oauth2_validators.py | 13 ++++++------- oauth2_provider/urls.py | 3 ++- oauth2_provider/views/oidc.py | 10 +++++----- tests/settings.py | 26 +++++++++++++++++++++----- tests/test_authorization_code.py | 2 -- tests/test_hybrid.py | 5 +++-- tests/test_oauth2_backends.py | 4 +++- tests/test_oidc_views.py | 2 +- 10 files changed, 45 insertions(+), 32 deletions(-) diff --git a/oauth2_provider/admin.py b/oauth2_provider/admin.py index d529f5073..a8d69e623 100644 --- a/oauth2_provider/admin.py +++ b/oauth2_provider/admin.py @@ -1,11 +1,8 @@ from django.contrib import admin from .models import ( - get_access_token_model, - get_application_model, - get_grant_model, - get_refresh_token_model, - get_id_token_model, + get_access_token_model, get_application_model, + get_grant_model, get_id_token_model, get_refresh_token_model ) diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 1027ab515..7135192db 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -1,10 +1,8 @@ -import logging import json +import logging from datetime import timedelta from urllib.parse import parse_qsl, urlparse -from jwcrypto import jwk, jwt - from django.apps import apps from django.conf import settings from django.core.exceptions import ImproperlyConfigured @@ -12,6 +10,7 @@ from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext_lazy as _ +from jwcrypto import jwk, jwt from .generators import generate_client_id, generate_client_secret from .scopes import get_scopes_backend diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index de10cde7a..f1c605590 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -1,8 +1,8 @@ import base64 import binascii -import json import hashlib import http.client +import json import logging from collections import OrderedDict from datetime import datetime, timedelta @@ -17,12 +17,11 @@ from django.utils import dateformat, timezone from django.utils.timezone import make_aware from django.utils.translation import gettext_lazy as _ -from oauthlib.oauth2 import RequestValidator -from oauthlib.oauth2.rfc6749 import utils - -from jwcrypto.common import JWException from jwcrypto import jwk, jwt +from jwcrypto.common import JWException from jwcrypto.jwt import JWTExpired +from oauthlib.oauth2 import RequestValidator +from oauthlib.oauth2.rfc6749 import utils from .exceptions import FatalClientError from .models import ( @@ -807,7 +806,7 @@ def get_id_token(self, token, token_handler, request): # http://openid.net/specs/openid-connect-core-1_0.html#ImplicitIDToken # if request.grant_type in 'authorization_code' and 'access_token' in token: if ( - (request.grant_type is "authorization_code" and "access_token" in token) + (request.grant_type == "authorization_code" and "access_token" in token) or request.response_type == "code id_token token" or (request.response_type == "id_token token" and "access_token" in token) ): @@ -890,4 +889,4 @@ def get_authorization_code_nonce(self, client_id, code, redirect_uri, request): - Authorization Token Grant Dispatcher """ # TODO: Fix this ;) - return "" + return "" diff --git a/oauth2_provider/urls.py b/oauth2_provider/urls.py index aa29687be..d29c54168 100644 --- a/oauth2_provider/urls.py +++ b/oauth2_provider/urls.py @@ -28,7 +28,8 @@ ] oidc_urlpatterns = [ - url(r"^\.well-known/openid-configuration/$", views.ConnectDiscoveryInfoView.as_view(), name="oidc-connect-discovery-info"), + url(r"^\.well-known/openid-configuration/$", views.ConnectDiscoveryInfoView.as_view(), + name="oidc-connect-discovery-info"), url(r"^jwks/$", views.JwksInfoView.as_view(), name="jwks-info"), url(r"^userinfo/$", views.UserInfoView.as_view(), name="user-info") ] diff --git a/oauth2_provider/views/oidc.py b/oauth2_provider/views/oidc.py index 7f3c9d5d4..732965a5d 100644 --- a/oauth2_provider/views/oidc.py +++ b/oauth2_provider/views/oidc.py @@ -5,10 +5,8 @@ from django.http import JsonResponse from django.urls import reverse_lazy from django.views.generic import View - -from rest_framework.views import APIView - from jwcrypto import jwk +from rest_framework.views import APIView from ..settings import oauth2_settings @@ -27,8 +25,10 @@ def get(self, request, *args, **kwargs): "jwks_uri": "{}{}".format(issuer_url, reverse_lazy("oauth2_provider:jwks-info")), "response_types_supported": oauth2_settings.OIDC_RESPONSE_TYPES_SUPPORTED, "subject_types_supported": oauth2_settings.OIDC_SUBJECT_TYPES_SUPPORTED, - "id_token_signing_alg_values_supported": oauth2_settings.OIDC_ID_TOKEN_SIGNING_ALG_VALUES_SUPPORTED, - "token_endpoint_auth_methods_supported": oauth2_settings.OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED, + "id_token_signing_alg_values_supported": + oauth2_settings.OIDC_ID_TOKEN_SIGNING_ALG_VALUES_SUPPORTED, + "token_endpoint_auth_methods_supported": + oauth2_settings.OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED, } response = JsonResponse(data) response["Access-Control-Allow-Origin"] = "*" diff --git a/tests/settings.py b/tests/settings.py index 19f95e31f..edd1ae679 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -131,13 +131,29 @@ } } +OIDC_RSA_PRIVATE_KEY = """-----BEGIN RSA PRIVATE KEY----- +MIICXQIBAAKBgQCbCYh5h2NmQuBqVO6G+/CO+cHm9VBzsb0MeA6bbQfDnbhstVOT +j0hcnZJzDjYc6ajBZZf6gxVP9xrdm9Uh599VI3X5PFXLbMHrmzTAMzCGIyg+/fnP +0gocYxmCX2+XKyj/Zvt1pUX8VAN2AhrJSfxNDKUHERTVEV9bRBJg4F0C3wIDAQAB +AoGAP+i4nNw+Ec/8oWh8YSFm4xE6qKG0NdTtSMAOyWwy+KTB+vHuT1QPsLn1vj77 ++IQrX/moogg6F1oV9YdA3vat3U7rwt1sBGsRrLhA+Spp9WEQtglguNo4+QfVo2ju +YBa2rG+h75qjiA3xnU//F3rvwnAsOWv0NUVdVeguyR+u6okCQQDBUmgWeH2WHmUn +2nLNCz+9wj28rqhfOr9Ptem2gqk+ywJmuIr4Y5S1OdavOr2UZxOcEwncJ/MLVYQq +MH+x4V5HAkEAzU2GMR5OdVLcxfVTjzuIC76paoHVWnLibd1cdANpPmE6SM+pf5el +fVSwuH9Fmlizu8GiPCxbJUoXB/J1tGEKqQJBALhClEU+qOzpoZ6/voYi/6kdN3zc +uEy0EN6n09AKb8gS9QH1STgAqh+ltjMkeMe3C2DKYK5/QU9/Pc58lWl1FkcCQG67 +ZamQgxjcvJ85FvymS1aqW45KwNysIlzHjFo2jMlMf7dN6kobbPMQftDENLJvLWIT +qoFyGycdsxZiPAIyZSECQQCZFn3Dl6hnJxWZH8Fsa9hj79kZ/WVkIXGmtdgt0fNr +dTnvCVtA59ne4LEVie/PMH/odQWY0SxVm/76uBZv/1vY +-----END RSA PRIVATE KEY-----""" + OAUTH2_PROVIDER = { "OIDC_ISS_ENDPOINT": "http://localhost", "OIDC_USERINFO_ENDPOINT": "http://localhost/userinfo/", - "OIDC_RSA_PRIVATE_KEY": "-----BEGIN RSA PRIVATE KEY-----\nMIICXQIBAAKBgQCbCYh5h2NmQuBqVO6G+/CO+cHm9VBzsb0MeA6bbQfDnbhstVOT\nj0hcnZJzDjYc6ajBZZf6gxVP9xrdm9Uh599VI3X5PFXLbMHrmzTAMzCGIyg+/fnP\n0gocYxmCX2+XKyj/Zvt1pUX8VAN2AhrJSfxNDKUHERTVEV9bRBJg4F0C3wIDAQAB\nAoGAP+i4nNw+Ec/8oWh8YSFm4xE6qKG0NdTtSMAOyWwy+KTB+vHuT1QPsLn1vj77\n+IQrX/moogg6F1oV9YdA3vat3U7rwt1sBGsRrLhA+Spp9WEQtglguNo4+QfVo2ju\nYBa2rG+h75qjiA3xnU//F3rvwnAsOWv0NUVdVeguyR+u6okCQQDBUmgWeH2WHmUn\n2nLNCz+9wj28rqhfOr9Ptem2gqk+ywJmuIr4Y5S1OdavOr2UZxOcEwncJ/MLVYQq\nMH+x4V5HAkEAzU2GMR5OdVLcxfVTjzuIC76paoHVWnLibd1cdANpPmE6SM+pf5el\nfVSwuH9Fmlizu8GiPCxbJUoXB/J1tGEKqQJBALhClEU+qOzpoZ6/voYi/6kdN3zc\nuEy0EN6n09AKb8gS9QH1STgAqh+ltjMkeMe3C2DKYK5/QU9/Pc58lWl1FkcCQG67\nZamQgxjcvJ85FvymS1aqW45KwNysIlzHjFo2jMlMf7dN6kobbPMQftDENLJvLWIT\nqoFyGycdsxZiPAIyZSECQQCZFn3Dl6hnJxWZH8Fsa9hj79kZ/WVkIXGmtdgt0fNr\ndTnvCVtA59ne4LEVie/PMH/odQWY0SxVm/76uBZv/1vY\n-----END RSA PRIVATE KEY-----" + "OIDC_RSA_PRIVATE_KEY": OIDC_RSA_PRIVATE_KEY, } -OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = 'oauth2_provider.AccessToken' -OAUTH2_PROVIDER_APPLICATION_MODEL = 'oauth2_provider.Application' -OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = 'oauth2_provider.RefreshToken' -OAUTH2_PROVIDER_ID_TOKEN_MODEL = 'oauth2_provider.IDToken' +OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = "oauth2_provider.AccessToken" +OAUTH2_PROVIDER_APPLICATION_MODEL = "oauth2_provider.Application" +OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = "oauth2_provider.RefreshToken" +OAUTH2_PROVIDER_ID_TOKEN_MODEL = "oauth2_provider.IDToken" diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index bfe4170f9..49de9e8f6 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -1777,7 +1777,6 @@ def test_id_token_code_exchange_succeed_when_redirect_uri_match_with_multiple_qu content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS ) - def test_oob_as_html(self): """ Test out-of-band authentication. @@ -1871,7 +1870,6 @@ def test_oob_as_json(self): ) - class TestAuthorizationCodeProtectedResource(BaseTest): def test_resource_access_allowed(self): self.client.login(username="test_user", password="123456") diff --git a/tests/test_hybrid.py b/tests/test_hybrid.py index da3d0b5e6..1f45aeeec 100644 --- a/tests/test_hybrid.py +++ b/tests/test_hybrid.py @@ -1,7 +1,6 @@ import base64 import datetime import json - from urllib.parse import parse_qs, urlencode, urlparse from django.contrib.auth import get_user_model @@ -731,7 +730,9 @@ def test_code_post_auth_failing_redirection_uri_with_querystring(self): response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) - self.assertEqual("http://example.com?foo=bar&error=access_denied&state=random_state_string", response["Location"]) + self.assertEqual( + "http://example.com?foo=bar&error=access_denied&state=random_state_string", response["Location"] + ) def test_code_post_auth_fails_when_redirect_uri_path_is_invalid(self): """ diff --git a/tests/test_oauth2_backends.py b/tests/test_oauth2_backends.py index 2381e9cdc..0d98dad8b 100644 --- a/tests/test_oauth2_backends.py +++ b/tests/test_oauth2_backends.py @@ -65,7 +65,9 @@ def test_create_token_response_gets_extra_credentials(self): payload = "grant_type=password&username=john&password=123456" request = self.factory.post("/o/token/", payload, content_type="application/x-www-form-urlencoded") - with mock.patch("oauthlib.openid.connect.core.endpoints.pre_configured.Server.create_token_response") as create_token_response: + with mock.patch( + "oauthlib.openid.connect.core.endpoints.pre_configured.Server.create_token_response" + ) as create_token_response: mocked = mock.MagicMock() create_token_response.return_value = mocked, mocked, mocked core = self.MyOAuthLibCore() diff --git a/tests/test_oidc_views.py b/tests/test_oidc_views.py index 2105b4fd0..43e46d297 100644 --- a/tests/test_oidc_views.py +++ b/tests/test_oidc_views.py @@ -39,7 +39,7 @@ def test_get_jwks_info(self): "kid": "s4a1o8mFEd1tATAIH96caMlu4hOxzBUaI2QTqbYNBHs", "e": "AQAB", "kty": "RSA", - "n": "mwmIeYdjZkLgalTuhvvwjvnB5vVQc7G9DHgOm20Hw524bLVTk49IXJ2Scw42HOmowWWX-oMVT_ca3ZvVIeffVSN1-TxVy2zB65s0wDMwhiMoPv35z9IKHGMZgl9vlyso_2b7daVF_FQDdgIayUn8TQylBxEU1RFfW0QSYOBdAt8" + "n": "mwmIeYdjZkLgalTuhvvwjvnB5vVQc7G9DHgOm20Hw524bLVTk49IXJ2Scw42HOmowWWX-oMVT_ca3ZvVIeffVSN1-TxVy2zB65s0wDMwhiMoPv35z9IKHGMZgl9vlyso_2b7daVF_FQDdgIayUn8TQylBxEU1RFfW0QSYOBdAt8" # noqa }] } response = self.client.get(reverse("oauth2_provider:jwks-info")) From 00d477e8d4b8c93f2dff89190c7ac5f7db63f99b Mon Sep 17 00:00:00 2001 From: fvlima Date: Sun, 26 Apr 2020 19:21:52 -0300 Subject: [PATCH 42/57] Fix test doc deps --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index 78a1d8a9b..3082ab7a3 100644 --- a/tox.ini +++ b/tox.ini @@ -45,6 +45,7 @@ commands = make html deps = sphinx<3 oauthlib>=3.1.0 m2r>=0.2.1 + jwcrypto [testenv:py37-flake8] skip_install = True From fd6f2e5128aa2cc34358e35bafcac76cea825913 Mon Sep 17 00:00:00 2001 From: fvlima Date: Sun, 26 Apr 2020 21:34:50 -0300 Subject: [PATCH 43/57] Add project settings to be ignored in coverage --- tox.ini | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 3082ab7a3..a9fc0ea0c 100644 --- a/tox.ini +++ b/tox.ini @@ -71,7 +71,9 @@ commands = [coverage:run] source = oauth2_provider -omit = */migrations/* +omit = + */migrations/* + oauth2_provider/settings.py [flake8] max-line-length = 110 From 0be7230be05403fccc519e5f7a511309f2a8e5bc Mon Sep 17 00:00:00 2001 From: Shaun Stanworth Date: Thu, 13 Aug 2020 14:38:53 +0100 Subject: [PATCH 44/57] Tweak migrations to support non-overidden models --- oauth2_provider/migrations/0004_idtoken.py | 4 +++- oauth2_provider/migrations/0005_accesstoken_id_token.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/oauth2_provider/migrations/0004_idtoken.py b/oauth2_provider/migrations/0004_idtoken.py index 853a7089f..7910870ef 100644 --- a/oauth2_provider/migrations/0004_idtoken.py +++ b/oauth2_provider/migrations/0004_idtoken.py @@ -2,6 +2,8 @@ from django.db import migrations, models import django.db.models.deletion +from oauth2_provider.settings import oauth2_settings + class Migration(migrations.Migration): @@ -20,7 +22,7 @@ class Migration(migrations.Migration): ('scope', models.TextField(blank=True)), ('created', models.DateTimeField(auto_now_add=True)), ('updated', models.DateTimeField(auto_now=True)), - ('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), + ('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=oauth2_settings.APPLICATION_MODEL)), ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='oauth2_provider_idtoken', to=settings.AUTH_USER_MODEL)), ], options={ diff --git a/oauth2_provider/migrations/0005_accesstoken_id_token.py b/oauth2_provider/migrations/0005_accesstoken_id_token.py index 0a14a058c..6407aae30 100644 --- a/oauth2_provider/migrations/0005_accesstoken_id_token.py +++ b/oauth2_provider/migrations/0005_accesstoken_id_token.py @@ -2,6 +2,8 @@ from django.db import migrations, models import django.db.models.deletion +from oauth2_provider.settings import oauth2_settings + class Migration(migrations.Migration): @@ -13,6 +15,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name='accesstoken', name='id_token', - field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_token', to=settings.OAUTH2_PROVIDER_ID_TOKEN_MODEL), + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_token', to=oauth2_settings.ID_TOKEN_MODEL), ), ] From c7ceb0fc404d9bd60ac3f367a46145524b429f0d Mon Sep 17 00:00:00 2001 From: Shaun Stanworth Date: Thu, 13 Aug 2020 14:39:58 +0100 Subject: [PATCH 45/57] OIDC_USERINFO_ENDPOINT is not mandatory --- oauth2_provider/settings.py | 1 - oauth2_provider/views/oidc.py | 8 +++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index d770cbd56..9f33e8781 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -106,7 +106,6 @@ "SCOPES", "ALLOWED_REDIRECT_URI_SCHEMES", "OIDC_ISS_ENDPOINT", - "OIDC_USERINFO_ENDPOINT", "OIDC_RSA_PRIVATE_KEY", "OIDC_RESPONSE_TYPES_SUPPORTED", "OIDC_SUBJECT_TYPES_SUPPORTED", diff --git a/oauth2_provider/views/oidc.py b/oauth2_provider/views/oidc.py index 732965a5d..717325e57 100644 --- a/oauth2_provider/views/oidc.py +++ b/oauth2_provider/views/oidc.py @@ -2,8 +2,10 @@ import json -from django.http import JsonResponse -from django.urls import reverse_lazy +from django.http import JsonResponse, HttpResponse +from django.urls import reverse_lazy, reverse +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt from django.views.generic import View from jwcrypto import jwk from rest_framework.views import APIView @@ -21,7 +23,7 @@ def get(self, request, *args, **kwargs): "issuer": issuer_url, "authorization_endpoint": "{}{}".format(issuer_url, reverse_lazy("oauth2_provider:authorize")), "token_endpoint": "{}{}".format(issuer_url, reverse_lazy("oauth2_provider:token")), - "userinfo_endpoint": oauth2_settings.OIDC_USERINFO_ENDPOINT, + "userinfo_endpoint": oauth2_settings.OIDC_USERINFO_ENDPOINT or request.build_absolute_uri(reverse("oauth2_provider:user-info")), "jwks_uri": "{}{}".format(issuer_url, reverse_lazy("oauth2_provider:jwks-info")), "response_types_supported": oauth2_settings.OIDC_RESPONSE_TYPES_SUPPORTED, "subject_types_supported": oauth2_settings.OIDC_SUBJECT_TYPES_SUPPORTED, From 0ff5118dd32f88805edf03c66a79dc5710f8f1cf Mon Sep 17 00:00:00 2001 From: Shaun Stanworth Date: Thu, 13 Aug 2020 14:40:55 +0100 Subject: [PATCH 46/57] refresh_token grant should be support for OpenID hybrid --- oauth2_provider/oauth2_validators.py | 1 + 1 file changed, 1 insertion(+) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index f1c605590..5f8009af5 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -49,6 +49,7 @@ AbstractApplication.GRANT_AUTHORIZATION_CODE, AbstractApplication.GRANT_PASSWORD, AbstractApplication.GRANT_CLIENT_CREDENTIALS, + AbstractApplication.GRANT_OPENID_HYBRID, ), } From db9c295a4c208eed260a89b95c8fc69b08153623 Mon Sep 17 00:00:00 2001 From: Shaun Stanworth Date: Thu, 13 Aug 2020 14:45:01 +0100 Subject: [PATCH 47/57] Fix the user info view, and remove hard dependency on DRF --- oauth2_provider/oauth2_backends.py | 15 +++++++++++++++ oauth2_provider/oauth2_validators.py | 9 +++++++++ oauth2_provider/views/mixins.py | 10 ++++++++++ oauth2_provider/views/oidc.py | 16 ++++++++++++---- 4 files changed, 46 insertions(+), 4 deletions(-) diff --git a/oauth2_provider/oauth2_backends.py b/oauth2_provider/oauth2_backends.py index f1b1f5b29..0263f63ac 100644 --- a/oauth2_provider/oauth2_backends.py +++ b/oauth2_provider/oauth2_backends.py @@ -167,6 +167,21 @@ def create_revocation_response(self, request): return uri, headers, body, status + def create_userinfo_response(self, request): + """ + A wrapper method that calls create_userinfo_response on a + `server_class` instance. + + :param request: The current django.http.HttpRequest object + """ + uri, http_method, body, headers = self._extract_params(request) + headers, body, status = self.server.create_userinfo_response( + uri, http_method, body, headers + ) + uri = headers.get("Location", None) + + return uri, headers, body, status + def verify_request(self, request, scopes): """ A wrapper method that calls verify_request on `server_class` instance. diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 5f8009af5..2e9ff0014 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -891,3 +891,12 @@ def get_authorization_code_nonce(self, client_id, code, redirect_uri, request): """ # TODO: Fix this ;) return "" + + def get_userinfo_claims(self, request): + """ + Generates and saves a new JWT for this request, and returns it as the + current user's claims. As the JWT is encrypted, this returns an opaque + string rather than a dictionary. + + """ + return self.get_id_token(None, None, request) diff --git a/oauth2_provider/views/mixins.py b/oauth2_provider/views/mixins.py index 1321e221d..103c436ba 100644 --- a/oauth2_provider/views/mixins.py +++ b/oauth2_provider/views/mixins.py @@ -134,6 +134,16 @@ def create_revocation_response(self, request): core = self.get_oauthlib_core() return core.create_revocation_response(request) + def create_userinfo_response(self, request): + """ + A wrapper method that calls create_userinfo_response on the + `server_class` instance. + + :param request: The current django.http.HttpRequest object + """ + core = self.get_oauthlib_core() + return core.create_userinfo_response(request) + def verify_request(self, request): """ A wrapper method that calls verify_request on `server_class` instance. diff --git a/oauth2_provider/views/oidc.py b/oauth2_provider/views/oidc.py index 717325e57..d428145ce 100644 --- a/oauth2_provider/views/oidc.py +++ b/oauth2_provider/views/oidc.py @@ -8,8 +8,8 @@ from django.views.decorators.csrf import csrf_exempt from django.views.generic import View from jwcrypto import jwk -from rest_framework.views import APIView +from .mixins import OAuthLibMixin from ..settings import oauth2_settings @@ -56,11 +56,19 @@ def get(self, request, *args, **kwargs): return response -class UserInfoView(APIView): +@method_decorator(csrf_exempt, name="dispatch") +class UserInfoView(OAuthLibMixin, View): """ View used to show Claims about the authenticated End-User """ + server_class = oauth2_settings.OAUTH2_SERVER_CLASS + validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS + oauthlib_backend_class = oauth2_settings.OAUTH2_BACKEND_CLASS + def get(self, request, *args, **kwargs): - response = JsonResponse(request.auth.id_token.claims) - response["Access-Control-Allow-Origin"] = "*" + url, headers, body, status = self.create_userinfo_response(request) + response = HttpResponse(content=body or "", status=status) + + for k, v in headers.items(): + response[k] = v return response From dacb9c1250d4b6703c564c63016b15674a8bb019 Mon Sep 17 00:00:00 2001 From: Shaun Stanworth Date: Fri, 14 Aug 2020 16:15:51 +0100 Subject: [PATCH 48/57] Use proper URL generation for OIDC endpoints --- oauth2_provider/views/oidc.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/oauth2_provider/views/oidc.py b/oauth2_provider/views/oidc.py index d428145ce..bbfb42517 100644 --- a/oauth2_provider/views/oidc.py +++ b/oauth2_provider/views/oidc.py @@ -21,10 +21,10 @@ def get(self, request, *args, **kwargs): issuer_url = oauth2_settings.OIDC_ISS_ENDPOINT data = { "issuer": issuer_url, - "authorization_endpoint": "{}{}".format(issuer_url, reverse_lazy("oauth2_provider:authorize")), - "token_endpoint": "{}{}".format(issuer_url, reverse_lazy("oauth2_provider:token")), + "authorization_endpoint": request.build_absolute_uri(reverse("oauth2_provider:authorize")), + "token_endpoint": request.build_absolute_uri(reverse("oauth2_provider:token")), "userinfo_endpoint": oauth2_settings.OIDC_USERINFO_ENDPOINT or request.build_absolute_uri(reverse("oauth2_provider:user-info")), - "jwks_uri": "{}{}".format(issuer_url, reverse_lazy("oauth2_provider:jwks-info")), + "jwks_uri": request.build_absolute_uri(reverse("oauth2_provider:jwks-info")), "response_types_supported": oauth2_settings.OIDC_RESPONSE_TYPES_SUPPORTED, "subject_types_supported": oauth2_settings.OIDC_SUBJECT_TYPES_SUPPORTED, "id_token_signing_alg_values_supported": From e80e4a3f1f3d2524bf51813bdbcd812aebdf11bc Mon Sep 17 00:00:00 2001 From: Shaun Stanworth Date: Fri, 14 Aug 2020 16:17:07 +0100 Subject: [PATCH 49/57] Support rich ID tokens and userinfo claims Extend the validator and override get_additional_claims based on your own user model. --- oauth2_provider/oauth2_validators.py | 34 +++++++++++++++++++++------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 2e9ff0014..f654659e6 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -774,29 +774,37 @@ def _save_id_token(self, token, request, expires, *args, **kwargs): def get_jwt_bearer_token(self, token, token_handler, request): return self.get_id_token(token, token_handler, request) - def get_id_token(self, token, token_handler, request): + def get_oidc_claims(self, token, token_handler, request): + # Required OIDC claims + claims = { + "sub": str(request.user.id), + } - key = jwk.JWK.from_pem(oauth2_settings.OIDC_RSA_PRIVATE_KEY.encode("utf8")) + # https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims + claims.update(**self.get_additional_claims(request)) + + return claims + def get_id_token_dictionary(self, token, token_handler, request): # TODO: http://openid.net/specs/openid-connect-core-1_0.html#HybridIDToken2 # Save the id_token on database bound to code when the request come to # Authorization Endpoint and return the same one when request come to # Token Endpoint # TODO: Check if at this point this request parameters are alredy validated + claims = self.get_oidc_claims(token, token_handler, request) expiration_time = timezone.now() + timedelta( seconds=oauth2_settings.ID_TOKEN_EXPIRE_SECONDS ) # Required ID Token claims - claims = { + claims.update(**{ "iss": oauth2_settings.OIDC_ISS_ENDPOINT, - "sub": str(request.user.id), "aud": request.client_id, "exp": int(dateformat.format(expiration_time, "U")), "iat": int(dateformat.format(datetime.utcnow(), "U")), "auth_time": int(dateformat.format(request.user.last_login, "U")), - } + }) nonce = getattr(request, "nonce", None) if nonce: @@ -826,6 +834,13 @@ def get_id_token(self, token, token_handler, request): c_hash = base64.urlsafe_b64encode(bits256.encode("ascii")) claims["c_hash"] = c_hash.decode("utf8") + return claims, expiration_time + + def get_id_token(self, token, token_handler, request): + key = jwk.JWK.from_pem(oauth2_settings.OIDC_RSA_PRIVATE_KEY.encode("utf8")) + + claims, expiration_time = self.get_id_token_dictionary(token, token_handler, request) + jwt_token = jwt.JWT( header=json.dumps({"alg": "RS256"}, default=str), claims=json.dumps(claims, default=str), @@ -838,6 +853,7 @@ def get_id_token(self, token, token_handler, request): request.id_token = id_token return jwt_token.serialize() + def validate_jwt_bearer_token(self, token, scopes, request): return self.validate_id_token(token, scopes, request) @@ -895,8 +911,10 @@ def get_authorization_code_nonce(self, client_id, code, redirect_uri, request): def get_userinfo_claims(self, request): """ Generates and saves a new JWT for this request, and returns it as the - current user's claims. As the JWT is encrypted, this returns an opaque - string rather than a dictionary. + current user's claims. """ - return self.get_id_token(None, None, request) + return self.get_oidc_claims(None, None, request) + + def get_additional_claims(self, request): + return {} From cd530956af8307ecef9002553e7addff46698ae7 Mon Sep 17 00:00:00 2001 From: Shaun Stanworth Date: Fri, 14 Aug 2020 16:17:26 +0100 Subject: [PATCH 50/57] Bug fix for at_hash generation See https://openid.net/specs/openid-connect-core-1_0.html#id_token-tokenExample to prove algorithm --- oauth2_provider/oauth2_validators.py | 12 ++++++++---- tests/test_oauth2_validators.py | 7 +++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index f654659e6..a28dd0d47 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -820,10 +820,8 @@ def get_id_token_dictionary(self, token, token_handler, request): or (request.response_type == "id_token token" and "access_token" in token) ): acess_token = token["access_token"] - sha256 = hashlib.sha256(acess_token.encode("ascii")) - bits128 = sha256.hexdigest()[:16] - at_hash = base64.urlsafe_b64encode(bits128.encode("ascii")) - claims["at_hash"] = at_hash.decode("utf8") + at_hash = self.generate_at_hash(acess_token) + claims["at_hash"] = at_hash # TODO: create a function to check if we should include c_hash # http://openid.net/specs/openid-connect-core-1_0.html#HybridIDToken @@ -836,6 +834,12 @@ def get_id_token_dictionary(self, token, token_handler, request): return claims, expiration_time + def generate_at_hash(self, access_token): + sha256 = hashlib.sha256(access_token.encode("ascii")) + bits128 = sha256.digest()[:16] + at_hash = base64.urlsafe_b64encode(bits128).decode("utf8").rstrip('=') + return at_hash + def get_id_token(self, token, token_handler, request): key = jwk.JWK.from_pem(oauth2_settings.OIDC_RSA_PRIVATE_KEY.encode("utf8")) diff --git a/tests/test_oauth2_validators.py b/tests/test_oauth2_validators.py index 7821148d5..1a0926988 100644 --- a/tests/test_oauth2_validators.py +++ b/tests/test_oauth2_validators.py @@ -287,6 +287,13 @@ def test_save_bearer_token__with_new_token__calls_methods_to_create_access_and_r assert create_access_token_mock.call_count == 1 assert create_refresh_token_mock.call_count == 1 + def test_generate_at_hash(self): + # Values taken from spec, https://openid.net/specs/openid-connect-core-1_0.html#id_token-tokenExample + access_token = "jHkWEdUXMU1BwAsC4vtUsZwnNvTIxEl0z9K3vx5KF0Y" + at_hash = self.validator.generate_at_hash(access_token) + + assert at_hash == "77QmUPtjPfzWtF2AnpK9RQ" + class TestOAuth2ValidatorProvidesErrorData(TransactionTestCase): """These test cases check that the recommended error codes are returned From d4b308be773ed06855217b31ad900d92629a42f8 Mon Sep 17 00:00:00 2001 From: Shaun Stanworth Date: Fri, 14 Aug 2020 16:55:38 +0100 Subject: [PATCH 51/57] OIDC_ISS_ENDPOINT is an optional setting --- oauth2_provider/oauth2_validators.py | 16 +++++++++++++++- oauth2_provider/settings.py | 1 - oauth2_provider/views/oidc.py | 5 +++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index a28dd0d47..31cae33b9 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -14,6 +14,8 @@ from django.core.exceptions import ObjectDoesNotExist from django.db import transaction from django.db.models import Q +from django.http import HttpRequest +from django.urls import reverse from django.utils import dateformat, timezone from django.utils.timezone import make_aware from django.utils.translation import gettext_lazy as _ @@ -799,7 +801,7 @@ def get_id_token_dictionary(self, token, token_handler, request): ) # Required ID Token claims claims.update(**{ - "iss": oauth2_settings.OIDC_ISS_ENDPOINT, + "iss": self.get_oidc_issuer_endpoint(request), "aud": request.client_id, "exp": int(dateformat.format(expiration_time, "U")), "iat": int(dateformat.format(datetime.utcnow(), "U")), @@ -834,6 +836,18 @@ def get_id_token_dictionary(self, token, token_handler, request): return claims, expiration_time + def get_oidc_issuer_endpoint(self, request): + if oauth2_settings.OIDC_ISS_ENDPOINT: + return oauth2_settings.OIDC_ISS_ENDPOINT + + # generate it based on known URL + django_request = HttpRequest() + django_request.META = request.headers + + abs_url = django_request.build_absolute_uri(reverse('oauth2_provider:oidc-connect-discovery-info')) + base_url = abs_url[:-len("/.well-known/openid-configuration/")] + return base_url + def generate_at_hash(self, access_token): sha256 = hashlib.sha256(access_token.encode("ascii")) bits128 = sha256.digest()[:16] diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index 9f33e8781..2038ce999 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -105,7 +105,6 @@ "OAUTH2_BACKEND_CLASS", "SCOPES", "ALLOWED_REDIRECT_URI_SCHEMES", - "OIDC_ISS_ENDPOINT", "OIDC_RSA_PRIVATE_KEY", "OIDC_RESPONSE_TYPES_SUPPORTED", "OIDC_SUBJECT_TYPES_SUPPORTED", diff --git a/oauth2_provider/views/oidc.py b/oauth2_provider/views/oidc.py index bbfb42517..57df782ae 100644 --- a/oauth2_provider/views/oidc.py +++ b/oauth2_provider/views/oidc.py @@ -19,6 +19,11 @@ class ConnectDiscoveryInfoView(View): """ def get(self, request, *args, **kwargs): issuer_url = oauth2_settings.OIDC_ISS_ENDPOINT + + if not issuer_url: + abs_url = request.build_absolute_uri(reverse('oauth2_provider:oidc-connect-discovery-info')) + issuer_url = abs_url[:-len("/.well-known/openid-configuration/")] + data = { "issuer": issuer_url, "authorization_endpoint": request.build_absolute_uri(reverse("oauth2_provider:authorize")), From 1f44d42f22851742e9f8d0533efdda672a18978c Mon Sep 17 00:00:00 2001 From: Shaun Stanworth Date: Mon, 17 Aug 2020 11:59:37 +0100 Subject: [PATCH 52/57] Support OIDC urls from issuer url if provided --- oauth2_provider/views/oidc.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/oauth2_provider/views/oidc.py b/oauth2_provider/views/oidc.py index 57df782ae..a37a9a87c 100644 --- a/oauth2_provider/views/oidc.py +++ b/oauth2_provider/views/oidc.py @@ -24,12 +24,22 @@ def get(self, request, *args, **kwargs): abs_url = request.build_absolute_uri(reverse('oauth2_provider:oidc-connect-discovery-info')) issuer_url = abs_url[:-len("/.well-known/openid-configuration/")] + authorization_endpoint = request.build_absolute_uri(reverse("oauth2_provider:authorize")) + token_endpoint = request.build_absolute_uri(reverse("oauth2_provider:token")) + userinfo_endpoint = oauth2_settings.OIDC_USERINFO_ENDPOINT or request.build_absolute_uri(reverse("oauth2_provider:user-info")) + jwks_uri = request.build_absolute_uri(reverse("oauth2_provider:jwks-info")) + else: + authorization_endpoint = "{}{}".format(issuer_url, reverse_lazy("oauth2_provider:authorize")) + token_endpoint = "{}{}".format(issuer_url, reverse_lazy("oauth2_provider:token")) + userinfo_endpoint = oauth2_settings.OIDC_USERINFO_ENDPOINT or "{}{}".format(issuer_url, reverse_lazy("oauth2_provider:user-info")) + jwks_uri = "{}{}".format(issuer_url, reverse_lazy("oauth2_provider:jwks-info")) + data = { "issuer": issuer_url, - "authorization_endpoint": request.build_absolute_uri(reverse("oauth2_provider:authorize")), - "token_endpoint": request.build_absolute_uri(reverse("oauth2_provider:token")), - "userinfo_endpoint": oauth2_settings.OIDC_USERINFO_ENDPOINT or request.build_absolute_uri(reverse("oauth2_provider:user-info")), - "jwks_uri": request.build_absolute_uri(reverse("oauth2_provider:jwks-info")), + "authorization_endpoint": authorization_endpoint, + "token_endpoint": token_endpoint, + "userinfo_endpoint": userinfo_endpoint, + "jwks_uri": jwks_uri, "response_types_supported": oauth2_settings.OIDC_RESPONSE_TYPES_SUPPORTED, "subject_types_supported": oauth2_settings.OIDC_SUBJECT_TYPES_SUPPORTED, "id_token_signing_alg_values_supported": From d8e3e93d810763dc6f8501ea43baf8954e6ec974 Mon Sep 17 00:00:00 2001 From: Shaun Stanworth Date: Mon, 17 Aug 2020 12:12:29 +0100 Subject: [PATCH 53/57] Test for generated OIDC urls --- tests/test_oidc_views.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/test_oidc_views.py b/tests/test_oidc_views.py index 43e46d297..71f41d7eb 100644 --- a/tests/test_oidc_views.py +++ b/tests/test_oidc_views.py @@ -3,6 +3,8 @@ from django.test import TestCase from django.urls import reverse +from oauth2_provider.settings import oauth2_settings + class TestConnectDiscoveryInfoView(TestCase): def test_get_connect_discovery_info(self): @@ -29,6 +31,34 @@ def test_get_connect_discovery_info(self): self.assertEqual(response.status_code, 200) assert response.json() == expected_response + def test_get_connect_discovery_info_without_issuer_url(self): + oauth2_settings.OIDC_ISS_ENDPOINT = None + oauth2_settings.OIDC_USERINFO_ENDPOINT = None + expected_response = { + "issuer": "http://testserver/o", + "authorization_endpoint": "http://testserver/o/authorize/", + "token_endpoint": "http://testserver/o/token/", + "userinfo_endpoint": "http://testserver/o/userinfo/", + "jwks_uri": "http://testserver/o/jwks/", + "response_types_supported": [ + "code", + "token", + "id_token", + "id_token token", + "code token", + "code id_token", + "code id_token token" + ], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["RS256", "HS256"], + "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"] + } + response = self.client.get(reverse("oauth2_provider:oidc-connect-discovery-info")) + self.assertEqual(response.status_code, 200) + assert response.json() == expected_response + oauth2_settings.OIDC_ISS_ENDPOINT = "http://localhost" + oauth2_settings.OIDC_USERINFO_ENDPOINT = "http://localhost/userinfo/" + class TestJwksInfoView(TestCase): def test_get_jwks_info(self): From 87191b82532f66838309d692004615fe95146a5c Mon Sep 17 00:00:00 2001 From: Shaun Stanworth Date: Mon, 17 Aug 2020 13:09:55 +0100 Subject: [PATCH 54/57] Flake --- oauth2_provider/oauth2_validators.py | 5 ++--- oauth2_provider/views/oidc.py | 18 ++++++++++++------ 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 31cae33b9..f109eea38 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -844,14 +844,14 @@ def get_oidc_issuer_endpoint(self, request): django_request = HttpRequest() django_request.META = request.headers - abs_url = django_request.build_absolute_uri(reverse('oauth2_provider:oidc-connect-discovery-info')) + abs_url = django_request.build_absolute_uri(reverse("oauth2_provider:oidc-connect-discovery-info")) base_url = abs_url[:-len("/.well-known/openid-configuration/")] return base_url def generate_at_hash(self, access_token): sha256 = hashlib.sha256(access_token.encode("ascii")) bits128 = sha256.digest()[:16] - at_hash = base64.urlsafe_b64encode(bits128).decode("utf8").rstrip('=') + at_hash = base64.urlsafe_b64encode(bits128).decode("utf8").rstrip("=") return at_hash def get_id_token(self, token, token_handler, request): @@ -871,7 +871,6 @@ def get_id_token(self, token, token_handler, request): request.id_token = id_token return jwt_token.serialize() - def validate_jwt_bearer_token(self, token, scopes, request): return self.validate_id_token(token, scopes, request) diff --git a/oauth2_provider/views/oidc.py b/oauth2_provider/views/oidc.py index a37a9a87c..d7ffe4670 100644 --- a/oauth2_provider/views/oidc.py +++ b/oauth2_provider/views/oidc.py @@ -2,15 +2,15 @@ import json -from django.http import JsonResponse, HttpResponse -from django.urls import reverse_lazy, reverse +from django.http import HttpResponse, JsonResponse +from django.urls import reverse, reverse_lazy from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt from django.views.generic import View from jwcrypto import jwk -from .mixins import OAuthLibMixin from ..settings import oauth2_settings +from .mixins import OAuthLibMixin class ConnectDiscoveryInfoView(View): @@ -21,17 +21,23 @@ def get(self, request, *args, **kwargs): issuer_url = oauth2_settings.OIDC_ISS_ENDPOINT if not issuer_url: - abs_url = request.build_absolute_uri(reverse('oauth2_provider:oidc-connect-discovery-info')) + abs_url = request.build_absolute_uri(reverse("oauth2_provider:oidc-connect-discovery-info")) issuer_url = abs_url[:-len("/.well-known/openid-configuration/")] authorization_endpoint = request.build_absolute_uri(reverse("oauth2_provider:authorize")) token_endpoint = request.build_absolute_uri(reverse("oauth2_provider:token")) - userinfo_endpoint = oauth2_settings.OIDC_USERINFO_ENDPOINT or request.build_absolute_uri(reverse("oauth2_provider:user-info")) + userinfo_endpoint = ( + oauth2_settings.OIDC_USERINFO_ENDPOINT or + request.build_absolute_uri(reverse("oauth2_provider:user-info")) + ) jwks_uri = request.build_absolute_uri(reverse("oauth2_provider:jwks-info")) else: authorization_endpoint = "{}{}".format(issuer_url, reverse_lazy("oauth2_provider:authorize")) token_endpoint = "{}{}".format(issuer_url, reverse_lazy("oauth2_provider:token")) - userinfo_endpoint = oauth2_settings.OIDC_USERINFO_ENDPOINT or "{}{}".format(issuer_url, reverse_lazy("oauth2_provider:user-info")) + userinfo_endpoint = ( + oauth2_settings.OIDC_USERINFO_ENDPOINT or + "{}{}".format(issuer_url, reverse_lazy("oauth2_provider:user-info")) + ) jwks_uri = "{}{}".format(issuer_url, reverse_lazy("oauth2_provider:jwks-info")) data = { From 6b0bc35816ae72a40383e4869dc115998b48c61c Mon Sep 17 00:00:00 2001 From: Dave Burkholder Date: Fri, 28 Aug 2020 13:38:46 -0400 Subject: [PATCH 55/57] Rebase on master and migrate url function to re_path --- oauth2_provider/urls.py | 6 +++--- oauth2_provider/views/introspect.py | 2 +- tests/test_implicit.py | 24 +++++++++--------------- tests/urls.py | 8 +++----- 4 files changed, 16 insertions(+), 24 deletions(-) diff --git a/oauth2_provider/urls.py b/oauth2_provider/urls.py index d29c54168..07cb4a154 100644 --- a/oauth2_provider/urls.py +++ b/oauth2_provider/urls.py @@ -28,10 +28,10 @@ ] oidc_urlpatterns = [ - url(r"^\.well-known/openid-configuration/$", views.ConnectDiscoveryInfoView.as_view(), + re_path(r"^\.well-known/openid-configuration/$", views.ConnectDiscoveryInfoView.as_view(), name="oidc-connect-discovery-info"), - url(r"^jwks/$", views.JwksInfoView.as_view(), name="jwks-info"), - url(r"^userinfo/$", views.UserInfoView.as_view(), name="user-info") + re_path(r"^jwks/$", views.JwksInfoView.as_view(), name="jwks-info"), + re_path(r"^userinfo/$", views.UserInfoView.as_view(), name="user-info") ] diff --git a/oauth2_provider/views/introspect.py b/oauth2_provider/views/introspect.py index 7d4381179..460a1395d 100644 --- a/oauth2_provider/views/introspect.py +++ b/oauth2_provider/views/introspect.py @@ -7,7 +7,7 @@ from django.views.decorators.csrf import csrf_exempt from oauth2_provider.models import get_access_token_model -from oauth2_provider.views import ClientProtectedScopedResourceView +from oauth2_provider.views.generic import ClientProtectedScopedResourceView @method_decorator(csrf_exempt, name="dispatch") diff --git a/tests/test_implicit.py b/tests/test_implicit.py index a0069e2f9..b005366b3 100644 --- a/tests/test_implicit.py +++ b/tests/test_implicit.py @@ -317,18 +317,16 @@ def test_id_token_skip_authorization_completely(self): self.application.skip_authorization = True self.application.save() - query_string = urlencode({ + query_data = { "client_id": self.application.client_id, "response_type": "id_token", "state": "random_state_string", "nonce": "random_nonce_string", "scope": "openid", "redirect_uri": "http://example.org", - }) - - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + } - response = self.client.get(url) + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 302) self.assertIn("http://example.org#", response["Location"]) self.assertNotIn("access_token=", response["Location"]) @@ -351,17 +349,15 @@ def test_id_token_skip_authorization_completely_missing_nonce(self): self.application.skip_authorization = True self.application.save() - query_string = urlencode({ + query_data = { "client_id": self.application.client_id, "response_type": "id_token", "state": "random_state_string", "scope": "openid", "redirect_uri": "http://example.org", - }) - - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + } - response = self.client.get(url) + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 302) self.assertIn("error=invalid_request", response["Location"]) self.assertIn("error_description=Request+is+missing+mandatory+nonce+paramete", response["Location"]) @@ -425,18 +421,16 @@ def test_access_token_and_id_token_skip_authorization_completely(self): self.application.skip_authorization = True self.application.save() - query_string = urlencode({ + query_data = { "client_id": self.application.client_id, "response_type": "id_token token", "state": "random_state_string", "nonce": "random_nonce_string", "scope": "openid", "redirect_uri": "http://example.org", - }) - - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + } - response = self.client.get(url) + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 302) self.assertIn("http://example.org#", response["Location"]) self.assertIn("access_token=", response["Location"]) diff --git a/tests/urls.py b/tests/urls.py index 16dcf6ded..d63672ed4 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -1,4 +1,4 @@ -from django.conf.urls import include, url +from django.urls import include, re_path from django.contrib import admin @@ -6,8 +6,6 @@ urlpatterns = [ - url(r"^o/", include("oauth2_provider.urls", namespace="oauth2_provider")), + re_path(r"^o/", include("oauth2_provider.urls", namespace="oauth2_provider")), + re_path(r"^admin/", admin.site.urls), ] - - -urlpatterns += [url(r"^admin/", admin.site.urls)] From c93d6776749f104fb1914edb0f233c8471df5661 Mon Sep 17 00:00:00 2001 From: Dave Burkholder Date: Fri, 28 Aug 2020 15:21:34 -0400 Subject: [PATCH 56/57] Handle invalid token format exceptions as invalid tokens --- oauth2_provider/views/mixins.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/oauth2_provider/views/mixins.py b/oauth2_provider/views/mixins.py index 103c436ba..986419ba4 100644 --- a/oauth2_provider/views/mixins.py +++ b/oauth2_provider/views/mixins.py @@ -288,11 +288,13 @@ def dispatch(self, request, *args, **kwargs): if not valid: # Alternatively allow access tokens # check if the request is valid and the protected resource may be accessed - valid, r = self.verify_request(request) - if valid: - request.resource_owner = r.user - return super().dispatch(request, *args, **kwargs) - else: - return HttpResponseForbidden() + try: + valid, r = self.verify_request(request) + if valid: + request.resource_owner = r.user + return super().dispatch(request, *args, **kwargs) + except ValueError: + pass + return HttpResponseForbidden() else: return super().dispatch(request, *args, **kwargs) From 1e8d66bb375344a38575b7231ca0655fea7bd616 Mon Sep 17 00:00:00 2001 From: Dave Burkholder Date: Fri, 28 Aug 2020 16:50:31 -0400 Subject: [PATCH 57/57] Merge migrations and sort imports isort for flake8 lint check --- .../migrations/0003_auto_20190413_2007.py | 21 ------------------- ..._idtoken.py => 0003_auto_20200902_2022.py} | 17 ++++++++++++++- .../migrations/0005_accesstoken_id_token.py | 20 ------------------ oauth2_provider/oauth2_validators.py | 8 ++----- oauth2_provider/urls.py | 2 +- tests/test_authorization_code.py | 6 ++---- tests/test_implicit.py | 4 +--- tests/urls.py | 2 +- tox.ini | 1 - 9 files changed, 23 insertions(+), 58 deletions(-) delete mode 100644 oauth2_provider/migrations/0003_auto_20190413_2007.py rename oauth2_provider/migrations/{0004_idtoken.py => 0003_auto_20200902_2022.py} (56%) delete mode 100644 oauth2_provider/migrations/0005_accesstoken_id_token.py diff --git a/oauth2_provider/migrations/0003_auto_20190413_2007.py b/oauth2_provider/migrations/0003_auto_20190413_2007.py deleted file mode 100644 index b27bd4ebb..000000000 --- a/oauth2_provider/migrations/0003_auto_20190413_2007.py +++ /dev/null @@ -1,21 +0,0 @@ -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('oauth2_provider', '0002_auto_20190406_1805'), - ] - - operations = [ - migrations.AddField( - model_name='application', - name='algorithm', - field=models.CharField(choices=[('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')], default='RS256', max_length=5), - ), - migrations.AlterField( - model_name='application', - name='authorization_grant_type', - 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), - ), - ] diff --git a/oauth2_provider/migrations/0004_idtoken.py b/oauth2_provider/migrations/0003_auto_20200902_2022.py similarity index 56% rename from oauth2_provider/migrations/0004_idtoken.py rename to oauth2_provider/migrations/0003_auto_20200902_2022.py index 7910870ef..684949c9d 100644 --- a/oauth2_provider/migrations/0004_idtoken.py +++ b/oauth2_provider/migrations/0003_auto_20200902_2022.py @@ -9,10 +9,20 @@ class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('oauth2_provider', '0003_auto_20190413_2007'), + ('oauth2_provider', '0002_auto_20190406_1805'), ] operations = [ + migrations.AddField( + model_name='application', + name='algorithm', + field=models.CharField(choices=[('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')], default='RS256', max_length=5), + ), + migrations.AlterField( + model_name='application', + name='authorization_grant_type', + 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), + ), migrations.CreateModel( name='IDToken', fields=[ @@ -30,4 +40,9 @@ class Migration(migrations.Migration): 'swappable': 'OAUTH2_PROVIDER_ID_TOKEN_MODEL', }, ), + migrations.AddField( + model_name='accesstoken', + name='id_token', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_token', to=oauth2_settings.ID_TOKEN_MODEL), + ), ] diff --git a/oauth2_provider/migrations/0005_accesstoken_id_token.py b/oauth2_provider/migrations/0005_accesstoken_id_token.py deleted file mode 100644 index 6407aae30..000000000 --- a/oauth2_provider/migrations/0005_accesstoken_id_token.py +++ /dev/null @@ -1,20 +0,0 @@ -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - -from oauth2_provider.settings import oauth2_settings - - -class Migration(migrations.Migration): - - dependencies = [ - ('oauth2_provider', '0004_idtoken'), - ] - - operations = [ - migrations.AddField( - model_name='accesstoken', - name='id_token', - field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_token', to=oauth2_settings.ID_TOKEN_MODEL), - ), - ] diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index f109eea38..e7fb860b3 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -27,12 +27,8 @@ from .exceptions import FatalClientError from .models import ( - AbstractApplication, - get_access_token_model, - get_id_token_model, - get_application_model, - get_grant_model, - get_refresh_token_model, + AbstractApplication, get_access_token_model, get_application_model, + get_grant_model, get_id_token_model, get_refresh_token_model ) from .scopes import get_scopes_backend from .settings import oauth2_settings diff --git a/oauth2_provider/urls.py b/oauth2_provider/urls.py index 07cb4a154..f2f04d853 100644 --- a/oauth2_provider/urls.py +++ b/oauth2_provider/urls.py @@ -29,7 +29,7 @@ oidc_urlpatterns = [ re_path(r"^\.well-known/openid-configuration/$", views.ConnectDiscoveryInfoView.as_view(), - name="oidc-connect-discovery-info"), + name="oidc-connect-discovery-info"), re_path(r"^jwks/$", views.JwksInfoView.as_view(), name="jwks-info"), re_path(r"^userinfo/$", views.UserInfoView.as_view(), name="user-info") ] diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index 49de9e8f6..e4eb8ae81 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -13,10 +13,8 @@ from oauthlib.oauth2.rfc6749 import errors as oauthlib_errors from oauth2_provider.models import ( - get_access_token_model, - get_application_model, - get_grant_model, - get_refresh_token_model, + get_access_token_model, get_application_model, + get_grant_model, get_refresh_token_model ) from oauth2_provider.settings import oauth2_settings from oauth2_provider.views import ProtectedResourceView diff --git a/tests/test_implicit.py b/tests/test_implicit.py index b005366b3..15ac7469d 100644 --- a/tests/test_implicit.py +++ b/tests/test_implicit.py @@ -1,11 +1,9 @@ -from urllib.parse import parse_qs, urlparse - import json +from urllib.parse import parse_qs, urlparse from django.contrib.auth import get_user_model from django.test import RequestFactory, TestCase from django.urls import reverse - from jwcrypto import jwk, jwt from oauth2_provider.models import get_application_model diff --git a/tests/urls.py b/tests/urls.py index d63672ed4..c7fa9a101 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -1,5 +1,5 @@ -from django.urls import include, re_path from django.contrib import admin +from django.urls import include, re_path admin.autodiscover() diff --git a/tox.ini b/tox.ini index a9fc0ea0c..686bf366a 100644 --- a/tox.ini +++ b/tox.ini @@ -35,7 +35,6 @@ deps = pytest-xdist py27: mock requests - jwcrypto [testenv:py37-docs] basepython = python