From 8b76b0d1a555af515219e68d63cc3f8495d4f66f Mon Sep 17 00:00:00 2001 From: John Byrne Date: Sat, 12 Aug 2023 15:00:44 -0400 Subject: [PATCH 1/6] Add an authorize endpoint that uses JSON instead of a Django template/HTML form --- oauth2_provider/urls.py | 2 + oauth2_provider/views/__init__.py | 2 +- oauth2_provider/views/base.py | 178 +++++++++++++++++------------- 3 files changed, 104 insertions(+), 78 deletions(-) diff --git a/oauth2_provider/urls.py b/oauth2_provider/urls.py index 4d23a3a5f..b0efe0a0c 100644 --- a/oauth2_provider/urls.py +++ b/oauth2_provider/urls.py @@ -8,6 +8,8 @@ base_urlpatterns = [ re_path(r"^authorize/$", views.AuthorizationView.as_view(), name="authorize"), + re_path(r"authorize.json/$", views.AuthorizationJSONView.as_view(), + name="authorize-json"), re_path(r"^token/$", views.TokenView.as_view(), name="token"), re_path(r"^revoke_token/$", views.RevokeTokenView.as_view(), name="revoke-token"), re_path(r"^introspect/$", views.IntrospectTokenView.as_view(), name="introspect"), diff --git a/oauth2_provider/views/__init__.py b/oauth2_provider/views/__init__.py index 9e32e17d8..9e0d42102 100644 --- a/oauth2_provider/views/__init__.py +++ b/oauth2_provider/views/__init__.py @@ -1,5 +1,5 @@ # flake8: noqa -from .base import AuthorizationView, TokenView, RevokeTokenView # isort:skip +from .base import AuthorizationView, AuthorizationJSONView, TokenView, RevokeTokenView # isort:skip from .application import ( ApplicationDelete, ApplicationDetail, diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index abaa81f59..7b00b25d9 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -12,6 +12,11 @@ from django.views.decorators.debug import sensitive_post_parameters from django.views.generic import FormView, View +# JB +from django.forms import model_to_dict +from django.core.serializers.json import DjangoJSONEncoder +from django.db.models import Model + from ..exceptions import OAuthToolkitError from ..forms import AllowForm from ..http import OAuth2ResponseRedirect @@ -65,8 +70,100 @@ def redirect(self, redirect_to, application): RFC3339 = "%Y-%m-%dT%H:%M:%SZ" +class AuthorizationMixin: + def get_context(self, request, *args, **kwargs): + try: + scopes, credentials = self.validate_authorization_request(request) + except OAuthToolkitError as error: + # Application is not available at this time. + return self.error_response(error, application=None) + + prompt = request.GET.get("prompt") + if prompt == "login": + return self.handle_prompt_login() + + all_scopes = get_scopes_backend().get_all_scopes() + kwargs["scopes_descriptions"] = [all_scopes[scope] for scope in scopes] + kwargs["scopes"] = scopes + # 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"]) + + 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"] + if "code_challenge" in credentials: + kwargs["code_challenge"] = credentials["code_challenge"] + if "code_challenge_method" in credentials: + kwargs["code_challenge_method"] = credentials["code_challenge_method"] + if "nonce" in credentials: + kwargs["nonce"] = credentials["nonce"] + if "claims" in credentials: + kwargs["claims"] = json.dumps(credentials["claims"]) + + self.oauth2_data = 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) + + try: + # If skip_authorization field is True, skip the authorization screen even + # if this is the first use of the application and there was no previous authorization. + # This is useful for in-house applications-> assume an in-house applications + # 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 + ) + 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() + ) + + # 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, + ) + return self.redirect(uri, application) + + except OAuthToolkitError as error: + return self.error_response(error, application) + return kwargs + +class AuthorizationJSONView(BaseAuthorizationView, AuthorizationMixin): + def get(self, request, *args, **kwargs): + context = self.get_context(request, *args, **kwargs) + return HttpResponse( + content=json.dumps(context, cls=self.ExtendedEncoder), + status=200) -class AuthorizationView(BaseAuthorizationView, FormView): + def post(self, request, *args, **kwargs): + # handle JSON post, sanitization etc. + pass + + class ExtendedEncoder(DjangoJSONEncoder): + def default(self, o): + if isinstance(o, Model): + return model_to_dict(o) + else: + return super().default(o) + +class AuthorizationView(BaseAuthorizationView, FormView, AuthorizationMixin): """ Implements an endpoint to handle *Authorization Requests* as in :rfc:`4.1.1` and prompting the user with a form to determine if she authorizes the client application to access her data. @@ -128,7 +225,6 @@ def form_valid(self, form): 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 @@ -141,82 +237,10 @@ def form_valid(self, form): return self.redirect(self.success_url, application) def get(self, request, *args, **kwargs): - try: - scopes, credentials = self.validate_authorization_request(request) - except OAuthToolkitError as error: - # Application is not available at this time. - return self.error_response(error, application=None) - - prompt = request.GET.get("prompt") - if prompt == "login": - return self.handle_prompt_login() - - all_scopes = get_scopes_backend().get_all_scopes() - kwargs["scopes_descriptions"] = [all_scopes[scope] for scope in scopes] - kwargs["scopes"] = scopes - # 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"]) - - 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"] - if "code_challenge" in credentials: - kwargs["code_challenge"] = credentials["code_challenge"] - if "code_challenge_method" in credentials: - kwargs["code_challenge_method"] = credentials["code_challenge_method"] - if "nonce" in credentials: - kwargs["nonce"] = credentials["nonce"] - if "claims" in credentials: - kwargs["claims"] = json.dumps(credentials["claims"]) - - self.oauth2_data = kwargs - # following two loc are here only because of https://code.djangoproject.com/ticket/17795 + context = self.get_context(request, *args, **kwargs) form = self.get_form(self.get_form_class()) - kwargs["form"] = form - - # 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) - - try: - # If skip_authorization field is True, skip the authorization screen even - # if this is the first use of the application and there was no previous authorization. - # This is useful for in-house applications-> assume an in-house applications - # 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 - ) - 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() - ) - - # 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, - ) - return self.redirect(uri, application) - - except OAuthToolkitError as error: - return self.error_response(error, application) - - return self.render_to_response(self.get_context_data(**kwargs)) + context["form"] = form + return self.render_to_response(self.get_context_data(**context)) def handle_prompt_login(self): path = self.request.build_absolute_uri() From 29a084d3186a65dd20ff2935f0d1da53b8976b8a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 12 Aug 2023 19:03:50 +0000 Subject: [PATCH 2/6] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- oauth2_provider/urls.py | 3 +-- oauth2_provider/views/base.py | 19 ++++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/oauth2_provider/urls.py b/oauth2_provider/urls.py index b0efe0a0c..37a095507 100644 --- a/oauth2_provider/urls.py +++ b/oauth2_provider/urls.py @@ -8,8 +8,7 @@ base_urlpatterns = [ re_path(r"^authorize/$", views.AuthorizationView.as_view(), name="authorize"), - re_path(r"authorize.json/$", views.AuthorizationJSONView.as_view(), - name="authorize-json"), + re_path(r"authorize.json/$", views.AuthorizationJSONView.as_view(), name="authorize-json"), re_path(r"^token/$", views.TokenView.as_view(), name="token"), re_path(r"^revoke_token/$", views.RevokeTokenView.as_view(), name="revoke-token"), re_path(r"^introspect/$", views.IntrospectTokenView.as_view(), name="introspect"), diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index 7b00b25d9..c4b202c12 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -4,6 +4,11 @@ from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.views import redirect_to_login +from django.core.serializers.json import DjangoJSONEncoder +from django.db.models import Model + +# JB +from django.forms import model_to_dict from django.http import HttpResponse from django.shortcuts import resolve_url from django.utils import timezone @@ -12,11 +17,6 @@ from django.views.decorators.debug import sensitive_post_parameters from django.views.generic import FormView, View -# JB -from django.forms import model_to_dict -from django.core.serializers.json import DjangoJSONEncoder -from django.db.models import Model - from ..exceptions import OAuthToolkitError from ..forms import AllowForm from ..http import OAuth2ResponseRedirect @@ -70,6 +70,7 @@ def redirect(self, redirect_to, application): RFC3339 = "%Y-%m-%dT%H:%M:%SZ" + class AuthorizationMixin: def get_context(self, request, *args, **kwargs): try: @@ -145,17 +146,16 @@ def get_context(self, request, *args, **kwargs): return self.error_response(error, application) return kwargs + class AuthorizationJSONView(BaseAuthorizationView, AuthorizationMixin): def get(self, request, *args, **kwargs): context = self.get_context(request, *args, **kwargs) - return HttpResponse( - content=json.dumps(context, cls=self.ExtendedEncoder), - status=200) + return HttpResponse(content=json.dumps(context, cls=self.ExtendedEncoder), status=200) def post(self, request, *args, **kwargs): # handle JSON post, sanitization etc. pass - + class ExtendedEncoder(DjangoJSONEncoder): def default(self, o): if isinstance(o, Model): @@ -163,6 +163,7 @@ def default(self, o): else: return super().default(o) + class AuthorizationView(BaseAuthorizationView, FormView, AuthorizationMixin): """ Implements an endpoint to handle *Authorization Requests* as in :rfc:`4.1.1` and prompting the From 701bacb3490d755a843e5ed3c71ea0dce4f8893d Mon Sep 17 00:00:00 2001 From: John Byrne Date: Wed, 16 Aug 2023 08:41:28 -0400 Subject: [PATCH 3/6] Add post handler --- oauth2_provider/views/base.py | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index 7b00b25d9..a2f6bdf05 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -153,8 +153,36 @@ def get(self, request, *args, **kwargs): status=200) def post(self, request, *args, **kwargs): - # handle JSON post, sanitization etc. - pass + body = request.POST + client_id = body["client_id"] + application = get_application_model().objects.get(client_id=client_id) + credentials = { + "client_id": body.get("client_id"), + "redirect_uri": body.get("redirect_uri"), + "response_type": body.get("response_type", None), + "state": body.get("state", None), + } + if body.get("code_challenge", False): + credentials["code_challenge"] = body.get("code_challenge") + if body.get("code_challenge_method", False): + credentials["code_challenge_method"] = body.get("code_challenge_method") + if body.get("nonce", False): + credentials["nonce"] = body.get("nonce") + if body.get("claims", False): + credentials["claims"] = body.get("claims") + + scopes = body.get("scope") + allow = body.get("allow") + try: + uri, headers, body, status = self.create_authorization_response( + request=self.request, scopes=scopes, credentials=credentials, allow=allow + ) + except OAuthToolkitError as error: + return self.error_response(error, application) + + self.success_url = uri + log.debug("Success url for the request: {0}".format(self.success_url)) + return self.redirect(self.success_url, application) class ExtendedEncoder(DjangoJSONEncoder): def default(self, o): From 855296d060df7fb27b33b14b7e97edb01ecc11f5 Mon Sep 17 00:00:00 2001 From: John Byrne Date: Thu, 31 Aug 2023 08:07:17 -0400 Subject: [PATCH 4/6] Remove JSON view --- oauth2_provider/urls.py | 1 - oauth2_provider/views/__init__.py | 2 +- oauth2_provider/views/base.py | 45 ------------------------------- 3 files changed, 1 insertion(+), 47 deletions(-) diff --git a/oauth2_provider/urls.py b/oauth2_provider/urls.py index 37a095507..4d23a3a5f 100644 --- a/oauth2_provider/urls.py +++ b/oauth2_provider/urls.py @@ -8,7 +8,6 @@ base_urlpatterns = [ re_path(r"^authorize/$", views.AuthorizationView.as_view(), name="authorize"), - re_path(r"authorize.json/$", views.AuthorizationJSONView.as_view(), name="authorize-json"), re_path(r"^token/$", views.TokenView.as_view(), name="token"), re_path(r"^revoke_token/$", views.RevokeTokenView.as_view(), name="revoke-token"), re_path(r"^introspect/$", views.IntrospectTokenView.as_view(), name="introspect"), diff --git a/oauth2_provider/views/__init__.py b/oauth2_provider/views/__init__.py index 9e0d42102..9e32e17d8 100644 --- a/oauth2_provider/views/__init__.py +++ b/oauth2_provider/views/__init__.py @@ -1,5 +1,5 @@ # flake8: noqa -from .base import AuthorizationView, AuthorizationJSONView, TokenView, RevokeTokenView # isort:skip +from .base import AuthorizationView, TokenView, RevokeTokenView # isort:skip from .application import ( ApplicationDelete, ApplicationDetail, diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index e63a09430..3bf5862a2 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -147,51 +147,6 @@ def get_context(self, request, *args, **kwargs): return kwargs -class AuthorizationJSONView(BaseAuthorizationView, AuthorizationMixin): - def get(self, request, *args, **kwargs): - context = self.get_context(request, *args, **kwargs) - return HttpResponse(content=json.dumps(context, cls=self.ExtendedEncoder), status=200) - - def post(self, request, *args, **kwargs): - body = request.POST - client_id = body["client_id"] - application = get_application_model().objects.get(client_id=client_id) - credentials = { - "client_id": body.get("client_id"), - "redirect_uri": body.get("redirect_uri"), - "response_type": body.get("response_type", None), - "state": body.get("state", None), - } - if body.get("code_challenge", False): - credentials["code_challenge"] = body.get("code_challenge") - if body.get("code_challenge_method", False): - credentials["code_challenge_method"] = body.get("code_challenge_method") - if body.get("nonce", False): - credentials["nonce"] = body.get("nonce") - if body.get("claims", False): - credentials["claims"] = body.get("claims") - - scopes = body.get("scope") - allow = body.get("allow") - try: - uri, headers, body, status = self.create_authorization_response( - request=self.request, scopes=scopes, credentials=credentials, allow=allow - ) - except OAuthToolkitError as error: - return self.error_response(error, application) - - self.success_url = uri - log.debug("Success url for the request: {0}".format(self.success_url)) - return self.redirect(self.success_url, application) - - class ExtendedEncoder(DjangoJSONEncoder): - def default(self, o): - if isinstance(o, Model): - return model_to_dict(o) - else: - return super().default(o) - - class AuthorizationView(BaseAuthorizationView, FormView, AuthorizationMixin): """ Implements an endpoint to handle *Authorization Requests* as in :rfc:`4.1.1` and prompting the From 1bbbe49ee6737fc0bdf2b2b2ab72d79ac7e59d19 Mon Sep 17 00:00:00 2001 From: John Byrne Date: Thu, 31 Aug 2023 08:09:01 -0400 Subject: [PATCH 5/6] Clean up imports --- oauth2_provider/views/base.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index 3bf5862a2..5f1c1b0ce 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -4,11 +4,6 @@ from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.views import redirect_to_login -from django.core.serializers.json import DjangoJSONEncoder -from django.db.models import Model - -# JB -from django.forms import model_to_dict from django.http import HttpResponse from django.shortcuts import resolve_url from django.utils import timezone From d0c03e51e5aecc047b91b4379586cd110bd99938 Mon Sep 17 00:00:00 2001 From: John Byrne Date: Sat, 2 Sep 2023 08:49:24 -0400 Subject: [PATCH 6/6] Handle other responses from AuthorizationView GET --- oauth2_provider/views/base.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index 5f1c1b0ce..62b391b0d 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -217,9 +217,12 @@ def form_valid(self, form): def get(self, request, *args, **kwargs): context = self.get_context(request, *args, **kwargs) - form = self.get_form(self.get_form_class()) - context["form"] = form - return self.render_to_response(self.get_context_data(**context)) + if isinstance(context, dict): + form = self.get_form(self.get_form_class()) + context["form"] = form + return self.render_to_response(self.get_context_data(**context)) + else: + return context def handle_prompt_login(self): path = self.request.build_absolute_uri()