From 82dcd8d2ee3d8dde84fa8f46f157b8e04d19f3df Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Mon, 18 Dec 2023 10:55:24 -0300 Subject: [PATCH 01/54] Update settings.rst --- docs/sections/settings.rst | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/sections/settings.rst b/docs/sections/settings.rst index 3a7df2cc..b44031b1 100644 --- a/docs/sections/settings.rst +++ b/docs/sections/settings.rst @@ -53,7 +53,7 @@ OIDC_CODE_EXPIRE OPTIONAL. ``int``. Code object expiration after been delivered. -Expressed in seconds. Default is ``60*10``. +Expressed in seconds. Default is ``10 mins``. OIDC_DISCOVERY_CACHE_ENABLE =========================== @@ -67,7 +67,7 @@ OIDC_DISCOVERY_CACHE_EXPIRE OPTIONAL. ``int``. Discovery endpoint cache expiration time expressed in seconds. -Expressed in seconds. Default is ``60*10``. +Expressed in seconds. Default is ``1 day``. OIDC_EXTRA_SCOPE_CLAIMS ======================= @@ -90,7 +90,7 @@ OIDC_IDTOKEN_EXPIRE OPTIONAL. ``int``. ID Token expiration after been delivered. -Expressed in seconds. Default is ``60*10``. +Expressed in seconds. Default is ``10 mins``. OIDC_IDTOKEN_PROCESSING_HOOK ============================ @@ -188,14 +188,14 @@ OIDC_SKIP_CONSENT_EXPIRE OPTIONAL. ``int``. How soon User Consent expires after being granted. -Expressed in days. Default is ``30*3``. +Expressed in days. Default is ``90 days``. OIDC_TOKEN_EXPIRE ================= OPTIONAL. ``int``. Token object (access token) expiration after being created. -Expressed in seconds. Default is ``60*60``. +Expressed in seconds. Default is ``1 hour``. OIDC_USERINFO ============= @@ -258,4 +258,4 @@ A flag which toggles whether the scope is returned with successful response on i Must be ``True`` to include ``scope`` into the successful response -Default is ``False``. \ No newline at end of file +Default is ``False``. From f7c313449b748db158967eab26b48f88b597c658 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Tue, 19 Dec 2023 12:22:00 -0300 Subject: [PATCH 02/54] Update LICENSE --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index 38ebb609..a55e239b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2014-2019 Juan Ignacio Fiorentino +Copyright (c) 2014-2024 Juan Ignacio Fiorentino Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal From 82fd925ecbb12968ee24a2581cf9d0393b1dc745 Mon Sep 17 00:00:00 2001 From: juanifioren Date: Thu, 3 Oct 2024 16:16:54 -0300 Subject: [PATCH 03/54] Default ordering RSA keys + example app for Django 4.2 --- example/app/templates/base.html | 2 +- example/app/templates/home.html | 2 +- example/app/urls.py | 15 ++++++--------- example/requirements.txt | 2 +- .../migrations/0027_alter_rsakey_options.py | 17 +++++++++++++++++ oidc_provider/models.py | 1 + 6 files changed, 27 insertions(+), 12 deletions(-) create mode 100644 oidc_provider/migrations/0027_alter_rsakey_options.py diff --git a/example/app/templates/base.html b/example/app/templates/base.html index 400cdff3..1ff34de3 100644 --- a/example/app/templates/base.html +++ b/example/app/templates/base.html @@ -1,4 +1,4 @@ -{% load i18n staticfiles %} +{% load i18n static %} diff --git a/example/app/templates/home.html b/example/app/templates/home.html index 32e01fff..1676ad29 100644 --- a/example/app/templates/home.html +++ b/example/app/templates/home.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% load i18n staticfiles %} +{% load i18n static %} {% block content %} diff --git a/example/app/urls.py b/example/app/urls.py index acc75adc..93e8b895 100644 --- a/example/app/urls.py +++ b/example/app/urls.py @@ -1,16 +1,13 @@ from django.contrib.auth import views as auth_views -try: - from django.urls import include, url -except ImportError: - from django.conf.urls import include, url +from django.urls import include, re_path from django.contrib import admin from django.views.generic import TemplateView urlpatterns = [ - url(r'^$', TemplateView.as_view(template_name='home.html'), name='home'), - url(r'^accounts/login/$', auth_views.LoginView.as_view(template_name='login.html'), name='login'), # noqa - url(r'^accounts/logout/$', auth_views.LogoutView.as_view(next_page='/'), name='logout'), - url(r'^', include('oidc_provider.urls', namespace='oidc_provider')), - url(r'^admin/', admin.site.urls), + re_path(r"^$", TemplateView.as_view(template_name='home.html'), name='home'), + re_path(r"^accounts/login/$", auth_views.LoginView.as_view(template_name='login.html'), name='login'), # noqa + re_path(r"^accounts/logout/$", auth_views.LogoutView.as_view(next_page='/'), name='logout'), + re_path(r"^", include('oidc_provider.urls', namespace='oidc_provider')), + re_path(r"^admin/", admin.site.urls), ] diff --git a/example/requirements.txt b/example/requirements.txt index 4f953922..92505d09 100644 --- a/example/requirements.txt +++ b/example/requirements.txt @@ -1,2 +1,2 @@ -django +django==4.2 https://github.com/juanifioren/django-oidc-provider/archive/master.zip diff --git a/oidc_provider/migrations/0027_alter_rsakey_options.py b/oidc_provider/migrations/0027_alter_rsakey_options.py new file mode 100644 index 00000000..ddec150a --- /dev/null +++ b/oidc_provider/migrations/0027_alter_rsakey_options.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2 on 2024-10-03 19:10 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('oidc_provider', '0026_client_multiple_response_types'), + ] + + operations = [ + migrations.AlterModelOptions( + name='rsakey', + options={'ordering': ['id'], 'verbose_name': 'RSA Key', 'verbose_name_plural': 'RSA Keys'}, + ), + ] diff --git a/oidc_provider/models.py b/oidc_provider/models.py index 45d66dc2..d2111086 100644 --- a/oidc_provider/models.py +++ b/oidc_provider/models.py @@ -252,6 +252,7 @@ class RSAKey(models.Model): verbose_name=_(u'Key'), help_text=_(u'Paste your private RSA Key here.')) class Meta: + ordering = ["id"] verbose_name = _(u'RSA Key') verbose_name_plural = _(u'RSA Keys') From 94734e25ed0676e1be49905f30f07d0b1f36e117 Mon Sep 17 00:00:00 2001 From: juanifioren Date: Thu, 3 Oct 2024 18:12:55 -0300 Subject: [PATCH 04/54] Update docs --- docs/conf.py | 4 ++-- docs/images/add_rsa_key.png | Bin 0 -> 58027 bytes docs/sections/changelog.rst | 2 ++ docs/sections/serverkeys.rst | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) create mode 100644 docs/images/add_rsa_key.png diff --git a/docs/conf.py b/docs/conf.py index f4fe2ecb..f17fbd1e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -45,7 +45,7 @@ # General information about the project. project = u'django-oidc-provider' -copyright = u'2023, Juan Ignacio Fiorentino' +copyright = u'2025, Juan Ignacio Fiorentino' author = u'Juan Ignacio Fiorentino' # The version info for the project you're documenting, acts as replacement for @@ -55,7 +55,7 @@ # The short X.Y version. version = u'0.8' # The full version, including alpha/beta/rc tags. -release = u'0.8.0' +release = u'0.8' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/docs/images/add_rsa_key.png b/docs/images/add_rsa_key.png new file mode 100644 index 0000000000000000000000000000000000000000..a493bb44c233f8053c0761a4e29f1b827ce51b94 GIT binary patch literal 58027 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 eH2L2MLR^z88XnW)#;7RBNUp< Date: Fri, 4 Oct 2024 16:34:38 -0300 Subject: [PATCH 05/54] Update docs --- docs/sections/contribute.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sections/contribute.rst b/docs/sections/contribute.rst index a804ae61..8d29a332 100644 --- a/docs/sections/contribute.rst +++ b/docs/sections/contribute.rst @@ -34,7 +34,7 @@ Improve Documentation We use `Sphinx `_ to generate this documentation. If you want to add or modify something just: -* Install Sphinx (``pip install sphinx``) and the auto-build tool (``pip install sphinx-autobuild``). +* Install Sphinx (``pip install sphinx sphinx_rtd_theme``) and the auto-build tool (``pip install sphinx-autobuild``). * Move inside the docs folder. ``cd docs/`` * Generate and watch docs by running ``sphinx-autobuild . _build/``. * Open ``http://127.0.0.1:8000`` in a browser. From a981a3d95a80cbe7ab3d33ec38e657def745543f Mon Sep 17 00:00:00 2001 From: juanifioren Date: Mon, 2 Dec 2024 17:56:40 -0300 Subject: [PATCH 06/54] Work on end_session_endpoint --- docs/sections/changelog.rst | 1 + docs/sections/sessionmanagement.rst | 51 ++++-- .../oidc_provider/end_session_prompt.html | 12 ++ .../tests/cases/test_end_session_endpoint.py | 161 ++++++++++++++---- oidc_provider/urls.py | 1 + oidc_provider/views.py | 128 ++++++++++---- 6 files changed, 268 insertions(+), 86 deletions(-) create mode 100644 oidc_provider/templates/oidc_provider/end_session_prompt.html diff --git a/docs/sections/changelog.rst b/docs/sections/changelog.rst index 73a19aa4..31ebcf67 100644 --- a/docs/sections/changelog.rst +++ b/docs/sections/changelog.rst @@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file. Unreleased ========== +* Changed: Improved "OpenID Connect RP-Initiated Logout" implementation. * Fixed: RSA server keys random ordering. * Fixed: Example app working with Django 4. diff --git a/docs/sections/sessionmanagement.rst b/docs/sections/sessionmanagement.rst index 3c182b71..3ae7e9ac 100644 --- a/docs/sections/sessionmanagement.rst +++ b/docs/sections/sessionmanagement.rst @@ -22,6 +22,39 @@ Somewhere in your Django ``settings.py``:: If you're in a multi-server setup, you might also want to add ``OIDC_UNAUTHENTICATED_SESSION_MANAGEMENT_KEY`` to your settings and set it to some random but fixed string. While authenticated clients have a session that can be used to calculate the browser state, there is no such thing for unauthenticated clients. Hence this value. By default a value is generated randomly on startup, so this will be different on each server. To get a consistent value across all servers you should set this yourself. +RP-Initiated Logout +=================== + +An RP can notify the OP that the End-User has logged out of the site, and might want to log out of the OP as well. In this case, the RP, after having logged the End-User out of the RP, redirects the End-User's User Agent to the OP's logout endpoint URL. + +This URL is normally obtained via the ``end_session_endpoint`` element of the OP's Discovery response. + +Parameters that are passed as query parameters in the logout request: + +* ``id_token_hint`` + RECOMMENDED. Previously issued ID Token passed to the logout endpoint as a hint about the End-User's current authenticated session with the Client. +* ``post_logout_redirect_uri`` + OPTIONAL. URL to which the RP is requesting that the End-User's User Agent be redirected after a logout has been performed. + + The value must be a valid, encoded URL that has been registered in the list of "Post Logout Redirect URIs" in your Client (RP) page. +* ``state`` + OPTIONAL. Opaque value used by the RP to maintain state between the logout request and the callback to the endpoint specified by the ``post_logout_redirect_uri`` query parameter. + +Example redirect:: + + http://localhost:8000/end-session/?id_token_hint=eyJhbGciOiJSUzI1NiIsImtpZCI6ImQwM...&post_logout_redirect_uri=http://rp.example.com/logged-out/&state=c91c03ea6c46a86 + +**Logout consent prompt** + +The standard defines that the logout flow should be interrupted to prompt the user for consent if the OpenID provider cannot verify that the request was made by the user. + +We enforce this behavior by displaying a logout consent prompt if it detects any of the following conditions: + +* If ``id_token_hint`` is not present or is invalid (we could not validate the client from it). +* If ``post_logout_redirect_uri`` is not registered in the list of "Post Logout Redirect URIs". + +If the user confirms the logout request, we continue the logout flow. To modify the logout consent template create your own ``oidc_provider/end_session_prompt.html``. + Example RP iframe ================= @@ -70,22 +103,4 @@ Example RP iframe -RP-Initiated Logout -=================== - -An RP can notify the OP that the End-User has logged out of the site, and might want to log out of the OP as well. In this case, the RP, after having logged the End-User out of the RP, redirects the End-User's User Agent to the OP's logout endpoint URL. -This URL is normally obtained via the ``end_session_endpoint`` element of the OP's Discovery response. - -Parameters that are passed as query parameters in the logout request: - -* ``id_token_hint`` - Previously issued ID Token passed to the logout endpoint as a hint about the End-User's current authenticated session with the Client. -* ``post_logout_redirect_uri`` - URL to which the RP is requesting that the End-User's User Agent be redirected after a logout has been performed. -* ``state`` - OPTIONAL. Opaque value used by the RP to maintain state between the logout request and the callback to the endpoint specified by the ``post_logout_redirect_uri`` query parameter. - -Example redirect:: - - http://localhost:8000/end-session/?id_token_hint=eyJhbGciOiJSUzI1NiIsImtpZCI6ImQwM...&post_logout_redirect_uri=http://rp.example.com/logged-out/&state=c91c03ea6c46a86 diff --git a/oidc_provider/templates/oidc_provider/end_session_prompt.html b/oidc_provider/templates/oidc_provider/end_session_prompt.html new file mode 100644 index 00000000..6f3c6842 --- /dev/null +++ b/oidc_provider/templates/oidc_provider/end_session_prompt.html @@ -0,0 +1,12 @@ +

End Session

+ +

Hi {{ user.email }}, are you sure you want to log out{% if client %} from {{ client.name }} app{% endif %}?

+ +
+ + {% csrf_token %} + + + + +
diff --git a/oidc_provider/tests/cases/test_end_session_endpoint.py b/oidc_provider/tests/cases/test_end_session_endpoint.py index fb36f8e8..a0f04038 100644 --- a/oidc_provider/tests/cases/test_end_session_endpoint.py +++ b/oidc_provider/tests/cases/test_end_session_endpoint.py @@ -1,3 +1,8 @@ +try: + from urllib import urlencode +except ImportError: + from urllib.parse import urlencode + from django.core.management import call_command try: from django.urls import reverse @@ -26,55 +31,139 @@ class EndSessionTestCase(TestCase): def setUp(self): call_command('creatersakey') self.user = create_fake_user() + self.client.force_login(self.user) + # Create a client with a custom logout URL. self.oidc_client = create_fake_client('id_token') - self.LOGOUT_URL = 'http://example.com/logged-out/' - self.oidc_client.post_logout_redirect_uris = [self.LOGOUT_URL] + self.url_logout = 'http://example.com/logged-out/' + self.oidc_client.post_logout_redirect_uris = [self.url_logout] self.oidc_client.save() - self.url = reverse('oidc_provider:end-session') - - def test_redirects_when_aud_is_str(self): - query_params = { - 'post_logout_redirect_uri': self.LOGOUT_URL, - } - response = self.client.get(self.url, query_params) - # With no id_token the OP MUST NOT redirect to the requested - # redirect_uri. - self.assertRedirects( - response, settings.get('OIDC_LOGIN_URL'), - fetch_redirect_response=False) - + # Create a valid ID Token for the user. token = create_token(self.user, self.oidc_client, []) id_token_dic = create_id_token( token=token, user=self.user, aud=self.oidc_client.client_id) - id_token = encode_id_token(id_token_dic, self.oidc_client) + self.id_token = encode_id_token(id_token_dic, self.oidc_client) - query_params['id_token_hint'] = id_token + self.url = reverse('oidc_provider:end-session') + self.url_prompt = reverse('oidc_provider:end-session-prompt') + def test_id_token_hint_not_present_user_prompted(self): + response = self.client.get(self.url) + # We should display a logout consent prompt if id_token_hint parameter is not present. + self.assertEqual(response.status_code, 302) + self.assertEqual(response.headers['Location'], self.url_prompt) + + def test_id_token_hint_is_present_user_redirected_to_client_logout_url(self): + query_params = { + 'id_token_hint': self.id_token, + } response = self.client.get(self.url, query_params) - self.assertRedirects( - response, self.LOGOUT_URL, fetch_redirect_response=False) + # ID Token is valid so user was + self.assertEqual(response.status_code, 302) + self.assertEqual(response.headers['Location'], self.url_logout) + + def test_id_token_hint_is_present_user_redirected_to_client_logout_url_with_post(self): + data = { + 'id_token_hint': self.id_token, + } + response = self.client.post(self.url, data) + # ID Token is valid so user was + self.assertEqual(response.status_code, 302) + self.assertEqual(response.headers['Location'], self.url_logout) - def test_redirects_when_aud_is_list(self): - """Check with 'aud' containing a list of str.""" + def test_state_is_present_and_being_passed_to_logout_url(self): query_params = { - 'post_logout_redirect_uri': self.LOGOUT_URL, + 'id_token_hint': self.id_token, + 'state': 'ABCDE', } - token = create_token(self.user, self.oidc_client, []) - id_token_dic = create_id_token( - token=token, user=self.user, aud=self.oidc_client.client_id) - id_token_dic['aud'] = [id_token_dic['aud']] - id_token = encode_id_token(id_token_dic, self.oidc_client) - query_params['id_token_hint'] = id_token response = self.client.get(self.url, query_params) - self.assertRedirects( - response, self.LOGOUT_URL, fetch_redirect_response=False) + # Let's ensure state is being passed to the logout url. + self.assertEqual(response.status_code, 302) + self.assertEqual(response.headers['Location'], '{0}?state={1}'.format(self.url_logout, 'ABCDE')) + + def test_post_logout_uri_not_in_client_urls(self): + query_params = { + 'id_token_hint': self.id_token, + 'post_logout_redirect_uri': 'http://other.com/bye/', + } + response = self.client.get(self.url, query_params) + # We prompt the user since the post logout url is not from client urls. + # Also ensure client_id is present since we could validate id_token_hint. + self.assertEqual(response.status_code, 302) + self.assertEqual(response.headers['Location'], '{0}?client_id={1}'.format(self.url_prompt, self.oidc_client.client_id)) + + def test_prompt_view_redirecting_to_client_post_logout_since_user_unauthenticated(self): + self.client.logout() + query_params = { + 'client_id': self.oidc_client.client_id, + } + response = self.client.get(self.url_prompt, query_params) + # Since user is unauthenticated on the backend, we send it back to client post logout + # registered uri. + self.assertEqual(response.status_code, 302) + self.assertEqual(response.headers['Location'], self.url_logout) + + def test_prompt_view_raising_404_since_user_unauthenticated_and_no_client(self): + self.client.logout() + response = self.client.get(self.url_prompt) + # Since user is unauthenticated and no client information is present, we just show + # not found page. + self.assertEqual(response.status_code, 404) + + def test_prompt_view_displaying_logout_decision_form_to_user(self): + query_params = { + 'client_id': self.oidc_client.client_id, + } + response = self.client.get(self.url_prompt, query_params) + # User is prompted to logout with client information displayed. + self.assertContains(response, '

Hi johndoe@example.com, are you sure you want to log out from Some Client app?

', status_code=200, html=True) + def test_prompt_view_displaying_logout_decision_form_to_user_no_client(self): + response = self.client.get(self.url_prompt) + # User is prompted to logout without client information displayed. + self.assertContains(response, '

Hi johndoe@example.com, are you sure you want to log out?

', status_code=200, html=True) + + @mock.patch(settings.get('OIDC_AFTER_END_SESSION_HOOK')) + def test_prompt_view_user_logged_out_after_form_allowed(self, end_session_hook): + self.assertIn('_auth_user_id', self.client.session) + # We want to POST to /end-session-prompt/?client_id=ABC endpoint. + url_prompt_with_client = self.url_prompt + '?' + urlencode({ + 'client_id': self.oidc_client.client_id, + }) + data = { + 'allow': 'Anything', # This means user allowed being logged out. + } + response = self.client.post(url_prompt_with_client, data) + # Ensure user is now logged out and redirected to client post logout uri. + self.assertNotIn('_auth_user_id', self.client.session) + self.assertEqual(response.status_code, 302) + self.assertEqual(response.headers['Location'], self.url_logout) + # End session hook should be called. + self.assertTrue(end_session_hook.called) + self.assertTrue(end_session_hook.call_count == 1) + + @mock.patch(settings.get('OIDC_AFTER_END_SESSION_HOOK')) + def test_prompt_view_user_logged_out_after_form_not_allowed(self, end_session_hook): + self.assertIn('_auth_user_id', self.client.session) + # We want to POST to /end-session-prompt/?client_id=ABC endpoint. + url_prompt_with_client = self.url_prompt + '?' + urlencode({ + 'client_id': self.oidc_client.client_id, + }) + response = self.client.post(url_prompt_with_client) # No data. + # Ensure user is still logged in and redirected to client post logout uri. + self.assertIn('_auth_user_id', self.client.session) + self.assertEqual(response.status_code, 302) + self.assertEqual(response.headers['Location'], self.url_logout) + # End session hook should not be called. + self.assertFalse(end_session_hook.called) + @mock.patch(settings.get('OIDC_AFTER_END_SESSION_HOOK')) - def test_call_post_end_session_hook(self, hook_function): - self.client.get(self.url) - self.assertTrue(hook_function.called, 'OIDC_AFTER_END_SESSION_HOOK should be called') - self.assertTrue( - hook_function.call_count == 1, - 'OIDC_AFTER_END_SESSION_HOOK should be called once') + def test_prompt_view_user_not_logged_out_after_form_not_allowed_no_client(self, end_session_hook): + self.assertIn('_auth_user_id', self.client.session) + response = self.client.post(self.url_prompt) # No data. + # Ensure user is still logged in and 404 NOT FOUND was raised. + self.assertIn('_auth_user_id', self.client.session) + self.assertEqual(response.status_code, 404) + # End session hook should not be called. + self.assertFalse(end_session_hook.called) diff --git a/oidc_provider/urls.py b/oidc_provider/urls.py index 08d219f0..1250a9ba 100644 --- a/oidc_provider/urls.py +++ b/oidc_provider/urls.py @@ -12,6 +12,7 @@ re_path(r'^token/?$', csrf_exempt(views.TokenView.as_view()), name='token'), re_path(r'^userinfo/?$', csrf_exempt(views.userinfo), name='userinfo'), re_path(r'^end-session/?$', views.EndSessionView.as_view(), name='end-session'), + re_path(r'^end-session-prompt/?$', views.EndSessionPromptView.as_view(), name='end-session-prompt'), re_path(r'^\.well-known/openid-configuration/?$', views.ProviderInfoView.as_view(), name='provider-info'), re_path(r'^introspect/?$', views.TokenIntrospectionView.as_view(), name='token-introspection'), diff --git a/oidc_provider/views.py b/oidc_provider/views.py index 4a2c94ce..748961f6 100644 --- a/oidc_provider/views.py +++ b/oidc_provider/views.py @@ -3,7 +3,6 @@ from django.views.decorators.csrf import csrf_exempt -from oidc_provider.lib.endpoints.introspection import TokenIntrospectionEndpoint try: from urllib import urlencode from urlparse import urlsplit, parse_qs, urlunsplit @@ -13,7 +12,6 @@ from Cryptodome.PublicKey import RSA from django.contrib.auth.views import ( redirect_to_login, - LogoutView, ) try: from django.urls import reverse @@ -22,18 +20,19 @@ from django.db import transaction from django.contrib.auth import logout as django_user_logout from django.core.cache import cache -from django.http import JsonResponse, HttpResponse +from django.http import JsonResponse, HttpResponse, Http404 from django.shortcuts import render from django.template.loader import render_to_string from django.utils.decorators import method_decorator from django.views.decorators.clickjacking import xframe_options_exempt from django.views.decorators.http import require_http_methods -from django.views.generic import View +from django.views.generic import TemplateView, View from jwkest import long_to_base64 from oidc_provider.compat import get_attr_or_callable from oidc_provider.lib.claims import StandardScopeClaims from oidc_provider.lib.endpoints.authorize import AuthorizeEndpoint +from oidc_provider.lib.endpoints.introspection import TokenIntrospectionEndpoint from oidc_provider.lib.endpoints.token import TokenEndpoint from oidc_provider.lib.errors import ( AuthorizeError, @@ -62,6 +61,7 @@ logger = logging.getLogger(__name__) OIDC_TEMPLATES = settings.get('OIDC_TEMPLATES') +after_end_session_hook = settings.get('OIDC_AFTER_END_SESSION_HOOK', import_str=True) class AuthorizeView(View): @@ -343,43 +343,107 @@ def get(self, request, *args, **kwargs): return response -class EndSessionView(LogoutView): - def dispatch(self, request, *args, **kwargs): - id_token_hint = request.GET.get('id_token_hint', '') - post_logout_redirect_uri = request.GET.get('post_logout_redirect_uri', '') - state = request.GET.get('state', '') - client = None - - next_page = settings.get('OIDC_LOGIN_URL') - after_end_session_hook = settings.get('OIDC_AFTER_END_SESSION_HOOK', import_str=True) - - if id_token_hint: - client_id = client_id_from_id_token(id_token_hint) - try: - client = Client.objects.get(client_id=client_id) - if post_logout_redirect_uri in client.post_logout_redirect_uris: - if state: - uri = urlsplit(post_logout_redirect_uri) - query_params = parse_qs(uri.query) - query_params['state'] = state - uri = uri._replace(query=urlencode(query_params, doseq=True)) - next_page = urlunsplit(uri) - else: - next_page = post_logout_redirect_uri - except Client.DoesNotExist: - pass +class EndSessionView(View): + http_method_names = ['get', 'post'] + @classmethod + def logout_user(cls, request, id_token_hint=None, post_logout_redirect_uri=None, state=None, client=None, next_page=None): after_end_session_hook( request=request, id_token=id_token_hint, post_logout_redirect_uri=post_logout_redirect_uri, state=state, client=client, - next_page=next_page + next_page=next_page, ) + django_user_logout(request) + + @method_decorator(csrf_exempt) + def dispatch(self, request, *args, **kwargs): + self.id_token_hint = request.POST.get('id_token_hint') or request.GET.get('id_token_hint') + self.post_logout_redirect_uri = request.POST.get('post_logout_redirect_uri') or request.GET.get('post_logout_redirect_uri') + self.state = request.POST.get('state') or request.GET.get('state') + self.client = None - self.next_page = next_page - return super(EndSessionView, self).dispatch(request, *args, **kwargs) + if self.id_token_hint: + client_id = client_id_from_id_token(self.id_token_hint) + try: + self.client = Client.objects.get(client_id=client_id) + + if self.post_logout_redirect_uri: + if not self.post_logout_redirect_uri in self.client.post_logout_redirect_uris: + return redirect(reverse('oidc_provider:end-session-prompt') + '?' + urlencode({ + 'client_id': client_id, + })) + elif self.client.post_logout_redirect_uris: + self.post_logout_redirect_uri = self.client.post_logout_redirect_uris[0] + else: + self.logout_user(request, self.id_token_hint, self.post_logout_redirect_uri, self.state, self.client) + raise Http404("You have successfully logged out!") + + if self.state: + uri = urlsplit(self.post_logout_redirect_uri) + query_params = parse_qs(uri.query) + query_params['state'] = self.state + uri = uri._replace(query=urlencode(query_params, doseq=True)) + next_page = urlunsplit(uri) + else: + next_page = self.post_logout_redirect_uri + + self.logout_user(request, self.id_token_hint, self.post_logout_redirect_uri, self.state, self.client, next_page) + return redirect(next_page) + except Client.DoesNotExist: + pass + + return redirect(reverse('oidc_provider:end-session-prompt')) + + +class EndSessionPromptView(TemplateView): + http_method_names = ['get', 'post'] + template_name = 'oidc_provider/end_session_prompt.html' + + def dispatch(self, request, *args, **kwargs): + self.client_id = request.GET.get('client_id') + self.client = Client.objects.filter(client_id=self.client_id).first() + return super(EndSessionPromptView, self).dispatch(request, *args, **kwargs) + + def get(self, request, *args, **kwargs): + # If user is not authenticated, we should redirect to client post logout uri if exists, + # otherwhise, just raise a not found error. + if not get_attr_or_callable(request.user, 'is_authenticated'): + if self.client and self.client.post_logout_redirect_uris: + return redirect(self.client.post_logout_redirect_uris[0]) + else: + raise Http404("You are already logged out!") + + return super(EndSessionPromptView, self).get(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + context = super(EndSessionPromptView, self).get_context_data(**kwargs) + context['client'] = self.client + + end_session_prompt_url = reverse('oidc_provider:end-session-prompt') + if self.client_id: + end_session_prompt_url += '?' + urlencode({ + 'client_id': self.client_id, + }) + context['end_session_prompt_url'] = end_session_prompt_url + + return context + + def post(self, request, *args, **kwargs): + allowed = request.POST.get('allow') + next_page = self.client.post_logout_redirect_uris[0] if self.client and self.client.post_logout_redirect_uris else None + + # Only logout users if they allow it. + if allowed: + EndSessionView.logout_user(request, client=self.client, next_page=next_page) + + # Redirect to post logout uri if client is present. + if next_page: + return redirect(next_page) + raise Http404("You have successfully logged out!" if allowed else "You can close this window.") + class CheckSessionIframeView(View): From 102f1c18402b1e21e63566a53db96c0a95787008 Mon Sep 17 00:00:00 2001 From: juanifioren Date: Mon, 2 Dec 2024 18:29:00 -0300 Subject: [PATCH 07/54] Work on end_session_endpoint --- .../tests/cases/test_end_session_endpoint.py | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/oidc_provider/tests/cases/test_end_session_endpoint.py b/oidc_provider/tests/cases/test_end_session_endpoint.py index a0f04038..59aac7e1 100644 --- a/oidc_provider/tests/cases/test_end_session_endpoint.py +++ b/oidc_provider/tests/cases/test_end_session_endpoint.py @@ -124,8 +124,8 @@ def test_prompt_view_displaying_logout_decision_form_to_user_no_client(self): # User is prompted to logout without client information displayed. self.assertContains(response, '

Hi johndoe@example.com, are you sure you want to log out?

', status_code=200, html=True) - @mock.patch(settings.get('OIDC_AFTER_END_SESSION_HOOK')) - def test_prompt_view_user_logged_out_after_form_allowed(self, end_session_hook): + @mock.patch('oidc_provider.views.after_end_session_hook') + def test_prompt_view_user_logged_out_after_form_allowed(self, after_end_session_hook): self.assertIn('_auth_user_id', self.client.session) # We want to POST to /end-session-prompt/?client_id=ABC endpoint. url_prompt_with_client = self.url_prompt + '?' + urlencode({ @@ -140,11 +140,11 @@ def test_prompt_view_user_logged_out_after_form_allowed(self, end_session_hook): self.assertEqual(response.status_code, 302) self.assertEqual(response.headers['Location'], self.url_logout) # End session hook should be called. - self.assertTrue(end_session_hook.called) - self.assertTrue(end_session_hook.call_count == 1) + self.assertTrue(after_end_session_hook.called) + self.assertTrue(after_end_session_hook.call_count == 1) - @mock.patch(settings.get('OIDC_AFTER_END_SESSION_HOOK')) - def test_prompt_view_user_logged_out_after_form_not_allowed(self, end_session_hook): + @mock.patch('oidc_provider.views.after_end_session_hook') + def test_prompt_view_user_logged_out_after_form_not_allowed(self, after_end_session_hook): self.assertIn('_auth_user_id', self.client.session) # We want to POST to /end-session-prompt/?client_id=ABC endpoint. url_prompt_with_client = self.url_prompt + '?' + urlencode({ @@ -156,14 +156,14 @@ def test_prompt_view_user_logged_out_after_form_not_allowed(self, end_session_ho self.assertEqual(response.status_code, 302) self.assertEqual(response.headers['Location'], self.url_logout) # End session hook should not be called. - self.assertFalse(end_session_hook.called) + self.assertFalse(after_end_session_hook.called) - @mock.patch(settings.get('OIDC_AFTER_END_SESSION_HOOK')) - def test_prompt_view_user_not_logged_out_after_form_not_allowed_no_client(self, end_session_hook): + @mock.patch('oidc_provider.views.after_end_session_hook') + def test_prompt_view_user_not_logged_out_after_form_not_allowed_no_client(self, after_end_session_hook): self.assertIn('_auth_user_id', self.client.session) response = self.client.post(self.url_prompt) # No data. # Ensure user is still logged in and 404 NOT FOUND was raised. self.assertIn('_auth_user_id', self.client.session) self.assertEqual(response.status_code, 404) # End session hook should not be called. - self.assertFalse(end_session_hook.called) + self.assertFalse(after_end_session_hook.called) From 8b29c30072b16b50292b6551df9ff1fe9e8de930 Mon Sep 17 00:00:00 2001 From: juanifioren Date: Tue, 3 Dec 2024 14:24:27 -0300 Subject: [PATCH 08/54] Work on end_session_endpoint --- .vscode/settings.json | 17 + .../tests/cases/test_end_session_endpoint.py | 152 +++++--- oidc_provider/urls.py | 34 +- oidc_provider/views.py | 334 ++++++++++-------- 4 files changed, 326 insertions(+), 211 deletions(-) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..171bc2c7 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,17 @@ +{ + "[python]": { + "editor.codeActionsOnSave": { + "source.organizeImports": true + } + }, + "python.formatting.provider": "black", + "editor.formatOnSave": true, + "black-formatter.args": [ + "--line-length=100", + "--preview", + ], + "isort.args": [ + "--profile", + "black" + ], +} \ No newline at end of file diff --git a/oidc_provider/tests/cases/test_end_session_endpoint.py b/oidc_provider/tests/cases/test_end_session_endpoint.py index 59aac7e1..7bb6562b 100644 --- a/oidc_provider/tests/cases/test_end_session_endpoint.py +++ b/oidc_provider/tests/cases/test_end_session_endpoint.py @@ -4,6 +4,7 @@ from urllib.parse import urlencode from django.core.management import call_command + try: from django.urls import reverse except ImportError: @@ -15,7 +16,6 @@ create_id_token, encode_id_token, ) -from oidc_provider import settings from oidc_provider.tests.app.utils import ( create_fake_client, create_fake_user, @@ -29,81 +29,103 @@ class EndSessionTestCase(TestCase): """ def setUp(self): - call_command('creatersakey') + call_command("creatersakey") self.user = create_fake_user() self.client.force_login(self.user) # Create a client with a custom logout URL. - self.oidc_client = create_fake_client('id_token') - self.url_logout = 'http://example.com/logged-out/' + self.oidc_client = create_fake_client("id_token") + self.url_logout = "http://example.com/logged-out/" self.oidc_client.post_logout_redirect_uris = [self.url_logout] self.oidc_client.save() # Create a valid ID Token for the user. token = create_token(self.user, self.oidc_client, []) - id_token_dic = create_id_token( - token=token, user=self.user, aud=self.oidc_client.client_id) + id_token_dic = create_id_token(token=token, user=self.user, aud=self.oidc_client.client_id) self.id_token = encode_id_token(id_token_dic, self.oidc_client) - self.url = reverse('oidc_provider:end-session') - self.url_prompt = reverse('oidc_provider:end-session-prompt') + self.url = reverse("oidc_provider:end-session") + self.url_prompt = reverse("oidc_provider:end-session-prompt") def test_id_token_hint_not_present_user_prompted(self): response = self.client.get(self.url) # We should display a logout consent prompt if id_token_hint parameter is not present. self.assertEqual(response.status_code, 302) - self.assertEqual(response.headers['Location'], self.url_prompt) - - def test_id_token_hint_is_present_user_redirected_to_client_logout_url(self): + self.assertEqual(response.headers["Location"], self.url_prompt) + # User still logged in. + self.assertIn("_auth_user_id", self.client.session) + + @mock.patch("oidc_provider.views.after_end_session_hook") + def test_id_token_hint_is_present_user_redirected_to_client_logout_url( + self, after_end_session_hook + ): query_params = { - 'id_token_hint': self.id_token, + "id_token_hint": self.id_token, } response = self.client.get(self.url, query_params) - # ID Token is valid so user was + # ID Token is valid so user was redirected to registered uri. self.assertEqual(response.status_code, 302) - self.assertEqual(response.headers['Location'], self.url_logout) - - def test_id_token_hint_is_present_user_redirected_to_client_logout_url_with_post(self): + self.assertEqual(response.headers["Location"], self.url_logout) + # User logged out. + self.assertNotIn("_auth_user_id", self.client.session) + # End session hook should be called. + self.assertTrue(after_end_session_hook.called) + self.assertTrue(after_end_session_hook.call_count == 1) + + @mock.patch("oidc_provider.views.after_end_session_hook") + def test_id_token_hint_is_present_user_redirected_to_client_logout_url_with_post( + self, after_end_session_hook + ): data = { - 'id_token_hint': self.id_token, + "id_token_hint": self.id_token, } response = self.client.post(self.url, data) # ID Token is valid so user was self.assertEqual(response.status_code, 302) - self.assertEqual(response.headers['Location'], self.url_logout) + self.assertEqual(response.headers["Location"], self.url_logout) + # User logged out. + self.assertNotIn("_auth_user_id", self.client.session) + # End session hook should be called. + self.assertTrue(after_end_session_hook.called) + self.assertTrue(after_end_session_hook.call_count == 1) def test_state_is_present_and_being_passed_to_logout_url(self): query_params = { - 'id_token_hint': self.id_token, - 'state': 'ABCDE', + "id_token_hint": self.id_token, + "state": "ABCDE", } response = self.client.get(self.url, query_params) # Let's ensure state is being passed to the logout url. self.assertEqual(response.status_code, 302) - self.assertEqual(response.headers['Location'], '{0}?state={1}'.format(self.url_logout, 'ABCDE')) - + self.assertEqual( + response.headers["Location"], "{0}?state={1}".format(self.url_logout, "ABCDE") + ) + def test_post_logout_uri_not_in_client_urls(self): query_params = { - 'id_token_hint': self.id_token, - 'post_logout_redirect_uri': 'http://other.com/bye/', + "id_token_hint": self.id_token, + "post_logout_redirect_uri": "http://other.com/bye/", } response = self.client.get(self.url, query_params) # We prompt the user since the post logout url is not from client urls. # Also ensure client_id is present since we could validate id_token_hint. self.assertEqual(response.status_code, 302) - self.assertEqual(response.headers['Location'], '{0}?client_id={1}'.format(self.url_prompt, self.oidc_client.client_id)) + self.assertEqual( + response.headers["Location"], + "{0}?client_id={1}".format(self.url_prompt, self.oidc_client.client_id), + ) def test_prompt_view_redirecting_to_client_post_logout_since_user_unauthenticated(self): self.client.logout() query_params = { - 'client_id': self.oidc_client.client_id, + "client_id": self.oidc_client.client_id, } response = self.client.get(self.url_prompt, query_params) # Since user is unauthenticated on the backend, we send it back to client post logout # registered uri. self.assertEqual(response.status_code, 302) - self.assertEqual(response.headers['Location'], self.url_logout) - + self.assertEqual(response.headers["Location"], self.url_logout) + def test_prompt_view_raising_404_since_user_unauthenticated_and_no_client(self): self.client.logout() response = self.client.get(self.url_prompt) @@ -113,57 +135,81 @@ def test_prompt_view_raising_404_since_user_unauthenticated_and_no_client(self): def test_prompt_view_displaying_logout_decision_form_to_user(self): query_params = { - 'client_id': self.oidc_client.client_id, + "client_id": self.oidc_client.client_id, } response = self.client.get(self.url_prompt, query_params) # User is prompted to logout with client information displayed. - self.assertContains(response, '

Hi johndoe@example.com, are you sure you want to log out from Some Client app?

', status_code=200, html=True) + self.assertContains( + response, + "

Hi johndoe@example.com, are you sure you want to log out from Some Client app?

", # noqa + status_code=200, + html=True, + ) def test_prompt_view_displaying_logout_decision_form_to_user_no_client(self): response = self.client.get(self.url_prompt) # User is prompted to logout without client information displayed. - self.assertContains(response, '

Hi johndoe@example.com, are you sure you want to log out?

', status_code=200, html=True) - - @mock.patch('oidc_provider.views.after_end_session_hook') + self.assertContains( + response, + "

Hi johndoe@example.com, are you sure you want to log out?

", + status_code=200, + html=True, + ) + + @mock.patch("oidc_provider.views.after_end_session_hook") def test_prompt_view_user_logged_out_after_form_allowed(self, after_end_session_hook): - self.assertIn('_auth_user_id', self.client.session) + self.assertIn("_auth_user_id", self.client.session) # We want to POST to /end-session-prompt/?client_id=ABC endpoint. - url_prompt_with_client = self.url_prompt + '?' + urlencode({ - 'client_id': self.oidc_client.client_id, - }) + url_prompt_with_client = ( + self.url_prompt + + "?" + + urlencode( + { + "client_id": self.oidc_client.client_id, + } + ) + ) data = { - 'allow': 'Anything', # This means user allowed being logged out. + "allow": "Anything", # This means user allowed being logged out. } response = self.client.post(url_prompt_with_client, data) # Ensure user is now logged out and redirected to client post logout uri. - self.assertNotIn('_auth_user_id', self.client.session) + self.assertNotIn("_auth_user_id", self.client.session) self.assertEqual(response.status_code, 302) - self.assertEqual(response.headers['Location'], self.url_logout) + self.assertEqual(response.headers["Location"], self.url_logout) # End session hook should be called. self.assertTrue(after_end_session_hook.called) self.assertTrue(after_end_session_hook.call_count == 1) - - @mock.patch('oidc_provider.views.after_end_session_hook') + + @mock.patch("oidc_provider.views.after_end_session_hook") def test_prompt_view_user_logged_out_after_form_not_allowed(self, after_end_session_hook): - self.assertIn('_auth_user_id', self.client.session) + self.assertIn("_auth_user_id", self.client.session) # We want to POST to /end-session-prompt/?client_id=ABC endpoint. - url_prompt_with_client = self.url_prompt + '?' + urlencode({ - 'client_id': self.oidc_client.client_id, - }) + url_prompt_with_client = ( + self.url_prompt + + "?" + + urlencode( + { + "client_id": self.oidc_client.client_id, + } + ) + ) response = self.client.post(url_prompt_with_client) # No data. # Ensure user is still logged in and redirected to client post logout uri. - self.assertIn('_auth_user_id', self.client.session) + self.assertIn("_auth_user_id", self.client.session) self.assertEqual(response.status_code, 302) - self.assertEqual(response.headers['Location'], self.url_logout) + self.assertEqual(response.headers["Location"], self.url_logout) # End session hook should not be called. self.assertFalse(after_end_session_hook.called) - - @mock.patch('oidc_provider.views.after_end_session_hook') - def test_prompt_view_user_not_logged_out_after_form_not_allowed_no_client(self, after_end_session_hook): - self.assertIn('_auth_user_id', self.client.session) + + @mock.patch("oidc_provider.views.after_end_session_hook") + def test_prompt_view_user_not_logged_out_after_form_not_allowed_no_client( + self, after_end_session_hook + ): + self.assertIn("_auth_user_id", self.client.session) response = self.client.post(self.url_prompt) # No data. # Ensure user is still logged in and 404 NOT FOUND was raised. - self.assertIn('_auth_user_id', self.client.session) + self.assertIn("_auth_user_id", self.client.session) self.assertEqual(response.status_code, 404) # End session hook should not be called. self.assertFalse(after_end_session_hook.called) diff --git a/oidc_provider/urls.py b/oidc_provider/urls.py index 1250a9ba..cdebac8e 100644 --- a/oidc_provider/urls.py +++ b/oidc_provider/urls.py @@ -6,21 +6,29 @@ views, ) -app_name = 'oidc_provider' +app_name = "oidc_provider" urlpatterns = [ - re_path(r'^authorize/?$', views.AuthorizeView.as_view(), name='authorize'), - re_path(r'^token/?$', csrf_exempt(views.TokenView.as_view()), name='token'), - re_path(r'^userinfo/?$', csrf_exempt(views.userinfo), name='userinfo'), - re_path(r'^end-session/?$', views.EndSessionView.as_view(), name='end-session'), - re_path(r'^end-session-prompt/?$', views.EndSessionPromptView.as_view(), name='end-session-prompt'), - re_path(r'^\.well-known/openid-configuration/?$', views.ProviderInfoView.as_view(), - name='provider-info'), - re_path(r'^introspect/?$', views.TokenIntrospectionView.as_view(), name='token-introspection'), - re_path(r'^jwks/?$', views.JwksView.as_view(), name='jwks'), + re_path(r"^authorize/?$", views.AuthorizeView.as_view(), name="authorize"), + re_path(r"^token/?$", csrf_exempt(views.TokenView.as_view()), name="token"), + re_path(r"^userinfo/?$", csrf_exempt(views.userinfo), name="userinfo"), + re_path(r"^end-session/?$", views.EndSessionView.as_view(), name="end-session"), + re_path( + r"^end-session-prompt/?$", views.EndSessionPromptView.as_view(), name="end-session-prompt" + ), + re_path( + r"^\.well-known/openid-configuration/?$", + views.ProviderInfoView.as_view(), + name="provider-info", + ), + re_path(r"^introspect/?$", views.TokenIntrospectionView.as_view(), name="token-introspection"), + re_path(r"^jwks/?$", views.JwksView.as_view(), name="jwks"), ] -if settings.get('OIDC_SESSION_MANAGEMENT_ENABLE'): +if settings.get("OIDC_SESSION_MANAGEMENT_ENABLE"): urlpatterns += [ - re_path(r'^check-session-iframe/?$', views.CheckSessionIframeView.as_view(), - name='check-session-iframe'), + re_path( + r"^check-session-iframe/?$", + views.CheckSessionIframeView.as_view(), + name="check-session-iframe", + ), ] diff --git a/oidc_provider/views.py b/oidc_provider/views.py index 748961f6..b96ce6b1 100644 --- a/oidc_provider/views.py +++ b/oidc_provider/views.py @@ -1,34 +1,35 @@ import hashlib import logging -from django.views.decorators.csrf import csrf_exempt - try: from urllib import urlencode - from urlparse import urlsplit, parse_qs, urlunsplit + + from urlparse import parse_qs, urlsplit, urlunsplit except ImportError: from urllib.parse import urlsplit, parse_qs, urlunsplit, urlencode from Cryptodome.PublicKey import RSA -from django.contrib.auth.views import ( - redirect_to_login, -) +from django.contrib.auth.views import redirect_to_login + try: from django.urls import reverse except ImportError: from django.core.urlresolvers import reverse -from django.db import transaction + from django.contrib.auth import logout as django_user_logout from django.core.cache import cache -from django.http import JsonResponse, HttpResponse, Http404 +from django.db import transaction +from django.http import Http404, HttpResponse, JsonResponse from django.shortcuts import render from django.template.loader import render_to_string from django.utils.decorators import method_decorator from django.views.decorators.clickjacking import xframe_options_exempt +from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods from django.views.generic import TemplateView, View from jwkest import long_to_base64 +from oidc_provider import settings, signals from oidc_provider.compat import get_attr_or_callable from oidc_provider.lib.claims import StandardScopeClaims from oidc_provider.lib.endpoints.authorize import AuthorizeEndpoint @@ -39,29 +40,24 @@ ClientIdError, RedirectUriError, TokenError, + TokenIntrospectionError, UserAuthError, - TokenIntrospectionError) +) from oidc_provider.lib.utils.authorize import strip_prompt_login from oidc_provider.lib.utils.common import ( - redirect, - get_site_url, - get_issuer, cors_allow_any, + get_issuer, + get_site_url, + redirect, ) from oidc_provider.lib.utils.oauth2 import protected_resource_view from oidc_provider.lib.utils.token import client_id_from_id_token -from oidc_provider.models import ( - Client, - RSAKey, - ResponseType) -from oidc_provider import settings -from oidc_provider import signals - +from oidc_provider.models import Client, ResponseType, RSAKey logger = logging.getLogger(__name__) -OIDC_TEMPLATES = settings.get('OIDC_TEMPLATES') -after_end_session_hook = settings.get('OIDC_AFTER_END_SESSION_HOOK', import_str=True) +OIDC_TEMPLATES = settings.get("OIDC_TEMPLATES") +after_end_session_hook = settings.get("OIDC_AFTER_END_SESSION_HOOK", import_str=True) class AuthorizeView(View): @@ -73,96 +69,102 @@ def get(self, request, *args, **kwargs): try: authorize.validate_params() - if get_attr_or_callable(request.user, 'is_authenticated'): + if get_attr_or_callable(request.user, "is_authenticated"): # Check if there's a hook setted. - hook_resp = settings.get('OIDC_AFTER_USERLOGIN_HOOK', import_str=True)( - request=request, user=request.user, - client=authorize.client) + hook_resp = settings.get("OIDC_AFTER_USERLOGIN_HOOK", import_str=True)( + request=request, user=request.user, client=authorize.client + ) if hook_resp: return hook_resp - if 'login' in authorize.params['prompt']: - if 'none' in authorize.params['prompt']: + if "login" in authorize.params["prompt"]: + if "none" in authorize.params["prompt"]: raise AuthorizeError( - authorize.params['redirect_uri'], 'login_required', - authorize.grant_type) + authorize.params["redirect_uri"], "login_required", authorize.grant_type + ) else: django_user_logout(request) next_page = strip_prompt_login(request.get_full_path()) - return redirect_to_login(next_page, settings.get('OIDC_LOGIN_URL')) + return redirect_to_login(next_page, settings.get("OIDC_LOGIN_URL")) - if 'select_account' in authorize.params['prompt']: + if "select_account" in authorize.params["prompt"]: # TODO: see how we can support multiple accounts for the end-user. - if 'none' in authorize.params['prompt']: + if "none" in authorize.params["prompt"]: raise AuthorizeError( - authorize.params['redirect_uri'], 'account_selection_required', - authorize.grant_type) + authorize.params["redirect_uri"], + "account_selection_required", + authorize.grant_type, + ) else: django_user_logout(request) return redirect_to_login( - request.get_full_path(), settings.get('OIDC_LOGIN_URL')) + request.get_full_path(), settings.get("OIDC_LOGIN_URL") + ) - if {'none', 'consent'}.issubset(authorize.params['prompt']): + if {"none", "consent"}.issubset(authorize.params["prompt"]): raise AuthorizeError( - authorize.params['redirect_uri'], 'consent_required', authorize.grant_type) + authorize.params["redirect_uri"], "consent_required", authorize.grant_type + ) if not authorize.client.require_consent and ( - authorize.is_client_allowed_to_skip_consent() and - 'consent' not in authorize.params['prompt']): + authorize.is_client_allowed_to_skip_consent() + and "consent" not in authorize.params["prompt"] + ): return redirect(authorize.create_response_uri()) if authorize.client.reuse_consent: # Check if user previously give consent. if authorize.client_has_user_consent() and ( - authorize.is_client_allowed_to_skip_consent() and - 'consent' not in authorize.params['prompt']): + authorize.is_client_allowed_to_skip_consent() + and "consent" not in authorize.params["prompt"] + ): return redirect(authorize.create_response_uri()) - if 'none' in authorize.params['prompt']: + if "none" in authorize.params["prompt"]: raise AuthorizeError( - authorize.params['redirect_uri'], 'consent_required', authorize.grant_type) + authorize.params["redirect_uri"], "consent_required", authorize.grant_type + ) # Generate hidden inputs for the form. context = { - 'params': authorize.params, + "params": authorize.params, } - hidden_inputs = render_to_string('oidc_provider/hidden_inputs.html', context) + hidden_inputs = render_to_string("oidc_provider/hidden_inputs.html", context) # Remove `openid` from scope list # since we don't need to print it. - if 'openid' in authorize.params['scope']: - authorize.params['scope'].remove('openid') + if "openid" in authorize.params["scope"]: + authorize.params["scope"].remove("openid") context = { - 'client': authorize.client, - 'hidden_inputs': hidden_inputs, - 'params': authorize.params, - 'scopes': authorize.get_scopes_information(), + "client": authorize.client, + "hidden_inputs": hidden_inputs, + "params": authorize.params, + "scopes": authorize.get_scopes_information(), } - return render(request, OIDC_TEMPLATES['authorize'], context) + return render(request, OIDC_TEMPLATES["authorize"], context) else: - if 'none' in authorize.params['prompt']: + if "none" in authorize.params["prompt"]: raise AuthorizeError( - authorize.params['redirect_uri'], 'login_required', authorize.grant_type) - if 'login' in authorize.params['prompt']: + authorize.params["redirect_uri"], "login_required", authorize.grant_type + ) + if "login" in authorize.params["prompt"]: next_page = strip_prompt_login(request.get_full_path()) - return redirect_to_login(next_page, settings.get('OIDC_LOGIN_URL')) + return redirect_to_login(next_page, settings.get("OIDC_LOGIN_URL")) - return redirect_to_login(request.get_full_path(), settings.get('OIDC_LOGIN_URL')) + return redirect_to_login(request.get_full_path(), settings.get("OIDC_LOGIN_URL")) except (ClientIdError, RedirectUriError) as error: context = { - 'error': error.error, - 'description': error.description, + "error": error.error, + "description": error.description, } - return render(request, OIDC_TEMPLATES['error'], context) + return render(request, OIDC_TEMPLATES["error"], context) except AuthorizeError as error: - uri = error.create_uri( - authorize.params['redirect_uri'], - authorize.params['state']) + uri = error.create_uri(authorize.params["redirect_uri"], authorize.params["state"]) return redirect(uri) @@ -172,18 +174,24 @@ def post(self, request, *args, **kwargs): try: authorize.validate_params() - if not request.POST.get('allow'): + if not request.POST.get("allow"): signals.user_decline_consent.send( - self.__class__, user=request.user, - client=authorize.client, scope=authorize.params['scope']) + self.__class__, + user=request.user, + client=authorize.client, + scope=authorize.params["scope"], + ) - raise AuthorizeError(authorize.params['redirect_uri'], - 'access_denied', - authorize.grant_type) + raise AuthorizeError( + authorize.params["redirect_uri"], "access_denied", authorize.grant_type + ) signals.user_accept_consent.send( - self.__class__, user=request.user, client=authorize.client, - scope=authorize.params['scope']) + self.__class__, + user=request.user, + client=authorize.client, + scope=authorize.params["scope"], + ) # Save the user consent given to the client. authorize.set_client_user_consent() @@ -193,9 +201,7 @@ def post(self, request, *args, **kwargs): return redirect(uri) except AuthorizeError as error: - uri = error.create_uri( - authorize.params['redirect_uri'], - authorize.params['state']) + uri = error.create_uri(authorize.params["redirect_uri"], authorize.params["state"]) return redirect(uri) @@ -219,8 +225,8 @@ def post(self, request, *args, **kwargs): return self.token_endpoint_class.response(error.create_dict(), status=403) -@require_http_methods(['GET', 'POST', 'OPTIONS']) -@protected_resource_view(['openid']) +@require_http_methods(["GET", "POST", "OPTIONS"]) +@protected_resource_view(["openid"]) def userinfo(request, *args, **kwargs): """ Create a dictionary with all the requested claims about the End-User. @@ -230,25 +236,25 @@ def userinfo(request, *args, **kwargs): """ def set_headers(response): - response['Cache-Control'] = 'no-store' - response['Pragma'] = 'no-cache' + response["Cache-Control"] = "no-store" + response["Pragma"] = "no-cache" cors_allow_any(request, response) return response - if request.method == 'OPTIONS': + if request.method == "OPTIONS": return set_headers(HttpResponse()) - token = kwargs['token'] + token = kwargs["token"] dic = { - 'sub': token.id_token.get('sub'), + "sub": token.id_token.get("sub"), } standard_claims = StandardScopeClaims(token) dic.update(standard_claims.create_response_dic()) - if settings.get('OIDC_EXTRA_SCOPE_CLAIMS'): - extra_claims = settings.get('OIDC_EXTRA_SCOPE_CLAIMS', import_str=True)(token) + if settings.get("OIDC_EXTRA_SCOPE_CLAIMS"): + extra_claims = settings.get("OIDC_EXTRA_SCOPE_CLAIMS", import_str=True)(token) dic.update(extra_claims.create_response_dic()) success_response = JsonResponse(dic, status=200) @@ -264,35 +270,35 @@ class ProviderInfoView(View): def types_supported(self): if self._types_supported is None: self._types_supported = [ - response_type.value for response_type in ResponseType.objects.all()] + response_type.value for response_type in ResponseType.objects.all() + ] return self._types_supported def _build_response_dict(self, request): dic = dict() site_url = get_site_url(request=request) - dic['issuer'] = get_issuer(site_url=site_url, request=request) + dic["issuer"] = get_issuer(site_url=site_url, request=request) - dic['authorization_endpoint'] = site_url + reverse('oidc_provider:authorize') - dic['token_endpoint'] = site_url + reverse('oidc_provider:token') - dic['userinfo_endpoint'] = site_url + reverse('oidc_provider:userinfo') - dic['end_session_endpoint'] = site_url + reverse('oidc_provider:end-session') - dic['introspection_endpoint'] = site_url + reverse('oidc_provider:token-introspection') + dic["authorization_endpoint"] = site_url + reverse("oidc_provider:authorize") + dic["token_endpoint"] = site_url + reverse("oidc_provider:token") + dic["userinfo_endpoint"] = site_url + reverse("oidc_provider:userinfo") + dic["end_session_endpoint"] = site_url + reverse("oidc_provider:end-session") + dic["introspection_endpoint"] = site_url + reverse("oidc_provider:token-introspection") - dic['response_types_supported'] = self.types_supported + dic["response_types_supported"] = self.types_supported - dic['jwks_uri'] = site_url + reverse('oidc_provider:jwks') + dic["jwks_uri"] = site_url + reverse("oidc_provider:jwks") - dic['id_token_signing_alg_values_supported'] = ['HS256', 'RS256'] + dic["id_token_signing_alg_values_supported"] = ["HS256", "RS256"] # See: http://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes - dic['subject_types_supported'] = ['public'] + dic["subject_types_supported"] = ["public"] - dic['token_endpoint_auth_methods_supported'] = ['client_secret_post', - 'client_secret_basic'] + dic["token_endpoint_auth_methods_supported"] = ["client_secret_post", "client_secret_basic"] - if settings.get('OIDC_SESSION_MANAGEMENT_ENABLE'): - dic['check_session_iframe'] = site_url + reverse('oidc_provider:check-session-iframe') + if settings.get("OIDC_SESSION_MANAGEMENT_ENABLE"): + dic["check_session_iframe"] = site_url + reverse("oidc_provider:check-session-iframe") return dic @@ -300,24 +306,24 @@ def _build_cache_key(self, request): """ Cache key will be a combination of site URL and types supported by the provider. """ - key_data = get_site_url(request=request) + ''.join(self.types_supported) - key_hash = hashlib.md5(key_data.encode('utf-8')).hexdigest() - return f'oidc_discovery_{key_hash}' + key_data = get_site_url(request=request) + "".join(self.types_supported) + key_hash = hashlib.md5(key_data.encode("utf-8")).hexdigest() + return f"oidc_discovery_{key_hash}" def get(self, request): - if settings.get('OIDC_DISCOVERY_CACHE_ENABLE'): + if settings.get("OIDC_DISCOVERY_CACHE_ENABLE"): cache_key = self._build_cache_key(request) cached_dict = cache.get(cache_key) if cached_dict: response_dict = cached_dict else: response_dict = self._build_response_dict(request) - cache.set(cache_key, response_dict, settings.get('OIDC_DISCOVERY_CACHE_EXPIRE')) + cache.set(cache_key, response_dict, settings.get("OIDC_DISCOVERY_CACHE_EXPIRE")) else: response_dict = self._build_response_dict(request) response = JsonResponse(response_dict) - response['Access-Control-Allow-Origin'] = '*' + response["Access-Control-Allow-Origin"] = "*" return response @@ -328,26 +334,36 @@ def get(self, request, *args, **kwargs): for rsakey in RSAKey.objects.all(): public_key = RSA.importKey(rsakey.key).publickey() - dic['keys'].append({ - 'kty': 'RSA', - 'alg': 'RS256', - 'use': 'sig', - 'kid': rsakey.kid, - 'n': long_to_base64(public_key.n), - 'e': long_to_base64(public_key.e), - }) + dic["keys"].append( + { + "kty": "RSA", + "alg": "RS256", + "use": "sig", + "kid": rsakey.kid, + "n": long_to_base64(public_key.n), + "e": long_to_base64(public_key.e), + } + ) response = JsonResponse(dic) - response['Access-Control-Allow-Origin'] = '*' + response["Access-Control-Allow-Origin"] = "*" return response class EndSessionView(View): - http_method_names = ['get', 'post'] + http_method_names = ["get", "post"] @classmethod - def logout_user(cls, request, id_token_hint=None, post_logout_redirect_uri=None, state=None, client=None, next_page=None): + def logout_user( + cls, + request, + id_token_hint=None, + post_logout_redirect_uri=None, + state=None, + client=None, + next_page=None, + ): after_end_session_hook( request=request, id_token=id_token_hint, @@ -360,9 +376,11 @@ def logout_user(cls, request, id_token_hint=None, post_logout_redirect_uri=None, @method_decorator(csrf_exempt) def dispatch(self, request, *args, **kwargs): - self.id_token_hint = request.POST.get('id_token_hint') or request.GET.get('id_token_hint') - self.post_logout_redirect_uri = request.POST.get('post_logout_redirect_uri') or request.GET.get('post_logout_redirect_uri') - self.state = request.POST.get('state') or request.GET.get('state') + self.id_token_hint = request.POST.get("id_token_hint") or request.GET.get("id_token_hint") + self.post_logout_redirect_uri = request.POST.get( + "post_logout_redirect_uri" + ) or request.GET.get("post_logout_redirect_uri") + self.state = request.POST.get("state") or request.GET.get("state") self.client = None if self.id_token_hint: @@ -371,46 +389,65 @@ def dispatch(self, request, *args, **kwargs): self.client = Client.objects.get(client_id=client_id) if self.post_logout_redirect_uri: - if not self.post_logout_redirect_uri in self.client.post_logout_redirect_uris: - return redirect(reverse('oidc_provider:end-session-prompt') + '?' + urlencode({ - 'client_id': client_id, - })) + if self.post_logout_redirect_uri not in self.client.post_logout_redirect_uris: + return redirect( + reverse("oidc_provider:end-session-prompt") + + "?" + + urlencode( + { + "client_id": client_id, + } + ) + ) elif self.client.post_logout_redirect_uris: self.post_logout_redirect_uri = self.client.post_logout_redirect_uris[0] else: - self.logout_user(request, self.id_token_hint, self.post_logout_redirect_uri, self.state, self.client) + self.logout_user( + request, + self.id_token_hint, + self.post_logout_redirect_uri, + self.state, + self.client, + ) raise Http404("You have successfully logged out!") - + if self.state: uri = urlsplit(self.post_logout_redirect_uri) query_params = parse_qs(uri.query) - query_params['state'] = self.state + query_params["state"] = self.state uri = uri._replace(query=urlencode(query_params, doseq=True)) next_page = urlunsplit(uri) else: next_page = self.post_logout_redirect_uri - self.logout_user(request, self.id_token_hint, self.post_logout_redirect_uri, self.state, self.client, next_page) + self.logout_user( + request, + self.id_token_hint, + self.post_logout_redirect_uri, + self.state, + self.client, + next_page, + ) return redirect(next_page) except Client.DoesNotExist: pass - return redirect(reverse('oidc_provider:end-session-prompt')) + return redirect(reverse("oidc_provider:end-session-prompt")) class EndSessionPromptView(TemplateView): - http_method_names = ['get', 'post'] - template_name = 'oidc_provider/end_session_prompt.html' + http_method_names = ["get", "post"] + template_name = "oidc_provider/end_session_prompt.html" def dispatch(self, request, *args, **kwargs): - self.client_id = request.GET.get('client_id') + self.client_id = request.GET.get("client_id") self.client = Client.objects.filter(client_id=self.client_id).first() return super(EndSessionPromptView, self).dispatch(request, *args, **kwargs) def get(self, request, *args, **kwargs): - # If user is not authenticated, we should redirect to client post logout uri if exists, + # If user is not authenticated, we should redirect to client post logout uri if exists, # otherwhise, just raise a not found error. - if not get_attr_or_callable(request.user, 'is_authenticated'): + if not get_attr_or_callable(request.user, "is_authenticated"): if self.client and self.client.post_logout_redirect_uris: return redirect(self.client.post_logout_redirect_uris[0]) else: @@ -420,20 +457,26 @@ def get(self, request, *args, **kwargs): def get_context_data(self, **kwargs): context = super(EndSessionPromptView, self).get_context_data(**kwargs) - context['client'] = self.client + context["client"] = self.client - end_session_prompt_url = reverse('oidc_provider:end-session-prompt') + end_session_prompt_url = reverse("oidc_provider:end-session-prompt") if self.client_id: - end_session_prompt_url += '?' + urlencode({ - 'client_id': self.client_id, - }) - context['end_session_prompt_url'] = end_session_prompt_url + end_session_prompt_url += "?" + urlencode( + { + "client_id": self.client_id, + } + ) + context["end_session_prompt_url"] = end_session_prompt_url return context - + def post(self, request, *args, **kwargs): - allowed = request.POST.get('allow') - next_page = self.client.post_logout_redirect_uris[0] if self.client and self.client.post_logout_redirect_uris else None + allowed = request.POST.get("allow") + next_page = ( + self.client.post_logout_redirect_uris[0] + if self.client and self.client.post_logout_redirect_uris + else None + ) # Only logout users if they allow it. if allowed: @@ -442,8 +485,9 @@ def post(self, request, *args, **kwargs): # Redirect to post logout uri if client is present. if next_page: return redirect(next_page) - raise Http404("You have successfully logged out!" if allowed else "You can close this window.") - + raise Http404( + "You have successfully logged out!" if allowed else "You can close this window." + ) class CheckSessionIframeView(View): @@ -452,7 +496,7 @@ def dispatch(self, request, *args, **kwargs): return super(CheckSessionIframeView, self).dispatch(request, *args, **kwargs) def get(self, request, *args, **kwargs): - return render(request, 'oidc_provider/check_session_iframe.html', kwargs) + return render(request, "oidc_provider/check_session_iframe.html", kwargs) class TokenIntrospectionView(View): @@ -470,4 +514,4 @@ def post(self, request, *args, **kwargs): dic = introspection.create_response_dic() return self.token_instrospection_endpoint_class.response(dic) except TokenIntrospectionError: - return self.token_instrospection_endpoint_class.response({'active': False}) + return self.token_instrospection_endpoint_class.response({"active": False}) From c9201d776da63eed78a7b74c744cbff89a981d8d Mon Sep 17 00:00:00 2001 From: juanifioren Date: Tue, 3 Dec 2024 23:46:57 -0300 Subject: [PATCH 09/54] Work on end_session_endpoint --- .../oidc_provider/end_session_completed.html | 3 ++ .../oidc_provider/end_session_failed.html | 3 ++ .../tests/cases/test_end_session_endpoint.py | 34 +++++++++++-------- oidc_provider/views.py | 17 +++++++--- 4 files changed, 37 insertions(+), 20 deletions(-) create mode 100644 oidc_provider/templates/oidc_provider/end_session_completed.html create mode 100644 oidc_provider/templates/oidc_provider/end_session_failed.html diff --git a/oidc_provider/templates/oidc_provider/end_session_completed.html b/oidc_provider/templates/oidc_provider/end_session_completed.html new file mode 100644 index 00000000..c6cc5ba5 --- /dev/null +++ b/oidc_provider/templates/oidc_provider/end_session_completed.html @@ -0,0 +1,3 @@ +

End Session Completed

+ +

You've been logged out.

\ No newline at end of file diff --git a/oidc_provider/templates/oidc_provider/end_session_failed.html b/oidc_provider/templates/oidc_provider/end_session_failed.html new file mode 100644 index 00000000..a40000d3 --- /dev/null +++ b/oidc_provider/templates/oidc_provider/end_session_failed.html @@ -0,0 +1,3 @@ +

End Session Failed

+ +

You can now close this window.

\ No newline at end of file diff --git a/oidc_provider/tests/cases/test_end_session_endpoint.py b/oidc_provider/tests/cases/test_end_session_endpoint.py index 7bb6562b..299cbbea 100644 --- a/oidc_provider/tests/cases/test_end_session_endpoint.py +++ b/oidc_provider/tests/cases/test_end_session_endpoint.py @@ -9,18 +9,12 @@ from django.urls import reverse except ImportError: from django.core.urlresolvers import reverse -from django.test import TestCase -from oidc_provider.lib.utils.token import ( - create_token, - create_id_token, - encode_id_token, -) -from oidc_provider.tests.app.utils import ( - create_fake_client, - create_fake_user, -) import mock +from django.test import TestCase + +from oidc_provider.lib.utils.token import create_id_token, create_token, encode_id_token +from oidc_provider.tests.app.utils import create_fake_client, create_fake_user class EndSessionTestCase(TestCase): @@ -126,12 +120,17 @@ def test_prompt_view_redirecting_to_client_post_logout_since_user_unauthenticate self.assertEqual(response.status_code, 302) self.assertEqual(response.headers["Location"], self.url_logout) - def test_prompt_view_raising_404_since_user_unauthenticated_and_no_client(self): + def test_prompt_view_show_completed_since_user_unauthenticated_and_no_client(self): self.client.logout() response = self.client.get(self.url_prompt) # Since user is unauthenticated and no client information is present, we just show - # not found page. - self.assertEqual(response.status_code, 404) + # a page explaining session is closed already. + self.assertContains( + response, + "You've been logged out.", + status_code=200, + html=True, + ) def test_prompt_view_displaying_logout_decision_form_to_user(self): query_params = { @@ -203,13 +202,18 @@ def test_prompt_view_user_logged_out_after_form_not_allowed(self, after_end_sess self.assertFalse(after_end_session_hook.called) @mock.patch("oidc_provider.views.after_end_session_hook") - def test_prompt_view_user_not_logged_out_after_form_not_allowed_no_client( + def test_prompt_view_user_still_logged_in_after_form_not_allowed_no_client( self, after_end_session_hook ): self.assertIn("_auth_user_id", self.client.session) response = self.client.post(self.url_prompt) # No data. # Ensure user is still logged in and 404 NOT FOUND was raised. self.assertIn("_auth_user_id", self.client.session) - self.assertEqual(response.status_code, 404) + self.assertContains( + response, + "You can now close this window.", + status_code=200, + html=True, + ) # End session hook should not be called. self.assertFalse(after_end_session_hook.called) diff --git a/oidc_provider/views.py b/oidc_provider/views.py index b96ce6b1..2ae078a8 100644 --- a/oidc_provider/views.py +++ b/oidc_provider/views.py @@ -409,7 +409,9 @@ def dispatch(self, request, *args, **kwargs): self.state, self.client, ) - raise Http404("You have successfully logged out!") + return render( + request, "oidc_provider/end_session_completed.html", {"client": self.client} + ) if self.state: uri = urlsplit(self.post_logout_redirect_uri) @@ -451,7 +453,9 @@ def get(self, request, *args, **kwargs): if self.client and self.client.post_logout_redirect_uris: return redirect(self.client.post_logout_redirect_uris[0]) else: - raise Http404("You are already logged out!") + return render( + request, "oidc_provider/end_session_completed.html", {"client": self.client} + ) return super(EndSessionPromptView, self).get(request, *args, **kwargs) @@ -485,9 +489,12 @@ def post(self, request, *args, **kwargs): # Redirect to post logout uri if client is present. if next_page: return redirect(next_page) - raise Http404( - "You have successfully logged out!" if allowed else "You can close this window." - ) + elif allowed: + return render( + request, "oidc_provider/end_session_completed.html", {"client": self.client} + ) + else: + return render(request, "oidc_provider/end_session_failed.html", {"client": self.client}) class CheckSessionIframeView(View): From 981b779d5d6fcd413fe24fd330cc27b0a1f3f923 Mon Sep 17 00:00:00 2001 From: juanifioren Date: Tue, 3 Dec 2024 23:57:51 -0300 Subject: [PATCH 10/54] Work on end_session_endpoint --- docs/sections/sessionmanagement.rst | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/sections/sessionmanagement.rst b/docs/sections/sessionmanagement.rst index 3ae7e9ac..4e1523a9 100644 --- a/docs/sections/sessionmanagement.rst +++ b/docs/sections/sessionmanagement.rst @@ -42,7 +42,7 @@ Parameters that are passed as query parameters in the logout request: Example redirect:: - http://localhost:8000/end-session/?id_token_hint=eyJhbGciOiJSUzI1NiIsImtpZCI6ImQwM...&post_logout_redirect_uri=http://rp.example.com/logged-out/&state=c91c03ea6c46a86 + http://localhost:8000/end-session/?id_token_hint=eyJhbGciOiJSUzI1NiIsImtpZCI6ImQwM...&post_logout_redirect_uri=http%3A%2F%2Frp.example.com%2Flogged-out%2F&state=c91c03ea6c46a86 **Logout consent prompt** @@ -55,6 +55,14 @@ We enforce this behavior by displaying a logout consent prompt if it detects any If the user confirms the logout request, we continue the logout flow. To modify the logout consent template create your own ``oidc_provider/end_session_prompt.html``. +**Other scenarios** + +In some cases, there may be no valid redirect URI for the user after logging out (e.g., the OP could not find a post-logout URI). If the user ends up being logged out, the system will render the ``oidc_provider/end_session_completed.html`` template. + +On the other hand, if the session remains active for any reason, the ``oidc_provider/end_session_failed.html`` template will be used. + +Both templates will receive the ``{{ client }}`` variable in their context. + Example RP iframe ================= From 6a250a8a6a374cf09321b7c4a08c2b1d4e437326 Mon Sep 17 00:00:00 2001 From: juanifioren Date: Wed, 4 Dec 2024 10:08:16 -0300 Subject: [PATCH 11/54] Work on end_session_endpoint --- oidc_provider/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oidc_provider/views.py b/oidc_provider/views.py index 2ae078a8..45798e59 100644 --- a/oidc_provider/views.py +++ b/oidc_provider/views.py @@ -19,7 +19,7 @@ from django.contrib.auth import logout as django_user_logout from django.core.cache import cache from django.db import transaction -from django.http import Http404, HttpResponse, JsonResponse +from django.http import HttpResponse, JsonResponse from django.shortcuts import render from django.template.loader import render_to_string from django.utils.decorators import method_decorator From 98b981039f0830acf18097228483a5e5d7792196 Mon Sep 17 00:00:00 2001 From: juanifioren Date: Wed, 4 Dec 2024 16:12:23 -0300 Subject: [PATCH 12/54] Work on end_session_endpoint --- .vscode/settings.json | 2 +- example/app/settings.py | 75 ++++++++++--------- .../templates/oidc_provider/authorize.html | 6 +- 3 files changed, 42 insertions(+), 41 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 171bc2c7..ab96ebe3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,7 @@ { "[python]": { "editor.codeActionsOnSave": { - "source.organizeImports": true + "source.sortImports": "explicit" } }, "python.formatting.provider": "black", diff --git a/example/app/settings.py b/example/app/settings.py index 1c3e9722..4332fd43 100644 --- a/example/app/settings.py +++ b/example/app/settings.py @@ -1,74 +1,75 @@ # Build paths inside the project like this: os.path.join(BASE_DIR, ...) import os + BASE_DIR = os.path.dirname(os.path.dirname(__file__)) -SECRET_KEY = 'c14d549c574e4d8cf162404ef0b04598' +SECRET_KEY = "c14d549c574e4d8cf162404ef0b04598" DEBUG = True TEMPLATE_DEBUG = False -ALLOWED_HOSTS = ['*'] +ALLOWED_HOSTS = ["*"] # Application definition INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'app', - 'oidc_provider', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "app", + "oidc_provider", ] MIDDLEWARE_CLASSES = [ - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', - 'oidc_provider.middleware.SessionManagementMiddleware', + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "oidc_provider.middleware.SessionManagementMiddleware", ] MIDDLEWARE = MIDDLEWARE_CLASSES TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], }, }, ] -ROOT_URLCONF = 'app.urls' +ROOT_URLCONF = "app.urls" -WSGI_APPLICATION = 'app.wsgi.application' +WSGI_APPLICATION = "app.wsgi.application" # Database DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': os.path.join(BASE_DIR, 'DATABASE.sqlite3'), + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": os.path.join(BASE_DIR, "DATABASE.sqlite3"), } } # Internationalization -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" USE_I18N = True @@ -78,14 +79,14 @@ # Static files (CSS, JavaScript, Images) -STATIC_URL = '/static/' -STATIC_ROOT = os.path.join(BASE_DIR, 'static/') +STATIC_URL = "/static/" +STATIC_ROOT = os.path.join(BASE_DIR, "static/") # Custom settings -LOGIN_REDIRECT_URL = '/' +LOGIN_REDIRECT_URL = "/" # OIDC Provider settings -SITE_URL = 'http://localhost:8000' +SITE_URL = "http://localhost:8000" OIDC_SESSION_MANAGEMENT_ENABLE = True diff --git a/example/app/templates/oidc_provider/authorize.html b/example/app/templates/oidc_provider/authorize.html index 001896ad..c5fae6ec 100644 --- a/example/app/templates/oidc_provider/authorize.html +++ b/example/app/templates/oidc_provider/authorize.html @@ -1,5 +1,5 @@ {% extends 'base.html' %} -{% load i18n staticfiles %} +{% load i18n static %} {% block content %} @@ -16,10 +16,10 @@

{% trans 'Request for Permission' %}

{% endfor %}
- + -{% endblock %} +{% endblock %} \ No newline at end of file From b7449923174f4fad14d628e881b1c713e2e9636e Mon Sep 17 00:00:00 2001 From: juanifioren Date: Wed, 4 Dec 2024 22:56:33 -0300 Subject: [PATCH 13/54] Fix create_id_token with extra scope claims + add ruff as formatter. --- .vscode/settings.json | 19 +- docs/sections/contribute.rst | 4 +- oidc_provider/lib/endpoints/authorize.py | 263 ++++++++++++----------- oidc_provider/lib/endpoints/token.py | 223 +++++++++---------- oidc_provider/lib/utils/token.py | 63 +++--- oidc_provider/tests/app/utils.py | 96 +++++---- oidc_provider/tests/cases/test_utils.py | 102 ++++++--- pyproject.toml | 5 + 8 files changed, 418 insertions(+), 357 deletions(-) create mode 100644 pyproject.toml diff --git a/.vscode/settings.json b/.vscode/settings.json index ab96ebe3..e64211ad 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,17 +1,10 @@ { "[python]": { + "editor.formatOnSave": true, "editor.codeActionsOnSave": { - "source.sortImports": "explicit" - } - }, - "python.formatting.provider": "black", - "editor.formatOnSave": true, - "black-formatter.args": [ - "--line-length=100", - "--preview", - ], - "isort.args": [ - "--profile", - "black" - ], + "source.fixAll": "explicit", + "source.organizeImports": "explicit" + }, + "editor.defaultFormatter": "charliermarsh.ruff" + } } \ No newline at end of file diff --git a/docs/sections/contribute.rst b/docs/sections/contribute.rst index 8d29a332..121cc883 100644 --- a/docs/sections/contribute.rst +++ b/docs/sections/contribute.rst @@ -24,8 +24,8 @@ Use `tox `_ for running tests in each of the e # Run with Python 3.11 and Django 4.2. $ tox -e py311-django42 - # Run single test file on specific environment. - $ tox -e py311-django42 -- tests/cases/test_authorize_endpoint.py + # Run a single test method. + $ tox -e py311-django42 -- tests/cases/test_authorize_endpoint.py::TestClass::test_some_method We use `Github Actions `_ to automatically test every commit to the project. diff --git a/oidc_provider/lib/endpoints/authorize.py b/oidc_provider/lib/endpoints/authorize.py index 4728158e..c14d7689 100644 --- a/oidc_provider/lib/endpoints/authorize.py +++ b/oidc_provider/lib/endpoints/authorize.py @@ -1,42 +1,41 @@ -from datetime import timedelta -from hashlib import ( - md5, - sha256, -) import logging +from datetime import timedelta +from hashlib import md5 +from hashlib import sha256 + try: from urllib import urlencode - from urlparse import urlsplit, parse_qs, urlunsplit + + from urlparse import parse_qs + from urlparse import urlsplit + from urlparse import urlunsplit except ImportError: - from urllib.parse import urlsplit, parse_qs, urlunsplit, urlencode + from urllib.parse import parse_qs + from urllib.parse import urlencode + from urllib.parse import urlsplit + from urllib.parse import urlunsplit from uuid import uuid4 from django.utils import timezone -from oidc_provider.lib.claims import StandardScopeClaims -from oidc_provider.lib.errors import ( - AuthorizeError, - ClientIdError, - RedirectUriError, -) -from oidc_provider.lib.utils.token import ( - create_code, - create_id_token, - create_token, - encode_id_token, -) -from oidc_provider.models import ( - Client, - UserConsent, -) from oidc_provider import settings +from oidc_provider.lib.claims import StandardScopeClaims +from oidc_provider.lib.errors import AuthorizeError +from oidc_provider.lib.errors import ClientIdError +from oidc_provider.lib.errors import RedirectUriError from oidc_provider.lib.utils.common import get_browser_state_or_default +from oidc_provider.lib.utils.token import create_code +from oidc_provider.lib.utils.token import create_id_token +from oidc_provider.lib.utils.token import create_token +from oidc_provider.lib.utils.token import encode_id_token +from oidc_provider.models import Client +from oidc_provider.models import UserConsent logger = logging.getLogger(__name__) class AuthorizeEndpoint(object): - _allowed_prompt_params = {'none', 'login', 'consent', 'select_account'} + _allowed_prompt_params = {"none", "login", "consent", "select_account"} client_class = Client def __init__(self, request): @@ -46,18 +45,17 @@ def __init__(self, request): self._extract_params() # Determine which flow to use. - if self.params['response_type'] in ['code']: - self.grant_type = 'authorization_code' - elif self.params['response_type'] in ['id_token', 'id_token token', 'token']: - self.grant_type = 'implicit' - elif self.params['response_type'] in [ - 'code token', 'code id_token', 'code id_token token']: - self.grant_type = 'hybrid' + if self.params["response_type"] in ["code"]: + self.grant_type = "authorization_code" + elif self.params["response_type"] in ["id_token", "id_token token", "token"]: + self.grant_type = "implicit" + elif self.params["response_type"] in ["code token", "code id_token", "code id_token token"]: + self.grant_type = "hybrid" else: self.grant_type = None # Determine if it's an OpenID Authentication request (or OAuth2). - self.is_authentication = 'openid' in self.params['scope'] + self.is_authentication = "openid" in self.params["scope"] def _extract_params(self): """ @@ -68,73 +66,79 @@ def _extract_params(self): """ # Because in this endpoint we handle both GET # and POST request. - query_dict = (self.request.POST if self.request.method == 'POST' - else self.request.GET) + query_dict = self.request.POST if self.request.method == "POST" else self.request.GET - self.params['client_id'] = query_dict.get('client_id', '') - self.params['redirect_uri'] = query_dict.get('redirect_uri', '') - self.params['response_type'] = query_dict.get('response_type', '') - self.params['scope'] = query_dict.get('scope', '').split() - self.params['state'] = query_dict.get('state', '') - self.params['nonce'] = query_dict.get('nonce', '') + self.params["client_id"] = query_dict.get("client_id", "") + self.params["redirect_uri"] = query_dict.get("redirect_uri", "") + self.params["response_type"] = query_dict.get("response_type", "") + self.params["scope"] = query_dict.get("scope", "").split() + self.params["state"] = query_dict.get("state", "") + self.params["nonce"] = query_dict.get("nonce", "") - self.params['prompt'] = self._allowed_prompt_params.intersection( - set(query_dict.get('prompt', '').split())) + self.params["prompt"] = self._allowed_prompt_params.intersection( + set(query_dict.get("prompt", "").split()) + ) - self.params['code_challenge'] = query_dict.get('code_challenge', '') - self.params['code_challenge_method'] = query_dict.get('code_challenge_method', '') + self.params["code_challenge"] = query_dict.get("code_challenge", "") + self.params["code_challenge_method"] = query_dict.get("code_challenge_method", "") def validate_params(self): # Client validation. try: - self.client = self.client_class.objects.get(client_id=self.params['client_id']) + self.client = self.client_class.objects.get(client_id=self.params["client_id"]) except Client.DoesNotExist: - logger.debug('[Authorize] Invalid client identifier: %s', self.params['client_id']) + logger.debug("[Authorize] Invalid client identifier: %s", self.params["client_id"]) raise ClientIdError() # Redirect URI validation. - if self.is_authentication and not self.params['redirect_uri']: - logger.debug('[Authorize] Missing redirect uri.') + if self.is_authentication and not self.params["redirect_uri"]: + logger.debug("[Authorize] Missing redirect uri.") raise RedirectUriError() - if not (self.params['redirect_uri'] in self.client.redirect_uris): - logger.debug('[Authorize] Invalid redirect uri: %s', self.params['redirect_uri']) + if self.params["redirect_uri"] not in self.client.redirect_uris: + logger.debug("[Authorize] Invalid redirect uri: %s", self.params["redirect_uri"]) raise RedirectUriError() # Grant type validation. if not self.grant_type: - logger.debug('[Authorize] Invalid response type: %s', self.params['response_type']) + logger.debug("[Authorize] Invalid response type: %s", self.params["response_type"]) raise AuthorizeError( - self.params['redirect_uri'], 'unsupported_response_type', self.grant_type) + self.params["redirect_uri"], "unsupported_response_type", self.grant_type + ) - if (not self.is_authentication and (self.grant_type == 'hybrid' or - self.params['response_type'] in ['id_token', 'id_token token'])): - logger.debug('[Authorize] Missing openid scope.') - raise AuthorizeError(self.params['redirect_uri'], 'invalid_scope', self.grant_type) + if not self.is_authentication and ( + self.grant_type == "hybrid" + or self.params["response_type"] in ["id_token", "id_token token"] + ): + logger.debug("[Authorize] Missing openid scope.") + raise AuthorizeError(self.params["redirect_uri"], "invalid_scope", self.grant_type) # Nonce parameter validation. - if self.is_authentication and self.grant_type == 'implicit' and not self.params['nonce']: - raise AuthorizeError(self.params['redirect_uri'], 'invalid_request', self.grant_type) + if self.is_authentication and self.grant_type == "implicit" and not self.params["nonce"]: + raise AuthorizeError(self.params["redirect_uri"], "invalid_request", self.grant_type) # Response type parameter validation. - if self.is_authentication \ - and self.params['response_type'] not in self.client.response_type_values(): - raise AuthorizeError(self.params['redirect_uri'], 'invalid_request', self.grant_type) + if ( + self.is_authentication + and self.params["response_type"] not in self.client.response_type_values() + ): + raise AuthorizeError(self.params["redirect_uri"], "invalid_request", self.grant_type) # PKCE validation of the transformation method. - if self.params['code_challenge']: - if not (self.params['code_challenge_method'] in ['plain', 'S256']): + if self.params["code_challenge"]: + if self.params["code_challenge_method"] not in ["plain", "S256"]: raise AuthorizeError( - self.params['redirect_uri'], 'invalid_request', self.grant_type) + self.params["redirect_uri"], "invalid_request", self.grant_type + ) def create_code(self): code = create_code( user=self.request.user, client=self.client, - scope=self.params['scope'], - nonce=self.params['nonce'], + scope=self.params["scope"], + nonce=self.params["nonce"], is_authentication=self.is_authentication, - code_challenge=self.params['code_challenge'], - code_challenge_method=self.params['code_challenge_method'], + code_challenge=self.params["code_challenge"], + code_challenge_method=self.params["code_challenge_method"], ) return code @@ -143,50 +147,58 @@ def create_token(self): token = create_token( user=self.request.user, client=self.client, - scope=self.params['scope'], + scope=self.params["scope"], ) return token def create_response_uri(self): - uri = urlsplit(self.params['redirect_uri']) + uri = urlsplit(self.params["redirect_uri"]) query_params = parse_qs(uri.query) query_fragment = {} try: - if self.grant_type in ['authorization_code', 'hybrid']: + if self.grant_type in ["authorization_code", "hybrid"]: code = self.create_code() code.save() - if self.grant_type == 'authorization_code': - query_params['code'] = code.code - query_params['state'] = self.params['state'] if self.params['state'] else '' - elif self.grant_type in ['implicit', 'hybrid']: + if self.grant_type == "authorization_code": + query_params["code"] = code.code + query_params["state"] = self.params["state"] if self.params["state"] else "" + elif self.grant_type in ["implicit", "hybrid"]: token = self.create_token() # Check if response_type must include access_token in the response. - if (self.params['response_type'] in - ['id_token token', 'token', 'code token', 'code id_token token']): - query_fragment['access_token'] = token.access_token + if self.params["response_type"] in [ + "id_token token", + "token", + "code token", + "code id_token token", + ]: + query_fragment["access_token"] = token.access_token # We don't need id_token if it's an OAuth2 request. if self.is_authentication: kwargs = { - 'token': token, - 'user': self.request.user, - 'aud': self.client.client_id, - 'nonce': self.params['nonce'], - 'request': self.request, - 'scope': self.params['scope'], + "token": token, + "user": self.request.user, + "aud": self.client.client_id, + "nonce": self.params["nonce"], + "request": self.request, + "scope": self.params["scope"], } # Include at_hash when access_token is being returned. - if 'access_token' in query_fragment: - kwargs['at_hash'] = token.at_hash + if "access_token" in query_fragment: + kwargs["at_hash"] = token.at_hash id_token_dic = create_id_token(**kwargs) # Check if response_type must include id_token in the response. - if self.params['response_type'] in [ - 'id_token', 'id_token token', 'code id_token', 'code id_token token']: - query_fragment['id_token'] = encode_id_token(id_token_dic, self.client) + if self.params["response_type"] in [ + "id_token", + "id_token token", + "code id_token", + "code id_token token", + ]: + query_fragment["id_token"] = encode_id_token(id_token_dic, self.client) else: id_token_dic = {} @@ -195,20 +207,21 @@ def create_response_uri(self): token.save() # Code parameter must be present if it's Hybrid Flow. - if self.grant_type == 'hybrid': - query_fragment['code'] = code.code + if self.grant_type == "hybrid": + query_fragment["code"] = code.code - query_fragment['token_type'] = 'bearer' + query_fragment["token_type"] = "bearer" - query_fragment['expires_in'] = settings.get('OIDC_TOKEN_EXPIRE') + query_fragment["expires_in"] = settings.get("OIDC_TOKEN_EXPIRE") - query_fragment['state'] = self.params['state'] if self.params['state'] else '' + query_fragment["state"] = self.params["state"] if self.params["state"] else "" - if settings.get('OIDC_SESSION_MANAGEMENT_ENABLE'): + if settings.get("OIDC_SESSION_MANAGEMENT_ENABLE"): # Generate client origin URI from the redirect_uri param. - redirect_uri_parsed = urlsplit(self.params['redirect_uri']) - client_origin = '{0}://{1}'.format( - redirect_uri_parsed.scheme, redirect_uri_parsed.netloc) + redirect_uri_parsed = urlsplit(self.params["redirect_uri"]) + client_origin = "{0}://{1}".format( + redirect_uri_parsed.scheme, redirect_uri_parsed.netloc + ) # Create random salt. salt = md5(uuid4().hex.encode()).hexdigest() @@ -216,25 +229,27 @@ def create_response_uri(self): # The generation of suitable Session State values is based # on a salted cryptographic hash of Client ID, origin URL, # and OP browser state. - session_state = '{client_id} {origin} {browser_state} {salt}'.format( + session_state = "{client_id} {origin} {browser_state} {salt}".format( client_id=self.client.client_id, origin=client_origin, browser_state=get_browser_state_or_default(self.request), - salt=salt) - session_state = sha256(session_state.encode('utf-8')).hexdigest() - session_state += '.' + salt - if self.grant_type == 'authorization_code': - query_params['session_state'] = session_state - elif self.grant_type in ['implicit', 'hybrid']: - query_fragment['session_state'] = session_state + salt=salt, + ) + session_state = sha256(session_state.encode("utf-8")).hexdigest() + session_state += "." + salt + if self.grant_type == "authorization_code": + query_params["session_state"] = session_state + elif self.grant_type in ["implicit", "hybrid"]: + query_fragment["session_state"] = session_state except Exception as error: - logger.exception('[Authorize] Error when trying to create response uri: %s', error) - raise AuthorizeError(self.params['redirect_uri'], 'server_error', self.grant_type) + logger.exception("[Authorize] Error when trying to create response uri: %s", error) + raise AuthorizeError(self.params["redirect_uri"], "server_error", self.grant_type) uri = uri._replace( query=urlencode(query_params, doseq=True), - fragment=uri.fragment + urlencode(query_fragment, doseq=True)) + fragment=uri.fragment + urlencode(query_fragment, doseq=True), + ) return urlunsplit(uri) @@ -245,18 +260,17 @@ def set_client_user_consent(self): Return None. """ date_given = timezone.now() - expires_at = date_given + timedelta( - days=settings.get('OIDC_SKIP_CONSENT_EXPIRE')) + expires_at = date_given + timedelta(days=settings.get("OIDC_SKIP_CONSENT_EXPIRE")) uc, created = UserConsent.objects.get_or_create( user=self.request.user, client=self.client, defaults={ - 'expires_at': expires_at, - 'date_given': date_given, - } + "expires_at": expires_at, + "date_given": date_given, + }, ) - uc.scope = self.params['scope'] + uc.scope = self.params["scope"] # Rewrite expires_at and date_given if object already exists. if not created: @@ -274,7 +288,7 @@ def client_has_user_consent(self): value = False try: uc = UserConsent.objects.get(user=self.request.user, client=self.client) - if (set(self.params['scope']).issubset(uc.scope)) and not (uc.has_expired()): + if (set(self.params["scope"]).issubset(uc.scope)) and not (uc.has_expired()): value = True except UserConsent.DoesNotExist: pass @@ -282,23 +296,24 @@ def client_has_user_consent(self): return value def is_client_allowed_to_skip_consent(self): - implicit_flow_resp_types = {'id_token', 'id_token token'} + implicit_flow_resp_types = {"id_token", "id_token token"} return ( - self.client.client_type != 'public' or - self.params['response_type'] in implicit_flow_resp_types + self.client.client_type != "public" + or self.params["response_type"] in implicit_flow_resp_types ) def get_scopes_information(self): """ Return a list with the description of all the scopes requested. """ - scopes = StandardScopeClaims.get_scopes_info(self.params['scope']) - if settings.get('OIDC_EXTRA_SCOPE_CLAIMS'): - scopes_extra = settings.get( - 'OIDC_EXTRA_SCOPE_CLAIMS', import_str=True).get_scopes_info(self.params['scope']) + scopes = StandardScopeClaims.get_scopes_info(self.params["scope"]) + if settings.get("OIDC_EXTRA_SCOPE_CLAIMS"): + scopes_extra = settings.get("OIDC_EXTRA_SCOPE_CLAIMS", import_str=True).get_scopes_info( + self.params["scope"] + ) for index_extra, scope_extra in enumerate(scopes_extra): for index, scope in enumerate(scopes[:]): - if scope_extra['scope'] == scope['scope']: + if scope_extra["scope"] == scope["scope"]: del scopes[index] else: scopes_extra = [] diff --git a/oidc_provider/lib/endpoints/token.py b/oidc_provider/lib/endpoints/token.py index 73a29937..39cc871f 100644 --- a/oidc_provider/lib/endpoints/token.py +++ b/oidc_provider/lib/endpoints/token.py @@ -8,27 +8,20 @@ from django.http import JsonResponse from oidc_provider import settings -from oidc_provider.lib.errors import ( - TokenError, - UserAuthError, -) +from oidc_provider.lib.errors import TokenError +from oidc_provider.lib.errors import UserAuthError from oidc_provider.lib.utils.oauth2 import extract_client_auth -from oidc_provider.lib.utils.token import ( - create_id_token, - create_token, - encode_id_token, -) -from oidc_provider.models import ( - Client, - Code, - Token, -) +from oidc_provider.lib.utils.token import create_id_token +from oidc_provider.lib.utils.token import create_token +from oidc_provider.lib.utils.token import encode_id_token +from oidc_provider.models import Client +from oidc_provider.models import Code +from oidc_provider.models import Token logger = logging.getLogger(__name__) class TokenEndpoint(object): - def __init__(self, request): self.request = request self.params = {} @@ -38,72 +31,79 @@ def __init__(self, request): def _extract_params(self): client_id, client_secret = extract_client_auth(self.request) - self.params['client_id'] = client_id - self.params['client_secret'] = client_secret - self.params['redirect_uri'] = self.request.POST.get('redirect_uri', '') - self.params['grant_type'] = self.request.POST.get('grant_type', '') - self.params['code'] = self.request.POST.get('code', '') - self.params['state'] = self.request.POST.get('state', '') - self.params['scope'] = self.request.POST.get('scope', '') - self.params['refresh_token'] = self.request.POST.get('refresh_token', '') + self.params["client_id"] = client_id + self.params["client_secret"] = client_secret + self.params["redirect_uri"] = self.request.POST.get("redirect_uri", "") + self.params["grant_type"] = self.request.POST.get("grant_type", "") + self.params["code"] = self.request.POST.get("code", "") + self.params["state"] = self.request.POST.get("state", "") + self.params["scope"] = self.request.POST.get("scope", "") + self.params["refresh_token"] = self.request.POST.get("refresh_token", "") # PKCE parameter. - self.params['code_verifier'] = self.request.POST.get('code_verifier') + self.params["code_verifier"] = self.request.POST.get("code_verifier") - self.params['username'] = self.request.POST.get('username', '') - self.params['password'] = self.request.POST.get('password', '') + self.params["username"] = self.request.POST.get("username", "") + self.params["password"] = self.request.POST.get("password", "") def validate_params(self): try: - self.client = Client.objects.get(client_id=self.params['client_id']) + self.client = Client.objects.get(client_id=self.params["client_id"]) except Client.DoesNotExist: - logger.debug('[Token] Client does not exist: %s', self.params['client_id']) - raise TokenError('invalid_client') + logger.debug("[Token] Client does not exist: %s", self.params["client_id"]) + raise TokenError("invalid_client") - if self.client.client_type == 'confidential': - if not (self.client.client_secret == self.params['client_secret']): - logger.debug('[Token] Invalid client secret: client %s do not have secret %s', - self.client.client_id, self.client.client_secret) - raise TokenError('invalid_client') + if self.client.client_type == "confidential": + if not (self.client.client_secret == self.params["client_secret"]): + logger.debug( + "[Token] Invalid client secret: client %s do not have secret %s", + self.client.client_id, + self.client.client_secret, + ) + raise TokenError("invalid_client") - if self.params['grant_type'] == 'authorization_code': - if not (self.params['redirect_uri'] in self.client.redirect_uris): - logger.debug('[Token] Invalid redirect uri: %s', self.params['redirect_uri']) - raise TokenError('invalid_client') + if self.params["grant_type"] == "authorization_code": + if self.params["redirect_uri"] not in self.client.redirect_uris: + logger.debug("[Token] Invalid redirect uri: %s", self.params["redirect_uri"]) + raise TokenError("invalid_client") try: self.code = Code.objects.select_for_update(nowait=True).get( - code=self.params['code']) + code=self.params["code"] + ) except DatabaseError: - logger.debug('[Token] Code cannot be reused: %s', self.params['code']) - raise TokenError('invalid_grant') + logger.debug("[Token] Code cannot be reused: %s", self.params["code"]) + raise TokenError("invalid_grant") except Code.DoesNotExist: - logger.debug('[Token] Code does not exist: %s', self.params['code']) - raise TokenError('invalid_grant') + logger.debug("[Token] Code does not exist: %s", self.params["code"]) + raise TokenError("invalid_grant") - if not (self.code.client == self.client) \ - or self.code.has_expired(): - logger.debug('[Token] Invalid code: invalid client or code has expired') - raise TokenError('invalid_grant') + if not (self.code.client == self.client) or self.code.has_expired(): + logger.debug("[Token] Invalid code: invalid client or code has expired") + raise TokenError("invalid_grant") # Validate PKCE parameters. if self.code.code_challenge: - if self.params['code_verifier'] is None: - raise TokenError('invalid_grant') - - if self.code.code_challenge_method == 'S256': - new_code_challenge = urlsafe_b64encode( - hashlib.sha256(self.params['code_verifier'].encode('ascii')).digest() - ).decode('utf-8').replace('=', '') + if self.params["code_verifier"] is None: + raise TokenError("invalid_grant") + + if self.code.code_challenge_method == "S256": + new_code_challenge = ( + urlsafe_b64encode( + hashlib.sha256(self.params["code_verifier"].encode("ascii")).digest() + ) + .decode("utf-8") + .replace("=", "") + ) else: - new_code_challenge = self.params['code_verifier'] + new_code_challenge = self.params["code_verifier"] # TODO: We should explain the error. if not (new_code_challenge == self.code.code_challenge): - raise TokenError('invalid_grant') + raise TokenError("invalid_grant") - elif self.params['grant_type'] == 'password': - if not settings.get('OIDC_GRANT_TYPE_PASSWORD_ENABLE'): - raise TokenError('unsupported_grant_type') + elif self.params["grant_type"] == "password": + if not settings.get("OIDC_GRANT_TYPE_PASSWORD_ENABLE"): + raise TokenError("unsupported_grant_type") auth_args = (self.request,) try: @@ -112,9 +112,7 @@ def validate_params(self): auth_args = () user = authenticate( - *auth_args, - username=self.params['username'], - password=self.params['password'] + *auth_args, username=self.params["username"], password=self.params["password"] ) if not user: @@ -122,56 +120,61 @@ def validate_params(self): self.user = user - elif self.params['grant_type'] == 'refresh_token': - if not self.params['refresh_token']: - logger.debug('[Token] Missing refresh token') - raise TokenError('invalid_grant') + elif self.params["grant_type"] == "refresh_token": + if not self.params["refresh_token"]: + logger.debug("[Token] Missing refresh token") + raise TokenError("invalid_grant") try: - self.token = Token.objects.get(refresh_token=self.params['refresh_token'], - client=self.client) + self.token = Token.objects.get( + refresh_token=self.params["refresh_token"], client=self.client + ) except Token.DoesNotExist: logger.debug( - '[Token] Refresh token does not exist: %s', self.params['refresh_token']) - raise TokenError('invalid_grant') - elif self.params['grant_type'] == 'client_credentials': + "[Token] Refresh token does not exist: %s", self.params["refresh_token"] + ) + raise TokenError("invalid_grant") + elif self.params["grant_type"] == "client_credentials": if not self.client._scope: - logger.debug('[Token] Client using client credentials with empty scope') - raise TokenError('invalid_scope') + logger.debug("[Token] Client using client credentials with empty scope") + raise TokenError("invalid_scope") else: - logger.debug('[Token] Invalid grant type: %s', self.params['grant_type']) - raise TokenError('unsupported_grant_type') + logger.debug("[Token] Invalid grant type: %s", self.params["grant_type"]) + raise TokenError("unsupported_grant_type") def validate_requested_scopes(self): """ Handling validation of requested scope for grant_type=[password|client_credentials] """ token_scopes = [] - if self.params['scope']: + if self.params["scope"]: # See https://tools.ietf.org/html/rfc6749#section-3.3 # The value of the scope parameter is expressed # as a list of space-delimited, case-sensitive strings - for scope_requested in self.params['scope'].split(' '): + for scope_requested in self.params["scope"].split(" "): if scope_requested in self.client.scope: token_scopes.append(scope_requested) else: - logger.debug('[Token] The request scope %s is not supported by client %s', - scope_requested, self.client.client_id) - raise TokenError('invalid_scope') + logger.debug( + "[Token] The request scope %s is not supported by client %s", + scope_requested, + self.client.client_id, + ) + raise TokenError("invalid_scope") # if no scopes requested assign client's scopes else: token_scopes.extend(self.client.scope) return token_scopes def create_response_dic(self): - if self.params['grant_type'] == 'authorization_code': + if self.params["grant_type"] == "authorization_code": return self.create_code_response_dic() - elif self.params['grant_type'] == 'refresh_token': + elif self.params["grant_type"] == "refresh_token": return self.create_refresh_response_dic() - elif self.params['grant_type'] == 'password': + elif self.params["grant_type"] == "password": return self.create_access_token_response_dic() - elif self.params['grant_type'] == 'client_credentials': + elif self.params["grant_type"] == "client_credentials": return self.create_client_credentials_response_dic() def create_token(self, user, client, scope): @@ -213,11 +216,11 @@ def create_code_response_dic(self): self.code.delete() dic = { - 'access_token': token.access_token, - 'refresh_token': token.refresh_token, - 'token_type': 'bearer', - 'expires_in': settings.get('OIDC_TOKEN_EXPIRE'), - 'id_token': encode_id_token(id_token_dic, token.client), + "access_token": token.access_token, + "refresh_token": token.refresh_token, + "token_type": "bearer", + "expires_in": settings.get("OIDC_TOKEN_EXPIRE"), + "id_token": encode_id_token(id_token_dic, token.client), } return dic @@ -225,11 +228,11 @@ def create_code_response_dic(self): def create_refresh_response_dic(self): # See https://tools.ietf.org/html/rfc6749#section-6 - scope_param = self.params['scope'] - scope = (scope_param.split(' ') if scope_param else self.token.scope) + scope_param = self.params["scope"] + scope = scope_param.split(" ") if scope_param else self.token.scope unauthorized_scopes = set(scope) - set(self.token.scope) if unauthorized_scopes: - raise TokenError('invalid_scope') + raise TokenError("invalid_scope") token = self.create_token( user=self.token.user, @@ -259,11 +262,11 @@ def create_refresh_response_dic(self): self.token.delete() dic = { - 'access_token': token.access_token, - 'refresh_token': token.refresh_token, - 'token_type': 'bearer', - 'expires_in': settings.get('OIDC_TOKEN_EXPIRE'), - 'id_token': encode_id_token(id_token_dic, self.token.client), + "access_token": token.access_token, + "refresh_token": token.refresh_token, + "token_type": "bearer", + "expires_in": settings.get("OIDC_TOKEN_EXPIRE"), + "id_token": encode_id_token(id_token_dic, self.token.client), } return dic @@ -281,7 +284,7 @@ def create_access_token_response_dic(self): token=token, user=self.user, aud=self.client.client_id, - nonce='self.code.nonce', + nonce="self.code.nonce", at_hash=token.at_hash, request=self.request, scope=token.scope, @@ -291,12 +294,12 @@ def create_access_token_response_dic(self): token.save() return { - 'access_token': token.access_token, - 'refresh_token': token.refresh_token, - 'expires_in': settings.get('OIDC_TOKEN_EXPIRE'), - 'token_type': 'bearer', - 'id_token': encode_id_token(id_token_dic, token.client), - 'scope': ' '.join(token.scope) + "access_token": token.access_token, + "refresh_token": token.refresh_token, + "expires_in": settings.get("OIDC_TOKEN_EXPIRE"), + "token_type": "bearer", + "id_token": encode_id_token(id_token_dic, token.client), + "scope": " ".join(token.scope), } def create_client_credentials_response_dic(self): @@ -311,10 +314,10 @@ def create_client_credentials_response_dic(self): token.save() return { - 'access_token': token.access_token, - 'expires_in': settings.get('OIDC_TOKEN_EXPIRE'), - 'token_type': 'bearer', - 'scope': ' '.join(token.scope), + "access_token": token.access_token, + "expires_in": settings.get("OIDC_TOKEN_EXPIRE"), + "token_type": "bearer", + "scope": " ".join(token.scope), } @classmethod @@ -323,7 +326,7 @@ def response(cls, dic, status=200): Create and return a response object. """ response = JsonResponse(dic, status=status) - response['Cache-Control'] = 'no-store' - response['Pragma'] = 'no-cache' + response["Cache-Control"] = "no-store" + response["Pragma"] = "no-cache" return response diff --git a/oidc_provider/lib/utils/token.py b/oidc_provider/lib/utils/token.py index d3fd3ab2..403440ad 100644 --- a/oidc_provider/lib/utils/token.py +++ b/oidc_provider/lib/utils/token.py @@ -19,7 +19,7 @@ from oidc_provider import settings -def create_id_token(token, user, aud, nonce='', at_hash='', request=None, scope=None): +def create_id_token(token, user, aud, nonce="", at_hash="", request=None, scope=None): """ Creates the id_token dictionary. See: http://openid.net/specs/openid-connect-core-1_0.html#IDToken @@ -27,44 +27,44 @@ def create_id_token(token, user, aud, nonce='', at_hash='', request=None, scope= """ if scope is None: scope = [] - sub = settings.get('OIDC_IDTOKEN_SUB_GENERATOR', import_str=True)(user=user) + sub = settings.get("OIDC_IDTOKEN_SUB_GENERATOR", import_str=True)(user=user) - expires_in = settings.get('OIDC_IDTOKEN_EXPIRE') + expires_in = settings.get("OIDC_IDTOKEN_EXPIRE") # Convert datetimes into timestamps. now = int(time.time()) iat_time = now exp_time = int(now + expires_in) user_auth_time = user.last_login or user.date_joined - auth_time = int(dateformat.format(user_auth_time, 'U')) + auth_time = int(dateformat.format(user_auth_time, "U")) dic = { - 'iss': get_issuer(request=request), - 'sub': sub, - 'aud': str(aud), - 'exp': exp_time, - 'iat': iat_time, - 'auth_time': auth_time, + "iss": get_issuer(request=request), + "sub": sub, + "aud": str(aud), + "exp": exp_time, + "iat": iat_time, + "auth_time": auth_time, } if nonce: - dic['nonce'] = str(nonce) + dic["nonce"] = str(nonce) if at_hash: - dic['at_hash'] = at_hash + dic["at_hash"] = at_hash # Inlude (or not) user standard claims in the id_token. - if settings.get('OIDC_IDTOKEN_INCLUDE_CLAIMS'): - if settings.get('OIDC_EXTRA_SCOPE_CLAIMS'): - custom_claims = settings.get('OIDC_EXTRA_SCOPE_CLAIMS', import_str=True)(token) - claims = custom_claims.create_response_dic() - else: - claims = StandardScopeClaims(token).create_response_dic() - dic.update(claims) + if settings.get("OIDC_IDTOKEN_INCLUDE_CLAIMS"): + standard_claims = StandardScopeClaims(token) + dic.update(standard_claims.create_response_dic()) + + if settings.get("OIDC_EXTRA_SCOPE_CLAIMS"): + extra_claims = settings.get("OIDC_EXTRA_SCOPE_CLAIMS", import_str=True)(token) + dic.update(extra_claims.create_response_dic()) dic = run_processing_hook( - dic, 'OIDC_IDTOKEN_PROCESSING_HOOK', - user=user, token=token, request=request) + dic, "OIDC_IDTOKEN_PROCESSING_HOOK", user=user, token=token, request=request + ) return dic @@ -94,7 +94,7 @@ def client_id_from_id_token(id_token): Returns a string or None. """ payload = JWT().unpack(id_token).payload() - aud = payload.get('aud', None) + aud = payload.get("aud", None) if aud is None: return None if isinstance(aud, list): @@ -116,15 +116,15 @@ def create_token(user, client, scope, id_token_dic=None): token.id_token = id_token_dic token.refresh_token = uuid.uuid4().hex - token.expires_at = timezone.now() + timedelta( - seconds=settings.get('OIDC_TOKEN_EXPIRE')) + token.expires_at = timezone.now() + timedelta(seconds=settings.get("OIDC_TOKEN_EXPIRE")) token.scope = scope return token -def create_code(user, client, scope, nonce, is_authentication, - code_challenge=None, code_challenge_method=None): +def create_code( + user, client, scope, nonce, is_authentication, code_challenge=None, code_challenge_method=None +): """ Create and populate a Code object. Return a Code object. @@ -139,8 +139,7 @@ def create_code(user, client, scope, nonce, is_authentication, code.code_challenge = code_challenge code.code_challenge_method = code_challenge_method - code.expires_at = timezone.now() + timedelta( - seconds=settings.get('OIDC_CODE_EXPIRE')) + code.expires_at = timezone.now() + timedelta(seconds=settings.get("OIDC_CODE_EXPIRE")) code.scope = scope code.nonce = nonce code.is_authentication = is_authentication @@ -153,15 +152,15 @@ def get_client_alg_keys(client): Takes a client and returns the set of keys associated with it. Returns a list of keys. """ - if client.jwt_alg == 'RS256': + if client.jwt_alg == "RS256": keys = [] for rsakey in RSAKey.objects.all(): keys.append(jwk_RSAKey(key=importKey(rsakey.key), kid=rsakey.kid)) if not keys: - raise Exception('You must add at least one RSA Key.') - elif client.jwt_alg == 'HS256': + raise Exception("You must add at least one RSA Key.") + elif client.jwt_alg == "HS256": keys = [SYMKey(key=client.client_secret, alg=client.jwt_alg)] else: - raise Exception('Unsupported key algorithm.') + raise Exception("Unsupported key algorithm.") return keys diff --git a/oidc_provider/tests/app/utils.py b/oidc_provider/tests/app/utils.py index 51f51d4e..491af0e5 100644 --- a/oidc_provider/tests/app/utils.py +++ b/oidc_provider/tests/app/utils.py @@ -5,25 +5,27 @@ from django.contrib.auth.backends import ModelBackend try: - from urlparse import parse_qs, urlsplit + from urlparse import parse_qs + from urlparse import urlsplit except ImportError: - from urllib.parse import parse_qs, urlsplit + from urllib.parse import parse_qs + from urllib.parse import urlsplit -from django.utils import timezone from django.contrib.auth.models import User +from django.utils import timezone -from oidc_provider.models import ( - Client, - Code, - Token, - ResponseType) - +from oidc_provider.lib.claims import ScopeClaims +from oidc_provider.models import Client +from oidc_provider.models import Code +from oidc_provider.models import ResponseType +from oidc_provider.models import Token -FAKE_NONCE = 'cb584e44c43ed6bd0bc2d9c7e242837d' -FAKE_RANDOM_STRING = ''.join( - random.choice(string.ascii_uppercase + string.digits) for _ in range(32)) -FAKE_CODE_CHALLENGE = 'YlYXEqXuRm-Xgi2BOUiK50JW1KsGTX6F1TDnZSC8VTg' -FAKE_CODE_VERIFIER = 'SmxGa0XueyNh5bDgTcSrqzAh2_FmXEqU8kDT6CuXicw' +FAKE_NONCE = "cb584e44c43ed6bd0bc2d9c7e242837d" +FAKE_RANDOM_STRING = "".join( + random.choice(string.ascii_uppercase + string.digits) for _ in range(32) +) +FAKE_CODE_CHALLENGE = "YlYXEqXuRm-Xgi2BOUiK50JW1KsGTX6F1TDnZSC8VTg" +FAKE_CODE_VERIFIER = "SmxGa0XueyNh5bDgTcSrqzAh2_FmXEqU8kDT6CuXicw" def create_fake_user(): @@ -33,11 +35,11 @@ def create_fake_user(): Return a User object. """ user = User() - user.username = 'johndoe' - user.email = 'johndoe@example.com' - user.first_name = 'John' - user.last_name = 'Doe' - user.set_password('1234') + user.username = "johndoe" + user.email = "johndoe@example.com" + user.first_name = "John" + user.last_name = "Doe" + user.set_password("1234") user.save() @@ -52,20 +54,20 @@ def create_fake_client(response_type, is_public=False, require_consent=True): Return a Client object. """ client = Client() - client.name = 'Some Client' + client.name = "Some Client" client.client_id = str(random.randint(1, 999999)).zfill(6) if is_public: - client.client_type = 'public' - client.client_secret = '' + client.client_type = "public" + client.client_secret = "" else: client.client_secret = str(random.randint(1, 999999)).zfill(6) - client.redirect_uris = ['http://example.com/'] + client.redirect_uris = ["http://example.com/"] client.require_consent = require_consent - client.scope = ['openid', 'email'] + client.scope = ["openid", "email"] client.save() # check if response_type is a string in a python 2 and 3 compatible way - if isinstance(response_type, ("".__class__, u"".__class__)): + if isinstance(response_type, ("".__class__, "".__class__)): response_type = (response_type,) for value in response_type: client.response_types.add(ResponseType.objects.get(value=value)) @@ -90,7 +92,7 @@ def is_code_valid(url, user, client): try: parsed = urlsplit(url) params = parse_qs(parsed.query or parsed.fragment) - code = params['code'][0] + code = params["code"][0] code = Code.objects.get(code=code) is_code_ok = (code.client == client) and (code.user == user) except Exception: @@ -103,15 +105,28 @@ def userinfo(claims, user): """ Fake function for setting OIDC_USERINFO. """ - claims['given_name'] = 'John' - claims['family_name'] = 'Doe' - claims['name'] = '{0} {1}'.format(claims['given_name'], claims['family_name']) - claims['email'] = user.email - claims['email_verified'] = True - claims['address']['country'] = 'Argentina' + claims["given_name"] = "John" + claims["family_name"] = "Doe" + claims["name"] = "{0} {1}".format(claims["given_name"], claims["family_name"]) + claims["email"] = user.email + claims["email_verified"] = True + claims["address"]["country"] = "Argentina" return claims +class FakeScopeClaims(ScopeClaims): + info_pizza = ( + "Pizza", + "Some description for the scope.", + ) + + def scope_pizza(self): + dic = { + "pizza": "Margherita", + } + return dic + + def fake_sub_generator(user): """ Fake function for setting OIDC_IDTOKEN_SUB_GENERATOR. @@ -123,8 +138,8 @@ def fake_idtoken_processing_hook(id_token, user, **kwargs): """ Fake function for inserting some keys into token. Testing OIDC_IDTOKEN_PROCESSING_HOOK. """ - id_token['test_idtoken_processing_hook'] = FAKE_RANDOM_STRING - id_token['test_idtoken_processing_hook_user_email'] = user.email + id_token["test_idtoken_processing_hook"] = FAKE_RANDOM_STRING + id_token["test_idtoken_processing_hook_user_email"] = user.email return id_token @@ -133,8 +148,8 @@ def fake_idtoken_processing_hook2(id_token, user, **kwargs): Fake function for inserting some keys into token. Testing OIDC_IDTOKEN_PROCESSING_HOOK - tuple or list as param """ - id_token['test_idtoken_processing_hook2'] = FAKE_RANDOM_STRING - id_token['test_idtoken_processing_hook_user_email2'] = user.email + id_token["test_idtoken_processing_hook2"] = FAKE_RANDOM_STRING + id_token["test_idtoken_processing_hook_user_email2"] = user.email return id_token @@ -142,7 +157,7 @@ def fake_idtoken_processing_hook3(id_token, user, token, **kwargs): """ Fake function for checking scope is passed to processing hook. """ - id_token['scope_of_token_passed_to_processing_hook'] = token.scope + id_token["scope_of_token_passed_to_processing_hook"] = token.scope return id_token @@ -150,15 +165,14 @@ def fake_idtoken_processing_hook4(id_token, user, **kwargs): """ Fake function for checking kwargs passed to processing hook. """ - id_token['kwargs_passed_to_processing_hook'] = { - key: repr(value) - for (key, value) in kwargs.items() + id_token["kwargs_passed_to_processing_hook"] = { + key: repr(value) for (key, value) in kwargs.items() } return id_token def fake_introspection_processing_hook(response_dict, client, id_token): - response_dict['test_introspection_processing_hook'] = FAKE_RANDOM_STRING + response_dict["test_introspection_processing_hook"] = FAKE_RANDOM_STRING return response_dict diff --git a/oidc_provider/tests/cases/test_utils.py b/oidc_provider/tests/cases/test_utils.py index 787a3f56..24c9ae65 100644 --- a/oidc_provider/tests/cases/test_utils.py +++ b/oidc_provider/tests/cases/test_utils.py @@ -1,56 +1,59 @@ import time from datetime import datetime from hashlib import sha224 +from unittest import mock from django.http import HttpRequest -from django.test import TestCase, override_settings +from django.test import TestCase +from django.test import override_settings from django.utils import timezone -from mock import mock -from oidc_provider.lib.utils.common import get_issuer, get_browser_state_or_default -from oidc_provider.lib.utils.token import create_token, create_id_token -from oidc_provider.tests.app.utils import create_fake_user, create_fake_client +from oidc_provider.lib.utils.common import get_browser_state_or_default +from oidc_provider.lib.utils.common import get_issuer +from oidc_provider.lib.utils.token import create_id_token +from oidc_provider.lib.utils.token import create_token +from oidc_provider.tests.app.utils import create_fake_client +from oidc_provider.tests.app.utils import create_fake_user class Request(object): """ Mock request object. """ - scheme = 'http' + + scheme = "http" def get_host(self): - return 'host-from-request:8888' + return "host-from-request:8888" class CommonTest(TestCase): """ Test cases for common utils. """ + def test_get_issuer(self): request = Request() # from default settings - self.assertEqual(get_issuer(), - 'http://localhost:8000/openid') + self.assertEqual(get_issuer(), "http://localhost:8000/openid") # from custom settings - with self.settings(SITE_URL='http://otherhost:8000'): - self.assertEqual(get_issuer(), - 'http://otherhost:8000/openid') + with self.settings(SITE_URL="http://otherhost:8000"): + self.assertEqual(get_issuer(), "http://otherhost:8000/openid") # `SITE_URL` not set, from `request` - with self.settings(SITE_URL=''): - self.assertEqual(get_issuer(request=request), - 'http://host-from-request:8888/openid') + with self.settings(SITE_URL=""): + self.assertEqual(get_issuer(request=request), "http://host-from-request:8888/openid") # use settings first if both are provided - self.assertEqual(get_issuer(request=request), - 'http://localhost:8000/openid') + self.assertEqual(get_issuer(request=request), "http://localhost:8000/openid") # `site_url` can even be overridden manually - self.assertEqual(get_issuer(site_url='http://127.0.0.1:9000', - request=request), - 'http://127.0.0.1:9000/openid') + self.assertEqual( + get_issuer(site_url="http://127.0.0.1:9000", request=request), + "http://127.0.0.1:9000/openid", + ) def timestamp_to_datetime(timestamp): @@ -69,32 +72,61 @@ def test_create_id_token(self): self.user.last_login = timestamp_to_datetime(login_timestamp) client = create_fake_client("code") token = create_token(self.user, client, []) - id_token_data = create_id_token(token=token, user=self.user, aud='test-aud') - iat = id_token_data['iat'] + id_token_data = create_id_token(token=token, user=self.user, aud="test-aud") + iat = id_token_data["iat"] self.assertEqual(type(iat), int) self.assertGreaterEqual(iat, start_time) self.assertLessEqual(iat - start_time, 5) # Can't take more than 5 s - self.assertEqual(id_token_data, { - 'aud': 'test-aud', - 'auth_time': login_timestamp, - 'exp': iat + 600, - 'iat': iat, - 'iss': 'http://localhost:8000/openid', - 'sub': str(self.user.id), - }) + self.assertEqual( + id_token_data, + { + "aud": "test-aud", + "auth_time": login_timestamp, + "exp": iat + 600, + "iat": iat, + "iss": "http://localhost:8000/openid", + "sub": str(self.user.id), + }, + ) + + @override_settings(OIDC_IDTOKEN_INCLUDE_CLAIMS=True) + def test_create_id_token_with_include_claims_setting(self): + client = create_fake_client("code") + token = create_token(self.user, client, scope=["openid", "email"]) + id_token_data = create_id_token(token=token, user=self.user, aud="test-aud") + self.assertIn("email", id_token_data) + self.assertTrue(id_token_data["email"]) + self.assertIn("email_verified", id_token_data) + self.assertTrue(id_token_data["email_verified"]) + + @override_settings( + OIDC_IDTOKEN_INCLUDE_CLAIMS=True, + OIDC_EXTRA_SCOPE_CLAIMS="oidc_provider.tests.app.utils.FakeScopeClaims", + ) + def test_create_id_token_with_include_claims_setting_and_extra(self): + client = create_fake_client("code") + token = create_token(self.user, client, scope=["openid", "email", "pizza"]) + id_token_data = create_id_token(token=token, user=self.user, aud="test-aud") + # Standard claims included. + self.assertIn("email", id_token_data) + self.assertTrue(id_token_data["email"]) + self.assertIn("email_verified", id_token_data) + self.assertTrue(id_token_data["email_verified"]) + # Extra claims included. + self.assertIn("pizza", id_token_data) + self.assertEqual(id_token_data["pizza"], "Margherita") class BrowserStateTest(TestCase): - - @override_settings(OIDC_UNAUTHENTICATED_SESSION_MANAGEMENT_KEY='my_static_key') + @override_settings(OIDC_UNAUTHENTICATED_SESSION_MANAGEMENT_KEY="my_static_key") def test_get_browser_state_uses_value_from_settings_to_calculate_browser_state(self): request = HttpRequest() request.session = mock.Mock(session_key=None) state = get_browser_state_or_default(request) - self.assertEqual(state, sha224('my_static_key'.encode('utf-8')).hexdigest()) + self.assertEqual(state, sha224("my_static_key".encode("utf-8")).hexdigest()) def test_get_browser_state_uses_session_key_to_calculate_browser_state_if_available(self): request = HttpRequest() - request.session = mock.Mock(session_key='my_session_key') + request.session = mock.Mock(session_key="my_session_key") state = get_browser_state_or_default(request) - self.assertEqual(state, sha224('my_session_key'.encode('utf-8')).hexdigest()) + self.assertEqual(state, sha224("my_session_key".encode("utf-8")).hexdigest()) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..17304614 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,5 @@ +[tool.ruff] +line-length = 100 + +[tool.ruff.lint.isort] +force-single-line = true \ No newline at end of file From 1756c20d9912b5d3767ffa8f36a313b10d79da99 Mon Sep 17 00:00:00 2001 From: juanifioren Date: Thu, 5 Dec 2024 13:34:25 -0300 Subject: [PATCH 14/54] Fix create_id_token with extra scope claims + add ruff as formatter. --- oidc_provider/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/oidc_provider/__init__.py b/oidc_provider/__init__.py index 1be3d2ce..e69de29b 100644 --- a/oidc_provider/__init__.py +++ b/oidc_provider/__init__.py @@ -1,2 +0,0 @@ - -default_app_config = 'oidc_provider.apps.OIDCProviderConfig' From 1a1c55cc9427ec90c3454040981422c579b4ed20 Mon Sep 17 00:00:00 2001 From: juanifioren Date: Thu, 5 Dec 2024 13:55:12 -0300 Subject: [PATCH 15/54] Fix create_id_token with extra scope claims + add ruff as formatter. --- docs/sections/scopesclaims.rst | 8 ++++++++ docs/sections/settings.rst | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/sections/scopesclaims.rst b/docs/sections/scopesclaims.rst index 977027f0..b2c4542c 100644 --- a/docs/sections/scopesclaims.rst +++ b/docs/sections/scopesclaims.rst @@ -111,3 +111,11 @@ Inside your oidc_provider_settings.py file add the following class:: .. note:: If a field is empty or ``None`` inside the dictionary you return on the ``scope_scopename`` method, it will be cleaned from the response. + +Include claims in the ID Token +============================== + +The draft specifies that ID Tokens MAY include additional claims. You can add claims to the ID Token using ``OIDC_IDTOKEN_INCLUDE_CLAIMS``. Note that the claims will be filtered based on the token's scope. + +.. note:: + Any extra claims defined with ``OIDC_EXTRA_SCOPE_CLAIMS`` will also be included. \ No newline at end of file diff --git a/docs/sections/settings.rst b/docs/sections/settings.rst index b44031b1..26a9cd37 100644 --- a/docs/sections/settings.rst +++ b/docs/sections/settings.rst @@ -81,7 +81,7 @@ Read more about how to implement it in :ref:`scopesclaims` section. OIDC_IDTOKEN_INCLUDE_CLAIMS ============================== -OPTIONAL. ``bool``. If enabled, id_token will include standard claims of the user (email, first name, etc.). +OPTIONAL. ``bool``. If enabled, id_token will include standard (and extra if defined) claims of the user (email, first name, etc.). Default is ``False``. From c20562e6b6eb04f5f6c4311a862e12376ef50353 Mon Sep 17 00:00:00 2001 From: juanifioren Date: Fri, 6 Dec 2024 13:39:50 -0300 Subject: [PATCH 16/54] Bump version 0.8.3 --- docs/sections/changelog.rst | 8 ++++++++ oidc_provider/version.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/sections/changelog.rst b/docs/sections/changelog.rst index 31ebcf67..0dceca8d 100644 --- a/docs/sections/changelog.rst +++ b/docs/sections/changelog.rst @@ -8,7 +8,15 @@ All notable changes to this project will be documented in this file. Unreleased ========== +None + +0.8.3 +===== + +*2024-12-06* + * Changed: Improved "OpenID Connect RP-Initiated Logout" implementation. +* Fixed: Fix ID Tokens not including standard claims when using extra scope claims. * Fixed: RSA server keys random ordering. * Fixed: Example app working with Django 4. diff --git a/oidc_provider/version.py b/oidc_provider/version.py index 4ca39e7c..732155f8 100644 --- a/oidc_provider/version.py +++ b/oidc_provider/version.py @@ -1 +1 @@ -__version__ = '0.8.2' +__version__ = "0.8.3" From 2175027c9ecb6066b51737760fa6a268192bc280 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Sun, 8 Dec 2024 23:02:18 -0300 Subject: [PATCH 17/54] Update README.md --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index e9daf3a9..4e78e9aa 100644 --- a/README.md +++ b/README.md @@ -18,3 +18,7 @@ Support for Python 3 and latest versions of django. [Read documentation for more info.](http://django-oidc-provider.readthedocs.org/) [Do you want to contribute? Please read this.](http://django-oidc-provider.readthedocs.io/en/master/sections/contribute.html) + +## Thanks to our sponsors + +[![Agilentia](https://avatars.githubusercontent.com/u/1707212?s=60&v=4)]([https://github.com/agilentia) From 3eb11dc3998aa12211c5b445550c11b614c98bb1 Mon Sep 17 00:00:00 2001 From: juanifioren Date: Wed, 11 Dec 2024 23:08:24 -0300 Subject: [PATCH 18/54] Tox improvement + id token better serializer --- oidc_provider/models.py | 215 +++--- .../tests/cases/test_token_endpoint.py | 635 +++++++++--------- oidc_provider/tests/cases/test_utils.py | 25 + tox.ini | 7 +- 4 files changed, 456 insertions(+), 426 deletions(-) diff --git a/oidc_provider/models.py b/oidc_provider/models.py index d2111086..2edf78b7 100644 --- a/oidc_provider/models.py +++ b/oidc_provider/models.py @@ -1,31 +1,32 @@ import base64 import binascii -from hashlib import md5, sha256 import json +from hashlib import md5 +from hashlib import sha256 +from django.conf import settings +from django.core.serializers.json import DjangoJSONEncoder from django.db import models from django.utils import timezone from django.utils.translation import gettext_lazy as _ -from django.conf import settings - CLIENT_TYPE_CHOICES = [ - ('confidential', 'Confidential'), - ('public', 'Public'), + ("confidential", "Confidential"), + ("public", "Public"), ] RESPONSE_TYPE_CHOICES = [ - ('code', 'code (Authorization Code Flow)'), - ('id_token', 'id_token (Implicit Flow)'), - ('id_token token', 'id_token token (Implicit Flow)'), - ('code token', 'code token (Hybrid Flow)'), - ('code id_token', 'code id_token (Hybrid Flow)'), - ('code id_token token', 'code id_token token (Hybrid Flow)'), + ("code", "code (Authorization Code Flow)"), + ("id_token", "id_token (Implicit Flow)"), + ("id_token token", "id_token token (Implicit Flow)"), + ("code token", "code token (Hybrid Flow)"), + ("code id_token", "code id_token (Hybrid Flow)"), + ("code id_token token", "code id_token token (Hybrid Flow)"), ] JWT_ALGS = [ - ('HS256', 'HS256'), - ('RS256', 'RS256'), + ("HS256", "HS256"), + ("RS256", "RS256"), ] @@ -41,82 +42,102 @@ class ResponseType(models.Model): max_length=30, choices=RESPONSE_TYPE_CHOICES, unique=True, - verbose_name=_(u'Response Type Value')) + verbose_name=_("Response Type Value"), + ) description = models.CharField( max_length=50, ) def natural_key(self): - return self.value, # natural_key must return tuple + return (self.value,) # natural_key must return tuple def __str__(self): - return u'{0}'.format(self.description) + return "{0}".format(self.description) class Client(models.Model): - - name = models.CharField(max_length=100, default='', verbose_name=_(u'Name')) + name = models.CharField(max_length=100, default="", verbose_name=_("Name")) owner = models.ForeignKey( - settings.AUTH_USER_MODEL, verbose_name=_(u'Owner'), blank=True, - null=True, default=None, on_delete=models.SET_NULL, related_name='oidc_clients_set') + settings.AUTH_USER_MODEL, + verbose_name=_("Owner"), + blank=True, + null=True, + default=None, + on_delete=models.SET_NULL, + related_name="oidc_clients_set", + ) client_type = models.CharField( max_length=30, choices=CLIENT_TYPE_CHOICES, - default='confidential', - verbose_name=_(u'Client Type'), - help_text=_(u'Confidential clients are capable of maintaining the confidentiality' - u' of their credentials. Public clients are incapable.')) - client_id = models.CharField(max_length=255, unique=True, verbose_name=_(u'Client ID')) - client_secret = models.CharField(max_length=255, blank=True, verbose_name=_(u'Client SECRET')) + default="confidential", + verbose_name=_("Client Type"), + help_text=_( + "Confidential clients are capable of maintaining the confidentiality" + " of their credentials. Public clients are incapable." + ), + ) + client_id = models.CharField(max_length=255, unique=True, verbose_name=_("Client ID")) + client_secret = models.CharField(max_length=255, blank=True, verbose_name=_("Client SECRET")) response_types = models.ManyToManyField(ResponseType) jwt_alg = models.CharField( max_length=10, choices=JWT_ALGS, - default='RS256', - verbose_name=_(u'JWT Algorithm'), - help_text=_(u'Algorithm used to encode ID Tokens.')) - date_created = models.DateField(auto_now_add=True, verbose_name=_(u'Date Created')) + default="RS256", + verbose_name=_("JWT Algorithm"), + help_text=_("Algorithm used to encode ID Tokens."), + ) + date_created = models.DateField(auto_now_add=True, verbose_name=_("Date Created")) website_url = models.CharField( - max_length=255, blank=True, default='', verbose_name=_(u'Website URL')) + max_length=255, blank=True, default="", verbose_name=_("Website URL") + ) terms_url = models.CharField( max_length=255, blank=True, - default='', - verbose_name=_(u'Terms URL'), - help_text=_(u'External reference to the privacy policy of the client.')) + default="", + verbose_name=_("Terms URL"), + help_text=_("External reference to the privacy policy of the client."), + ) contact_email = models.CharField( - max_length=255, blank=True, default='', verbose_name=_(u'Contact Email')) + max_length=255, blank=True, default="", verbose_name=_("Contact Email") + ) logo = models.FileField( - blank=True, default='', upload_to='oidc_provider/clients', verbose_name=_(u'Logo Image')) + blank=True, default="", upload_to="oidc_provider/clients", verbose_name=_("Logo Image") + ) reuse_consent = models.BooleanField( default=True, - verbose_name=_('Reuse Consent?'), - help_text=_('If enabled, server will save the user consent given to a specific client, ' - 'so that user won\'t be prompted for the same authorization multiple times.')) + verbose_name=_("Reuse Consent?"), + help_text=_( + "If enabled, server will save the user consent given to a specific client, " + "so that user won't be prompted for the same authorization multiple times." + ), + ) require_consent = models.BooleanField( default=True, - verbose_name=_('Require Consent?'), - help_text=_('If disabled, the Server will NEVER ask the user for consent.')) + verbose_name=_("Require Consent?"), + help_text=_("If disabled, the Server will NEVER ask the user for consent."), + ) _redirect_uris = models.TextField( - default='', verbose_name=_(u'Redirect URIs'), - help_text=_(u'Enter each URI on a new line.')) + default="", verbose_name=_("Redirect URIs"), help_text=_("Enter each URI on a new line.") + ) _post_logout_redirect_uris = models.TextField( blank=True, - default='', - verbose_name=_(u'Post Logout Redirect URIs'), - help_text=_(u'Enter each URI on a new line.')) + default="", + verbose_name=_("Post Logout Redirect URIs"), + help_text=_("Enter each URI on a new line."), + ) _scope = models.TextField( blank=True, - default='', - verbose_name=_(u'Scopes'), - help_text=_('Specifies the authorized scope values for the client app.')) + default="", + verbose_name=_("Scopes"), + help_text=_("Specifies the authorized scope values for the client app."), + ) class Meta: - verbose_name = _(u'Client') - verbose_name_plural = _(u'Clients') + verbose_name = _("Client") + verbose_name_plural = _("Clients") def __str__(self): - return u'{0}'.format(self.name) + return "{0}".format(self.name) def __unicode__(self): return self.__str__() @@ -134,7 +155,7 @@ def redirect_uris(self): @redirect_uris.setter def redirect_uris(self, value): - self._redirect_uris = '\n'.join(value) + self._redirect_uris = "\n".join(value) @property def post_logout_redirect_uris(self): @@ -142,7 +163,7 @@ def post_logout_redirect_uris(self): @post_logout_redirect_uris.setter def post_logout_redirect_uris(self, value): - self._post_logout_redirect_uris = '\n'.join(value) + self._post_logout_redirect_uris = "\n".join(value) @property def scope(self): @@ -150,18 +171,17 @@ def scope(self): @scope.setter def scope(self, value): - self._scope = ' '.join(value) + self._scope = " ".join(value) @property def default_redirect_uri(self): - return self.redirect_uris[0] if self.redirect_uris else '' + return self.redirect_uris[0] if self.redirect_uris else "" class BaseCodeTokenModel(models.Model): - - client = models.ForeignKey(Client, verbose_name=_(u'Client'), on_delete=models.CASCADE) - expires_at = models.DateTimeField(verbose_name=_(u'Expiration Date')) - _scope = models.TextField(default='', verbose_name=_(u'Scopes')) + client = models.ForeignKey(Client, verbose_name=_("Client"), on_delete=models.CASCADE) + expires_at = models.DateTimeField(verbose_name=_("Expiration Date")) + _scope = models.TextField(default="", verbose_name=_("Scopes")) class Meta: abstract = True @@ -172,7 +192,7 @@ def scope(self): @scope.setter def scope(self, value): - self._scope = ' '.join(value) + self._scope = " ".join(value) def __unicode__(self): return self.__str__() @@ -182,35 +202,36 @@ def has_expired(self): class Code(BaseCodeTokenModel): - user = models.ForeignKey( - settings.AUTH_USER_MODEL, verbose_name=_(u'User'), on_delete=models.CASCADE) - code = models.CharField(max_length=255, unique=True, verbose_name=_(u'Code')) - nonce = models.CharField(max_length=255, blank=True, default='', verbose_name=_(u'Nonce')) - is_authentication = models.BooleanField(default=False, verbose_name=_(u'Is Authentication?')) - code_challenge = models.CharField(max_length=255, null=True, verbose_name=_(u'Code Challenge')) + settings.AUTH_USER_MODEL, verbose_name=_("User"), on_delete=models.CASCADE + ) + code = models.CharField(max_length=255, unique=True, verbose_name=_("Code")) + nonce = models.CharField(max_length=255, blank=True, default="", verbose_name=_("Nonce")) + is_authentication = models.BooleanField(default=False, verbose_name=_("Is Authentication?")) + code_challenge = models.CharField(max_length=255, null=True, verbose_name=_("Code Challenge")) code_challenge_method = models.CharField( - max_length=255, null=True, verbose_name=_(u'Code Challenge Method')) + max_length=255, null=True, verbose_name=_("Code Challenge Method") + ) class Meta: - verbose_name = _(u'Authorization Code') - verbose_name_plural = _(u'Authorization Codes') + verbose_name = _("Authorization Code") + verbose_name_plural = _("Authorization Codes") def __str__(self): - return u'{0} - {1}'.format(self.client, self.code) + return "{0} - {1}".format(self.client, self.code) class Token(BaseCodeTokenModel): - user = models.ForeignKey( - settings.AUTH_USER_MODEL, null=True, verbose_name=_(u'User'), on_delete=models.CASCADE) - access_token = models.CharField(max_length=255, unique=True, verbose_name=_(u'Access Token')) - refresh_token = models.CharField(max_length=255, unique=True, verbose_name=_(u'Refresh Token')) - _id_token = models.TextField(verbose_name=_(u'ID Token')) + settings.AUTH_USER_MODEL, null=True, verbose_name=_("User"), on_delete=models.CASCADE + ) + access_token = models.CharField(max_length=255, unique=True, verbose_name=_("Access Token")) + refresh_token = models.CharField(max_length=255, unique=True, verbose_name=_("Refresh Token")) + _id_token = models.TextField(verbose_name=_("ID Token")) class Meta: - verbose_name = _(u'Token') - verbose_name_plural = _(u'Tokens') + verbose_name = _("Token") + verbose_name_plural = _("Tokens") @property def id_token(self): @@ -218,50 +239,48 @@ def id_token(self): @id_token.setter def id_token(self, value): - self._id_token = json.dumps(value) + self._id_token = json.dumps(value, cls=DjangoJSONEncoder, skipkeys=True, default=str) def __str__(self): - return u'{0} - {1}'.format(self.client, self.access_token) + return "{0} - {1}".format(self.client, self.access_token) @property def at_hash(self): # @@@ d-o-p only supports 256 bits (change this if that changes) - hashed_access_token = sha256( - self.access_token.encode('ascii') - ).hexdigest().encode('ascii') - return base64.urlsafe_b64encode( - binascii.unhexlify( - hashed_access_token[:len(hashed_access_token) // 2] + hashed_access_token = sha256(self.access_token.encode("ascii")).hexdigest().encode("ascii") + return ( + base64.urlsafe_b64encode( + binascii.unhexlify(hashed_access_token[: len(hashed_access_token) // 2]) ) - ).rstrip(b'=').decode('ascii') + .rstrip(b"=") + .decode("ascii") + ) class UserConsent(BaseCodeTokenModel): - user = models.ForeignKey( - settings.AUTH_USER_MODEL, verbose_name=_(u'User'), on_delete=models.CASCADE) - date_given = models.DateTimeField(verbose_name=_(u'Date Given')) + settings.AUTH_USER_MODEL, verbose_name=_("User"), on_delete=models.CASCADE + ) + date_given = models.DateTimeField(verbose_name=_("Date Given")) class Meta: - unique_together = ('user', 'client') + unique_together = ("user", "client") class RSAKey(models.Model): - - key = models.TextField( - verbose_name=_(u'Key'), help_text=_(u'Paste your private RSA Key here.')) + key = models.TextField(verbose_name=_("Key"), help_text=_("Paste your private RSA Key here.")) class Meta: ordering = ["id"] - verbose_name = _(u'RSA Key') - verbose_name_plural = _(u'RSA Keys') + verbose_name = _("RSA Key") + verbose_name_plural = _("RSA Keys") def __str__(self): - return u'{0}'.format(self.kid) + return "{0}".format(self.kid) def __unicode__(self): return self.__str__() @property def kid(self): - return u'{0}'.format(md5(self.key.encode('utf-8')).hexdigest() if self.key else '') + return "{0}".format(md5(self.key.encode("utf-8")).hexdigest() if self.key else "") diff --git a/oidc_provider/tests/cases/test_token_endpoint.py b/oidc_provider/tests/cases/test_token_endpoint.py index 8990d3d2..df0d6ad1 100644 --- a/oidc_provider/tests/cases/test_token_endpoint.py +++ b/oidc_provider/tests/cases/test_token_endpoint.py @@ -1,7 +1,6 @@ import json import time import uuid - from base64 import b64encode from django.db import DatabaseError @@ -18,11 +17,9 @@ from django.urls import reverse except ImportError: from django.core.urlresolvers import reverse -from django.test import ( - RequestFactory, - override_settings, -) +from django.test import RequestFactory from django.test import TestCase +from django.test import override_settings from django.views.decorators.http import require_http_methods from jwkest.jwk import KEYS from jwkest.jws import JWS @@ -33,19 +30,15 @@ from oidc_provider.lib.utils.oauth2 import protected_resource_view from oidc_provider.lib.utils.token import create_code from oidc_provider.models import Token -from oidc_provider.tests.app.utils import ( - create_fake_user, - create_fake_client, - FAKE_CODE_CHALLENGE, - FAKE_CODE_VERIFIER, - FAKE_NONCE, - FAKE_RANDOM_STRING, -) -from oidc_provider.views import ( - JwksView, - TokenView, - userinfo, -) +from oidc_provider.tests.app.utils import FAKE_CODE_CHALLENGE +from oidc_provider.tests.app.utils import FAKE_CODE_VERIFIER +from oidc_provider.tests.app.utils import FAKE_NONCE +from oidc_provider.tests.app.utils import FAKE_RANDOM_STRING +from oidc_provider.tests.app.utils import create_fake_client +from oidc_provider.tests.app.utils import create_fake_user +from oidc_provider.views import JwksView +from oidc_provider.views import TokenView +from oidc_provider.views import userinfo class TokenTestCase(TestCase): @@ -54,25 +47,26 @@ class TokenTestCase(TestCase): Token Request to the Token Endpoint to obtain a Token Response when using the Authorization Code Flow. """ - SCOPE = 'openid email' - SCOPE_LIST = SCOPE.split(' ') + + SCOPE = "openid email" + SCOPE_LIST = SCOPE.split(" ") def setUp(self): - call_command('creatersakey') + call_command("creatersakey") self.factory = RequestFactory() self.user = create_fake_user() self.request_client = self.client - self.client = create_fake_client(response_type='code') + self.client = create_fake_client(response_type="code") def _password_grant_post_data(self, scope=None): result = { - 'username': 'johndoe', - 'password': '1234', - 'grant_type': 'password', - 'scope': TokenTestCase.SCOPE, + "username": "johndoe", + "password": "1234", + "grant_type": "password", + "scope": TokenTestCase.SCOPE, } if scope is not None: - result['scope'] = ' '.join(scope) + result["scope"] = " ".join(scope) return result def _auth_code_post_data(self, code, scope=None): @@ -80,15 +74,15 @@ def _auth_code_post_data(self, code, scope=None): All the data that will be POSTed to the Token Endpoint. """ post_data = { - 'client_id': self.client.client_id, - 'client_secret': self.client.client_secret, - 'redirect_uri': self.client.default_redirect_uri, - 'grant_type': 'authorization_code', - 'code': code, - 'state': uuid.uuid4().hex, + "client_id": self.client.client_id, + "client_secret": self.client.client_secret, + "redirect_uri": self.client.default_redirect_uri, + "grant_type": "authorization_code", + "code": code, + "state": uuid.uuid4().hex, } if scope is not None: - post_data['scope'] = ' '.join(scope) + post_data["scope"] = " ".join(scope) return post_data @@ -97,24 +91,24 @@ def _refresh_token_post_data(self, refresh_token, scope=None): All the data that will be POSTed to the Token Endpoint. """ post_data = { - 'client_id': self.client.client_id, - 'client_secret': self.client.client_secret, - 'grant_type': 'refresh_token', - 'refresh_token': refresh_token, + "client_id": self.client.client_id, + "client_secret": self.client.client_secret, + "grant_type": "refresh_token", + "refresh_token": refresh_token, } if scope is not None: - post_data['scope'] = ' '.join(scope) + post_data["scope"] = " ".join(scope) return post_data def _client_credentials_post_data(self, scope=None): post_data = { - 'client_id': self.client.client_id, - 'client_secret': self.client.client_secret, - 'grant_type': 'client_credentials', + "client_id": self.client.client_id, + "client_secret": self.client.client_secret, + "grant_type": "client_credentials", } if scope is not None: - post_data['scope'] = ' '.join(scope) + post_data["scope"] = " ".join(scope) return post_data def _post_request(self, post_data, extras={}): @@ -123,13 +117,14 @@ def _post_request(self, post_data, extras={}): `post_data` parameters using the 'application/x-www-form-urlencoded' format. """ - url = reverse('oidc_provider:token') + url = reverse("oidc_provider:token") request = self.factory.post( url, data=urlencode(post_data), - content_type='application/x-www-form-urlencoded', - **extras) + content_type="application/x-www-form-urlencoded", + **extras, + ) response = TokenView.as_view()(request) @@ -144,7 +139,8 @@ def _create_code(self, scope=None): client=self.client, scope=(scope if scope else TokenTestCase.SCOPE_LIST), nonce=FAKE_NONCE, - is_authentication=True) + is_authentication=True, + ) code.save() return code @@ -153,141 +149,135 @@ def _get_keys(self): """ Get public key from discovery. """ - request = self.factory.get(reverse('oidc_provider:jwks')) + request = self.factory.get(reverse("oidc_provider:jwks")) response = JwksView.as_view()(request) - jwks_dic = json.loads(response.content.decode('utf-8')) + jwks_dic = json.loads(response.content.decode("utf-8")) SIGKEYS = KEYS() SIGKEYS.load_dict(jwks_dic) return SIGKEYS def _get_userinfo(self, access_token): - url = reverse('oidc_provider:userinfo') + url = reverse("oidc_provider:userinfo") request = self.factory.get(url) - request.META['HTTP_AUTHORIZATION'] = 'Bearer ' + access_token + request.META["HTTP_AUTHORIZATION"] = "Bearer " + access_token return userinfo(request) def _password_grant_auth_header(self): - user_pass = self.client.client_id + ':' + self.client.client_secret - auth = b'Basic ' + b64encode(user_pass.encode('utf-8')) - auth_header = {'HTTP_AUTHORIZATION': auth.decode('utf-8')} + user_pass = self.client.client_id + ":" + self.client.client_secret + auth = b"Basic " + b64encode(user_pass.encode("utf-8")) + auth_header = {"HTTP_AUTHORIZATION": auth.decode("utf-8")} return auth_header def test_default_setting_does_not_allow_grant_type_password(self): post_data = self._password_grant_post_data() response = self._post_request( - post_data=post_data, - extras=self._password_grant_auth_header() + post_data=post_data, extras=self._password_grant_auth_header() ) - response_dict = json.loads(response.content.decode('utf-8')) + response_dict = json.loads(response.content.decode("utf-8")) self.assertEqual(400, response.status_code) - self.assertEqual('unsupported_grant_type', response_dict['error']) + self.assertEqual("unsupported_grant_type", response_dict["error"]) @override_settings(OIDC_GRANT_TYPE_PASSWORD_ENABLE=True) def test_password_grant_get_access_token_without_scope(self): post_data = self._password_grant_post_data() - del (post_data['scope']) + del post_data["scope"] response = self._post_request( - post_data=post_data, - extras=self._password_grant_auth_header() + post_data=post_data, extras=self._password_grant_auth_header() ) - response_dict = json.loads(response.content.decode('utf-8')) - self.assertIn('access_token', response_dict) + response_dict = json.loads(response.content.decode("utf-8")) + self.assertIn("access_token", response_dict) @override_settings(OIDC_GRANT_TYPE_PASSWORD_ENABLE=True) def test_password_grant_get_access_token_with_scope(self): response = self._post_request( - post_data=self._password_grant_post_data(), - extras=self._password_grant_auth_header() + post_data=self._password_grant_post_data(), extras=self._password_grant_auth_header() ) - response_dict = json.loads(response.content.decode('utf-8')) - self.assertIn('access_token', response_dict) + response_dict = json.loads(response.content.decode("utf-8")) + self.assertIn("access_token", response_dict) @override_settings(OIDC_GRANT_TYPE_PASSWORD_ENABLE=True) def test_password_grant_get_access_token_invalid_user_credentials(self): invalid_post = self._password_grant_post_data() - invalid_post['password'] = 'wrong!' + invalid_post["password"] = "wrong!" response = self._post_request( - post_data=invalid_post, - extras=self._password_grant_auth_header() + post_data=invalid_post, extras=self._password_grant_auth_header() ) - response_dict = json.loads(response.content.decode('utf-8')) + response_dict = json.loads(response.content.decode("utf-8")) self.assertEqual(403, response.status_code) - self.assertEqual('access_denied', response_dict['error']) + self.assertEqual("access_denied", response_dict["error"]) def test_password_grant_get_access_token_invalid_client_credentials(self): - self.client.client_id = 'foo' - self.client.client_secret = 'bar' + self.client.client_id = "foo" + self.client.client_secret = "bar" response = self._post_request( - post_data=self._password_grant_post_data(), - extras=self._password_grant_auth_header() + post_data=self._password_grant_post_data(), extras=self._password_grant_auth_header() ) - response_dict = json.loads(response.content.decode('utf-8')) + response_dict = json.loads(response.content.decode("utf-8")) self.assertEqual(400, response.status_code) - self.assertEqual('invalid_client', response_dict['error']) + self.assertEqual("invalid_client", response_dict["error"]) def test_password_grant_full_response(self): - self.check_password_grant(scope=['openid', 'email']) + self.check_password_grant(scope=["openid", "email"]) def test_password_grant_scope(self): - scopes_list = ['openid', 'profile'] + scopes_list = ["openid", "profile"] self.client.scope = scopes_list self.client.save() self.check_password_grant(scope=scopes_list) - @override_settings(OIDC_TOKEN_EXPIRE=120, - OIDC_GRANT_TYPE_PASSWORD_ENABLE=True) + @override_settings(OIDC_TOKEN_EXPIRE=120, OIDC_GRANT_TYPE_PASSWORD_ENABLE=True) def check_password_grant(self, scope): response = self._post_request( post_data=self._password_grant_post_data(scope), - extras=self._password_grant_auth_header() + extras=self._password_grant_auth_header(), ) - response_dict = json.loads(response.content.decode('utf-8')) - id_token = JWS().verify_compact( - response_dict['id_token'].encode('utf-8'), self._get_keys()) + response_dict = json.loads(response.content.decode("utf-8")) + id_token = JWS().verify_compact(response_dict["id_token"].encode("utf-8"), self._get_keys()) token = Token.objects.get(user=self.user) - self.assertEqual(response_dict['access_token'], token.access_token) - self.assertEqual(response_dict['refresh_token'], token.refresh_token) - self.assertEqual(response_dict['expires_in'], 120) - self.assertEqual(response_dict['token_type'], 'bearer') - self.assertEqual(id_token['sub'], str(self.user.id)) - self.assertEqual(id_token['aud'], self.client.client_id) + self.assertEqual(response_dict["access_token"], token.access_token) + self.assertEqual(response_dict["refresh_token"], token.refresh_token) + self.assertEqual(response_dict["expires_in"], 120) + self.assertEqual(response_dict["token_type"], "bearer") + self.assertEqual(id_token["sub"], str(self.user.id)) + self.assertEqual(id_token["aud"], self.client.client_id) # Check the scope is honored by checking the claims in the userinfo - userinfo_response = self._get_userinfo(response_dict['access_token']) - userinfo = json.loads(userinfo_response.content.decode('utf-8')) + userinfo_response = self._get_userinfo(response_dict["access_token"]) + userinfo = json.loads(userinfo_response.content.decode("utf-8")) - for (scope_param, claim) in [('email', 'email'), ('profile', 'name')]: + for scope_param, claim in [("email", "email"), ("profile", "name")]: if scope_param in scope: self.assertIn(claim, userinfo) else: self.assertNotIn(claim, userinfo) - @override_settings(OIDC_GRANT_TYPE_PASSWORD_ENABLE=True, - AUTHENTICATION_BACKENDS=("oidc_provider.tests.app.utils.TestAuthBackend",)) + @override_settings( + OIDC_GRANT_TYPE_PASSWORD_ENABLE=True, + AUTHENTICATION_BACKENDS=("oidc_provider.tests.app.utils.TestAuthBackend",), + ) def test_password_grant_passes_request_to_backend(self): response = self._post_request( - post_data=self._password_grant_post_data(), - extras=self._password_grant_auth_header() + post_data=self._password_grant_post_data(), extras=self._password_grant_auth_header() ) - response_dict = json.loads(response.content.decode('utf-8')) - self.assertIn('access_token', response_dict) + response_dict = json.loads(response.content.decode("utf-8")) + self.assertIn("access_token", response_dict) @override_settings(OIDC_TOKEN_EXPIRE=720) def test_authorization_code(self): @@ -302,17 +292,17 @@ def test_authorization_code(self): post_data = self._auth_code_post_data(code=code.code) response = self._post_request(post_data) - response_dic = json.loads(response.content.decode('utf-8')) + response_dic = json.loads(response.content.decode("utf-8")) - id_token = JWS().verify_compact(response_dic['id_token'].encode('utf-8'), SIGKEYS) + id_token = JWS().verify_compact(response_dic["id_token"].encode("utf-8"), SIGKEYS) token = Token.objects.get(user=self.user) - self.assertEqual(response_dic['access_token'], token.access_token) - self.assertEqual(response_dic['refresh_token'], token.refresh_token) - self.assertEqual(response_dic['token_type'], 'bearer') - self.assertEqual(response_dic['expires_in'], 720) - self.assertEqual(id_token['sub'], str(self.user.id)) - self.assertEqual(id_token['aud'], self.client.client_id) + self.assertEqual(response_dic["access_token"], token.access_token) + self.assertEqual(response_dic["refresh_token"], token.refresh_token) + self.assertEqual(response_dic["token_type"], "bearer") + self.assertEqual(response_dic["expires_in"], 720) + self.assertEqual(id_token["sub"], str(self.user.id)) + self.assertEqual(id_token["aud"], self.client.client_id) @override_settings(OIDC_TOKEN_EXPIRE=720) def test_authorization_code_cant_be_reused(self): @@ -323,46 +313,44 @@ def test_authorization_code_cant_be_reused(self): code = self._create_code() post_data = self._auth_code_post_data(code=code.code) - with patch('django.db.models.query.QuerySet.select_for_update') as select_for_update_func: + with patch("django.db.models.query.QuerySet.select_for_update") as select_for_update_func: select_for_update_func.side_effect = DatabaseError() response = self._post_request(post_data) select_for_update_func.assert_called_once() self.assertEqual(response.status_code, 400) - response_dic = json.loads(response.content.decode('utf-8')) - self.assertEqual(response_dic['error'], 'invalid_grant') + response_dic = json.loads(response.content.decode("utf-8")) + self.assertEqual(response_dic["error"], "invalid_grant") - @override_settings(OIDC_TOKEN_EXPIRE=720, - OIDC_IDTOKEN_INCLUDE_CLAIMS=True) + @override_settings(OIDC_TOKEN_EXPIRE=720, OIDC_IDTOKEN_INCLUDE_CLAIMS=True) def test_scope_is_ignored_for_auth_code(self): """ Scope is ignored for token respones to auth code grant type. This comes down to that the scopes requested in authorize are returned. """ SIGKEYS = self._get_keys() - for code_scope in [['openid'], ['openid', 'email'], ['openid', 'profile']]: + for code_scope in [["openid"], ["openid", "email"], ["openid", "profile"]]: code = self._create_code(code_scope) - post_data = self._auth_code_post_data( - code=code.code, scope=code_scope) + post_data = self._auth_code_post_data(code=code.code, scope=code_scope) response = self._post_request(post_data) - response_dic = json.loads(response.content.decode('utf-8')) + response_dic = json.loads(response.content.decode("utf-8")) self.assertEqual(response.status_code, 200) - id_token = JWS().verify_compact(response_dic['id_token'].encode('utf-8'), SIGKEYS) + id_token = JWS().verify_compact(response_dic["id_token"].encode("utf-8"), SIGKEYS) - if 'email' in code_scope: - self.assertIn('email', id_token) - self.assertIn('email_verified', id_token) + if "email" in code_scope: + self.assertIn("email", id_token) + self.assertIn("email_verified", id_token) else: - self.assertNotIn('email', id_token) + self.assertNotIn("email", id_token) - if 'profile' in code_scope: - self.assertIn('given_name', id_token) + if "profile" in code_scope: + self.assertIn("given_name", id_token) else: - self.assertNotIn('given_name', id_token) + self.assertNotIn("given_name", id_token) def test_refresh_token(self): """ @@ -380,7 +368,7 @@ def test_refresh_token_invalid_scope(self): though the original authorized scope in the authorization code request is only ['openid', 'email']. """ - self.do_refresh_token_check(scope=['openid', 'profile']) + self.do_refresh_token_check(scope=["openid", "profile"]) def test_refresh_token_narrowed_scope(self): """ @@ -390,7 +378,7 @@ def test_refresh_token_narrowed_scope(self): though the original authorized scope in the authorization code request is ['openid', 'email']. """ - self.do_refresh_token_check(scope=['openid']) + self.do_refresh_token_check(scope=["openid"]) @override_settings(OIDC_IDTOKEN_INCLUDE_CLAIMS=True) def do_refresh_token_check(self, scope=None): @@ -401,70 +389,69 @@ def do_refresh_token_check(self, scope=None): self.assertEqual(code.scope, TokenTestCase.SCOPE_LIST) post_data = self._auth_code_post_data(code=code.code) start_time = time.time() - with patch('oidc_provider.lib.utils.token.time.time') as time_func: + with patch("oidc_provider.lib.utils.token.time.time") as time_func: time_func.return_value = start_time response = self._post_request(post_data) - response_dic1 = json.loads(response.content.decode('utf-8')) - id_token1 = JWS().verify_compact(response_dic1['id_token'].encode('utf-8'), SIGKEYS) + response_dic1 = json.loads(response.content.decode("utf-8")) + id_token1 = JWS().verify_compact(response_dic1["id_token"].encode("utf-8"), SIGKEYS) # Use refresh token to obtain new token - post_data = self._refresh_token_post_data( - response_dic1['refresh_token'], scope) - with patch('oidc_provider.lib.utils.token.time.time') as time_func: + post_data = self._refresh_token_post_data(response_dic1["refresh_token"], scope) + with patch("oidc_provider.lib.utils.token.time.time") as time_func: time_func.return_value = start_time + 600 response = self._post_request(post_data) - response_dic2 = json.loads(response.content.decode('utf-8')) + response_dic2 = json.loads(response.content.decode("utf-8")) if scope and set(scope) - set(code.scope): # too broad scope self.assertEqual(response.status_code, 400) # Bad Request - self.assertIn('error', response_dic2) - self.assertEqual(response_dic2['error'], 'invalid_scope') + self.assertIn("error", response_dic2) + self.assertEqual(response_dic2["error"], "invalid_scope") return # No more checks - id_token2 = JWS().verify_compact(response_dic2['id_token'].encode('utf-8'), SIGKEYS) + id_token2 = JWS().verify_compact(response_dic2["id_token"].encode("utf-8"), SIGKEYS) - if scope and 'email' not in scope: # narrowed scope The auth + if scope and "email" not in scope: # narrowed scope The auth # The auth code request had email in scope, so it should be # in the first id token - self.assertIn('email', id_token1) + self.assertIn("email", id_token1) # but the refresh request had no email in scope - self.assertNotIn('email', id_token2, 'email was not requested') + self.assertNotIn("email", id_token2, "email was not requested") - self.assertNotEqual(response_dic1['id_token'], response_dic2['id_token']) - self.assertNotEqual(response_dic1['access_token'], response_dic2['access_token']) - self.assertNotEqual(response_dic1['refresh_token'], response_dic2['refresh_token']) + self.assertNotEqual(response_dic1["id_token"], response_dic2["id_token"]) + self.assertNotEqual(response_dic1["access_token"], response_dic2["access_token"]) + self.assertNotEqual(response_dic1["refresh_token"], response_dic2["refresh_token"]) # http://openid.net/specs/openid-connect-core-1_0.html#rfc.section.12.2 - self.assertEqual(id_token1['iss'], id_token2['iss']) - self.assertEqual(id_token1['sub'], id_token2['sub']) - self.assertNotEqual(id_token1['iat'], id_token2['iat']) - self.assertEqual(id_token1['iat'], int(start_time)) - self.assertEqual(id_token2['iat'], int(start_time + 600)) - self.assertEqual(id_token1['aud'], id_token2['aud']) - self.assertEqual(id_token1['auth_time'], id_token2['auth_time']) - self.assertEqual(id_token1.get('azp'), id_token2.get('azp')) + self.assertEqual(id_token1["iss"], id_token2["iss"]) + self.assertEqual(id_token1["sub"], id_token2["sub"]) + self.assertNotEqual(id_token1["iat"], id_token2["iat"]) + self.assertEqual(id_token1["iat"], int(start_time)) + self.assertEqual(id_token2["iat"], int(start_time + 600)) + self.assertEqual(id_token1["aud"], id_token2["aud"]) + self.assertEqual(id_token1["auth_time"], id_token2["auth_time"]) + self.assertEqual(id_token1.get("azp"), id_token2.get("azp")) # Refresh token can't be reused - post_data = self._refresh_token_post_data(response_dic1['refresh_token']) + post_data = self._refresh_token_post_data(response_dic1["refresh_token"]) response = self._post_request(post_data) - self.assertIn('invalid_grant', response.content.decode('utf-8')) + self.assertIn("invalid_grant", response.content.decode("utf-8")) # Old access token is invalidated - self.assertEqual(self._get_userinfo(response_dic1['access_token']).status_code, 401) - self.assertEqual(self._get_userinfo(response_dic2['access_token']).status_code, 200) + self.assertEqual(self._get_userinfo(response_dic1["access_token"]).status_code, 401) + self.assertEqual(self._get_userinfo(response_dic2["access_token"]).status_code, 200) # Empty refresh token is invalid - post_data = self._refresh_token_post_data('') + post_data = self._refresh_token_post_data("") response = self._post_request(post_data) - self.assertIn('invalid_grant', response.content.decode('utf-8')) + self.assertIn("invalid_grant", response.content.decode("utf-8")) # No refresh token is invalid - post_data = self._refresh_token_post_data('') - del post_data['refresh_token'] + post_data = self._refresh_token_post_data("") + del post_data["refresh_token"] response = self._post_request(post_data) - self.assertIn('invalid_grant', response.content.decode('utf-8')) + self.assertIn("invalid_grant", response.content.decode("utf-8")) def test_client_redirect_uri(self): """ @@ -477,29 +464,29 @@ def test_client_redirect_uri(self): post_data = self._auth_code_post_data(code=code.code) # Unregistered URI - post_data['redirect_uri'] = 'http://invalid.example.org' + post_data["redirect_uri"] = "http://invalid.example.org" response = self._post_request(post_data) - self.assertIn('invalid_client', response.content.decode('utf-8')) + self.assertIn("invalid_client", response.content.decode("utf-8")) # Registered URI, but with query string appended - post_data['redirect_uri'] = self.client.default_redirect_uri + '?foo=bar' + post_data["redirect_uri"] = self.client.default_redirect_uri + "?foo=bar" response = self._post_request(post_data) - self.assertIn('invalid_client', response.content.decode('utf-8')) + self.assertIn("invalid_client", response.content.decode("utf-8")) # Registered URI - post_data['redirect_uri'] = self.client.default_redirect_uri + post_data["redirect_uri"] = self.client.default_redirect_uri response = self._post_request(post_data) - self.assertNotIn('invalid_client', response.content.decode('utf-8')) + self.assertNotIn("invalid_client", response.content.decode("utf-8")) def test_request_methods(self): """ Client sends an HTTP POST request to the Token Endpoint. Other request methods MUST NOT be allowed. """ - url = reverse('oidc_provider:token') + url = reverse("oidc_provider:token") requests = [ self.factory.get(url), @@ -511,16 +498,18 @@ def test_request_methods(self): response = TokenView.as_view()(request) self.assertEqual( - response.status_code, 405, - msg=request.method + ' request does not return a 405 status.') + response.status_code, + 405, + msg=request.method + " request does not return a 405 status.", + ) request = self.factory.post(url) response = TokenView.as_view()(request) self.assertEqual( - response.status_code, 400, - msg=request.method + ' request does not return a 400 status.') + response.status_code, 400, msg=request.method + " request does not return a 400 status." + ) def test_client_authentication(self): """ @@ -538,42 +527,45 @@ def test_client_authentication(self): response = self._post_request(post_data) self.assertNotIn( - 'invalid_client', - response.content.decode('utf-8'), - msg='Client authentication fails using request-body credentials.') + "invalid_client", + response.content.decode("utf-8"), + msg="Client authentication fails using request-body credentials.", + ) # Now, test with an invalid client_id. invalid_data = post_data.copy() - invalid_data['client_id'] = self.client.client_id * 2 # Fake id. + invalid_data["client_id"] = self.client.client_id * 2 # Fake id. # Create another grant code. code = self._create_code() - invalid_data['code'] = code.code + invalid_data["code"] = code.code response = self._post_request(invalid_data) self.assertIn( - 'invalid_client', - response.content.decode('utf-8'), - msg='Client authentication success with an invalid "client_id".') + "invalid_client", + response.content.decode("utf-8"), + msg='Client authentication success with an invalid "client_id".', + ) # Now, test using HTTP Basic Authentication method. basicauth_data = post_data.copy() # Create another grant code. code = self._create_code() - basicauth_data['code'] = code.code + basicauth_data["code"] = code.code - del basicauth_data['client_id'] - del basicauth_data['client_secret'] + del basicauth_data["client_id"] + del basicauth_data["client_secret"] response = self._post_request(basicauth_data, self._password_grant_auth_header()) - response.content.decode('utf-8') + response.content.decode("utf-8") self.assertNotIn( - 'invalid_client', - response.content.decode('utf-8'), - msg='Client authentication fails using HTTP Basic Auth.') + "invalid_client", + response.content.decode("utf-8"), + msg="Client authentication fails using HTTP Basic Auth.", + ) def test_access_token_contains_nonce(self): """ @@ -591,21 +583,21 @@ def test_access_token_contains_nonce(self): response = self._post_request(post_data) - response_dic = json.loads(response.content.decode('utf-8')) - id_token = JWT().unpack(response_dic['id_token'].encode('utf-8')).payload() + response_dic = json.loads(response.content.decode("utf-8")) + id_token = JWT().unpack(response_dic["id_token"].encode("utf-8")).payload() - self.assertEqual(id_token.get('nonce'), FAKE_NONCE) + self.assertEqual(id_token.get("nonce"), FAKE_NONCE) # Client does not supply a nonce parameter. - code.nonce = '' + code.nonce = "" code.save() response = self._post_request(post_data) - response_dic = json.loads(response.content.decode('utf-8')) + response_dic = json.loads(response.content.decode("utf-8")) - id_token = JWT().unpack(response_dic['id_token'].encode('utf-8')).payload() + id_token = JWT().unpack(response_dic["id_token"].encode("utf-8")).payload() - self.assertEqual(id_token.get('nonce'), None) + self.assertEqual(id_token.get("nonce"), None) def test_id_token_contains_at_hash(self): """ @@ -617,10 +609,10 @@ def test_id_token_contains_at_hash(self): response = self._post_request(post_data) - response_dic = json.loads(response.content.decode('utf-8')) - id_token = JWT().unpack(response_dic['id_token'].encode('utf-8')).payload() + response_dic = json.loads(response.content.decode("utf-8")) + id_token = JWT().unpack(response_dic["id_token"].encode("utf-8")).payload() - self.assertTrue(id_token.get('at_hash')) + self.assertTrue(id_token.get("at_hash")) def test_idtoken_sign_validation(self): """ @@ -629,19 +621,20 @@ def test_idtoken_sign_validation(self): the JOSE Header. """ SIGKEYS = self._get_keys() - RSAKEYS = [k for k in SIGKEYS if k.kty == 'RSA'] + RSAKEYS = [k for k in SIGKEYS if k.kty == "RSA"] code = self._create_code() post_data = self._auth_code_post_data(code=code.code) response = self._post_request(post_data) - response_dic = json.loads(response.content.decode('utf-8')) + response_dic = json.loads(response.content.decode("utf-8")) - JWS().verify_compact(response_dic['id_token'].encode('utf-8'), RSAKEYS) + JWS().verify_compact(response_dic["id_token"].encode("utf-8"), RSAKEYS) @override_settings( - OIDC_IDTOKEN_SUB_GENERATOR='oidc_provider.tests.app.utils.fake_sub_generator') + OIDC_IDTOKEN_SUB_GENERATOR="oidc_provider.tests.app.utils.fake_sub_generator" + ) def test_custom_sub_generator(self): """ Test custom function for setting OIDC_IDTOKEN_SUB_GENERATOR. @@ -652,35 +645,15 @@ def test_custom_sub_generator(self): response = self._post_request(post_data) - response_dic = json.loads(response.content.decode('utf-8')) - id_token = JWT().unpack(response_dic['id_token'].encode('utf-8')).payload() + response_dic = json.loads(response.content.decode("utf-8")) + id_token = JWT().unpack(response_dic["id_token"].encode("utf-8")).payload() - self.assertEqual(id_token.get('sub'), self.user.email) + self.assertEqual(id_token.get("sub"), self.user.email) @override_settings( - OIDC_IDTOKEN_PROCESSING_HOOK='oidc_provider.tests.app.utils.fake_idtoken_processing_hook') - def test_additional_idtoken_processing_hook(self): - """ - Test custom function for setting OIDC_IDTOKEN_PROCESSING_HOOK. - """ - code = self._create_code() - - post_data = self._auth_code_post_data(code=code.code) - - response = self._post_request(post_data) - - response_dic = json.loads(response.content.decode('utf-8')) - id_token = JWT().unpack(response_dic['id_token'].encode('utf-8')).payload() - - self.assertEqual(id_token.get('test_idtoken_processing_hook'), FAKE_RANDOM_STRING) - self.assertEqual(id_token.get('test_idtoken_processing_hook_user_email'), self.user.email) - - @override_settings( - OIDC_IDTOKEN_PROCESSING_HOOK=( - 'oidc_provider.tests.app.utils.fake_idtoken_processing_hook', - ) + OIDC_IDTOKEN_PROCESSING_HOOK=("oidc_provider.tests.app.utils.fake_idtoken_processing_hook",) ) - def test_additional_idtoken_processing_hook_one_element_in_tuple(self): + def test_additional_idtoken_processing_hook(self): """ Test custom function for setting OIDC_IDTOKEN_PROCESSING_HOOK. """ @@ -690,15 +663,15 @@ def test_additional_idtoken_processing_hook_one_element_in_tuple(self): response = self._post_request(post_data) - response_dic = json.loads(response.content.decode('utf-8')) - id_token = JWT().unpack(response_dic['id_token'].encode('utf-8')).payload() + response_dic = json.loads(response.content.decode("utf-8")) + id_token = JWT().unpack(response_dic["id_token"].encode("utf-8")).payload() - self.assertEqual(id_token.get('test_idtoken_processing_hook'), FAKE_RANDOM_STRING) - self.assertEqual(id_token.get('test_idtoken_processing_hook_user_email'), self.user.email) + self.assertEqual(id_token.get("test_idtoken_processing_hook"), FAKE_RANDOM_STRING) + self.assertEqual(id_token.get("test_idtoken_processing_hook_user_email"), self.user.email) @override_settings( OIDC_IDTOKEN_PROCESSING_HOOK=[ - 'oidc_provider.tests.app.utils.fake_idtoken_processing_hook', + "oidc_provider.tests.app.utils.fake_idtoken_processing_hook", ] ) def test_additional_idtoken_processing_hook_one_element_in_list(self): @@ -711,16 +684,16 @@ def test_additional_idtoken_processing_hook_one_element_in_list(self): response = self._post_request(post_data) - response_dic = json.loads(response.content.decode('utf-8')) - id_token = JWT().unpack(response_dic['id_token'].encode('utf-8')).payload() + response_dic = json.loads(response.content.decode("utf-8")) + id_token = JWT().unpack(response_dic["id_token"].encode("utf-8")).payload() - self.assertEqual(id_token.get('test_idtoken_processing_hook'), FAKE_RANDOM_STRING) - self.assertEqual(id_token.get('test_idtoken_processing_hook_user_email'), self.user.email) + self.assertEqual(id_token.get("test_idtoken_processing_hook"), FAKE_RANDOM_STRING) + self.assertEqual(id_token.get("test_idtoken_processing_hook_user_email"), self.user.email) @override_settings( OIDC_IDTOKEN_PROCESSING_HOOK=[ - 'oidc_provider.tests.app.utils.fake_idtoken_processing_hook', - 'oidc_provider.tests.app.utils.fake_idtoken_processing_hook2', + "oidc_provider.tests.app.utils.fake_idtoken_processing_hook", + "oidc_provider.tests.app.utils.fake_idtoken_processing_hook2", ] ) def test_additional_idtoken_processing_hook_two_elements_in_list(self): @@ -733,19 +706,19 @@ def test_additional_idtoken_processing_hook_two_elements_in_list(self): response = self._post_request(post_data) - response_dic = json.loads(response.content.decode('utf-8')) - id_token = JWT().unpack(response_dic['id_token'].encode('utf-8')).payload() + response_dic = json.loads(response.content.decode("utf-8")) + id_token = JWT().unpack(response_dic["id_token"].encode("utf-8")).payload() - self.assertEqual(id_token.get('test_idtoken_processing_hook'), FAKE_RANDOM_STRING) - self.assertEqual(id_token.get('test_idtoken_processing_hook_user_email'), self.user.email) + self.assertEqual(id_token.get("test_idtoken_processing_hook"), FAKE_RANDOM_STRING) + self.assertEqual(id_token.get("test_idtoken_processing_hook_user_email"), self.user.email) - self.assertEqual(id_token.get('test_idtoken_processing_hook2'), FAKE_RANDOM_STRING) - self.assertEqual(id_token.get('test_idtoken_processing_hook_user_email2'), self.user.email) + self.assertEqual(id_token.get("test_idtoken_processing_hook2"), FAKE_RANDOM_STRING) + self.assertEqual(id_token.get("test_idtoken_processing_hook_user_email2"), self.user.email) @override_settings( OIDC_IDTOKEN_PROCESSING_HOOK=( - 'oidc_provider.tests.app.utils.fake_idtoken_processing_hook', - 'oidc_provider.tests.app.utils.fake_idtoken_processing_hook2', + "oidc_provider.tests.app.utils.fake_idtoken_processing_hook", + "oidc_provider.tests.app.utils.fake_idtoken_processing_hook2", ) ) def test_additional_idtoken_processing_hook_two_elements_in_tuple(self): @@ -758,43 +731,41 @@ def test_additional_idtoken_processing_hook_two_elements_in_tuple(self): response = self._post_request(post_data) - response_dic = json.loads(response.content.decode('utf-8')) - id_token = JWT().unpack(response_dic['id_token'].encode('utf-8')).payload() + response_dic = json.loads(response.content.decode("utf-8")) + id_token = JWT().unpack(response_dic["id_token"].encode("utf-8")).payload() - self.assertEqual(id_token.get('test_idtoken_processing_hook'), FAKE_RANDOM_STRING) - self.assertEqual(id_token.get('test_idtoken_processing_hook_user_email'), self.user.email) + self.assertEqual(id_token.get("test_idtoken_processing_hook"), FAKE_RANDOM_STRING) + self.assertEqual(id_token.get("test_idtoken_processing_hook_user_email"), self.user.email) - self.assertEqual(id_token.get('test_idtoken_processing_hook2'), FAKE_RANDOM_STRING) - self.assertEqual(id_token.get('test_idtoken_processing_hook_user_email2'), self.user.email) + self.assertEqual(id_token.get("test_idtoken_processing_hook2"), FAKE_RANDOM_STRING) + self.assertEqual(id_token.get("test_idtoken_processing_hook_user_email2"), self.user.email) @override_settings( - OIDC_IDTOKEN_PROCESSING_HOOK=( - 'oidc_provider.tests.app.utils.fake_idtoken_processing_hook3')) + OIDC_IDTOKEN_PROCESSING_HOOK=("oidc_provider.tests.app.utils.fake_idtoken_processing_hook3") + ) def test_additional_idtoken_processing_hook_scope_available(self): """ Test scope is available in OIDC_IDTOKEN_PROCESSING_HOOK. """ - id_token = self._request_id_token_with_scope( - ['openid', 'email', 'profile', 'dummy']) + id_token = self._request_id_token_with_scope(["openid", "email", "profile", "dummy"]) self.assertEqual( - id_token.get('scope_of_token_passed_to_processing_hook'), - ['openid', 'email', 'profile', 'dummy']) + id_token.get("scope_of_token_passed_to_processing_hook"), + ["openid", "email", "profile", "dummy"], + ) @override_settings( - OIDC_IDTOKEN_PROCESSING_HOOK=( - 'oidc_provider.tests.app.utils.fake_idtoken_processing_hook4')) + OIDC_IDTOKEN_PROCESSING_HOOK=("oidc_provider.tests.app.utils.fake_idtoken_processing_hook4") + ) def test_additional_idtoken_processing_hook_kwargs(self): """ Test correct kwargs are passed to OIDC_IDTOKEN_PROCESSING_HOOK. """ - id_token = self._request_id_token_with_scope(['openid', 'profile']) - kwargs_passed = id_token.get('kwargs_passed_to_processing_hook') + id_token = self._request_id_token_with_scope(["openid", "profile"]) + kwargs_passed = id_token.get("kwargs_passed_to_processing_hook") assert kwargs_passed - self.assertTrue(kwargs_passed.get('token').startswith( - '") - self.assertEqual(set(kwargs_passed.keys()), {'token', 'request'}) + self.assertTrue(kwargs_passed.get("token").startswith("") + self.assertEqual(set(kwargs_passed.keys()), {"token", "request"}) def _request_id_token_with_scope(self, scope): code = self._create_code(scope) @@ -803,8 +774,8 @@ def _request_id_token_with_scope(self, scope): response = self._post_request(post_data) - response_dic = json.loads(response.content.decode('utf-8')) - id_token = JWT().unpack(response_dic['id_token'].encode('utf-8')).payload() + response_dic = json.loads(response.content.decode("utf-8")) + id_token = JWT().unpack(response_dic["id_token"].encode("utf-8")).payload() return id_token def test_pkce_parameters(self): @@ -812,19 +783,25 @@ def test_pkce_parameters(self): Test Proof Key for Code Exchange by OAuth Public Clients. https://tools.ietf.org/html/rfc7636 """ - code = create_code(user=self.user, client=self.client, - scope=['openid', 'email'], nonce=FAKE_NONCE, is_authentication=True, - code_challenge=FAKE_CODE_CHALLENGE, code_challenge_method='S256') + code = create_code( + user=self.user, + client=self.client, + scope=["openid", "email"], + nonce=FAKE_NONCE, + is_authentication=True, + code_challenge=FAKE_CODE_CHALLENGE, + code_challenge_method="S256", + ) code.save() post_data = self._auth_code_post_data(code=code.code) # Add parameters. - post_data['code_verifier'] = FAKE_CODE_VERIFIER + post_data["code_verifier"] = FAKE_CODE_VERIFIER response = self._post_request(post_data) - self.assertIn('access_token', json.loads(response.content.decode('utf-8'))) + self.assertIn("access_token", json.loads(response.content.decode("utf-8"))) def test_pkce_missing_code_verifier(self): """ @@ -832,22 +809,28 @@ def test_pkce_missing_code_verifier(self): fails when PKCE was used during the authorization request. """ - code = create_code(user=self.user, client=self.client, - scope=['openid', 'email'], nonce=FAKE_NONCE, is_authentication=True, - code_challenge=FAKE_CODE_CHALLENGE, code_challenge_method='S256') + code = create_code( + user=self.user, + client=self.client, + scope=["openid", "email"], + nonce=FAKE_NONCE, + is_authentication=True, + code_challenge=FAKE_CODE_CHALLENGE, + code_challenge_method="S256", + ) code.save() post_data = self._auth_code_post_data(code=code.code) - assert 'code_verifier' not in post_data + assert "code_verifier" not in post_data response = self._post_request(post_data) - assert json.loads(response.content.decode('utf-8')).get('error') == 'invalid_grant' + assert json.loads(response.content.decode("utf-8")).get("error") == "invalid_grant" @override_settings(OIDC_INTROSPECTION_VALIDATE_AUDIENCE_SCOPE=False) def test_client_credentials_grant_type(self): - fake_scopes_list = ['scopeone', 'scopetwo', INTROSPECTION_SCOPE] + fake_scopes_list = ["scopeone", "scopetwo", INTROSPECTION_SCOPE] # Add scope for this client. self.client.scope = fake_scopes_list @@ -855,139 +838,139 @@ def test_client_credentials_grant_type(self): post_data = self._client_credentials_post_data() response = self._post_request(post_data) - response_dict = json.loads(response.content.decode('utf-8')) + response_dict = json.loads(response.content.decode("utf-8")) # Ensure access token exists in the response, also check if scopes are # the ones we registered previously. - self.assertTrue('access_token' in response_dict) - self.assertEqual(' '.join(fake_scopes_list), response_dict['scope']) + self.assertTrue("access_token" in response_dict) + self.assertEqual(" ".join(fake_scopes_list), response_dict["scope"]) - access_token = response_dict['access_token'] + access_token = response_dict["access_token"] # Create a protected resource and test the access_token. - @require_http_methods(['GET']) + @require_http_methods(["GET"]) @protected_resource_view(fake_scopes_list) def protected_api(request, *args, **kwargs): - return JsonResponse({'protected': 'information'}, status=200) + return JsonResponse({"protected": "information"}, status=200) # Deploy view on some url. So, base url could be anything. - request = self.factory.get( - '/api/protected/?access_token={0}'.format(access_token)) + request = self.factory.get("/api/protected/?access_token={0}".format(access_token)) response = protected_api(request) - response_dict = json.loads(response.content.decode('utf-8')) + response_dict = json.loads(response.content.decode("utf-8")) self.assertEqual(response.status_code, 200) - self.assertTrue('protected' in response_dict) + self.assertTrue("protected" in response_dict) # Protected resource test ends here. # Verify access_token can be validated with token introspection response = self.request_client.post( - reverse('oidc_provider:token-introspection'), data={'token': access_token}, - **self._password_grant_auth_header()) + reverse("oidc_provider:token-introspection"), + data={"token": access_token}, + **self._password_grant_auth_header(), + ) self.assertEqual(response.status_code, 200) - response_dict = json.loads(response.content.decode('utf-8')) - self.assertTrue(response_dict.get('active')) + response_dict = json.loads(response.content.decode("utf-8")) + self.assertTrue(response_dict.get("active")) # End token introspection test # Clean scopes for this client. - self.client.scope = '' + self.client.scope = "" self.client.save() response = self._post_request(post_data) - response_dict = json.loads(response.content.decode('utf-8')) + response_dict = json.loads(response.content.decode("utf-8")) # It should fail when client does not have any scope added. self.assertEqual(400, response.status_code) - self.assertEqual('invalid_scope', response_dict['error']) + self.assertEqual("invalid_scope", response_dict["error"]) def test_printing_token_used_by_client_credentials_grant_type(self): # Add scope for this client. - self.client.scope = ['something'] + self.client.scope = ["something"] self.client.save() response = self._post_request(self._client_credentials_post_data()) - response_dict = json.loads(response.content.decode('utf-8')) - token = Token.objects.get(access_token=response_dict['access_token']) + response_dict = json.loads(response.content.decode("utf-8")) + token = Token.objects.get(access_token=response_dict["access_token"]) self.assertTrue(str(token)) @override_settings(OIDC_GRANT_TYPE_PASSWORD_ENABLE=True) def test_requested_scope(self): # GRANT_TYPE=PASSWORD response = self._post_request( - post_data=self._password_grant_post_data(['openid', 'invalid_scope']), - extras=self._password_grant_auth_header() + post_data=self._password_grant_post_data(["openid", "invalid_scope"]), + extras=self._password_grant_auth_header(), ) - response_dict = json.loads(response.content.decode('utf-8')) + response_dict = json.loads(response.content.decode("utf-8")) # It should fail when client requested an invalid scope. self.assertEqual(400, response.status_code) - self.assertEqual('invalid_scope', response_dict['error']) + self.assertEqual("invalid_scope", response_dict["error"]) # happy path: no scope response = self._post_request( - post_data=self._password_grant_post_data([]), - extras=self._password_grant_auth_header() + post_data=self._password_grant_post_data([]), extras=self._password_grant_auth_header() ) - response_dict = json.loads(response.content.decode('utf-8')) + response_dict = json.loads(response.content.decode("utf-8")) self.assertEqual(200, response.status_code) - self.assertEqual(TokenTestCase.SCOPE, response_dict['scope']) + self.assertEqual(TokenTestCase.SCOPE, response_dict["scope"]) # happy path: single scope response = self._post_request( - post_data=self._password_grant_post_data(['email']), - extras=self._password_grant_auth_header() + post_data=self._password_grant_post_data(["email"]), + extras=self._password_grant_auth_header(), ) - response_dict = json.loads(response.content.decode('utf-8')) + response_dict = json.loads(response.content.decode("utf-8")) self.assertEqual(200, response.status_code) - self.assertEqual('email', response_dict['scope']) + self.assertEqual("email", response_dict["scope"]) # happy path: multiple scopes response = self._post_request( - post_data=self._password_grant_post_data(['email', 'openid']), - extras=self._password_grant_auth_header() + post_data=self._password_grant_post_data(["email", "openid"]), + extras=self._password_grant_auth_header(), ) # GRANT_TYPE=CLIENT_CREDENTIALS - response_dict = json.loads(response.content.decode('utf-8')) + response_dict = json.loads(response.content.decode("utf-8")) self.assertEqual(200, response.status_code) - self.assertEqual('email openid', response_dict['scope']) + self.assertEqual("email openid", response_dict["scope"]) response = self._post_request( - post_data=self._client_credentials_post_data(['openid', 'invalid_scope']) + post_data=self._client_credentials_post_data(["openid", "invalid_scope"]) ) - response_dict = json.loads(response.content.decode('utf-8')) + response_dict = json.loads(response.content.decode("utf-8")) # It should fail when client requested an invalid scope. self.assertEqual(400, response.status_code) - self.assertEqual('invalid_scope', response_dict['error']) + self.assertEqual("invalid_scope", response_dict["error"]) # happy path: no scope response = self._post_request(post_data=self._client_credentials_post_data()) - response_dict = json.loads(response.content.decode('utf-8')) + response_dict = json.loads(response.content.decode("utf-8")) self.assertEqual(200, response.status_code) - self.assertEqual(TokenTestCase.SCOPE, response_dict['scope']) + self.assertEqual(TokenTestCase.SCOPE, response_dict["scope"]) # happy path: single scope - response = self._post_request(post_data=self._client_credentials_post_data(['email'])) + response = self._post_request(post_data=self._client_credentials_post_data(["email"])) - response_dict = json.loads(response.content.decode('utf-8')) + response_dict = json.loads(response.content.decode("utf-8")) self.assertEqual(200, response.status_code) - self.assertEqual('email', response_dict['scope']) + self.assertEqual("email", response_dict["scope"]) # happy path: multiple scopes response = self._post_request( - post_data=self._client_credentials_post_data(['email', 'openid']) + post_data=self._client_credentials_post_data(["email", "openid"]) ) - response_dict = json.loads(response.content.decode('utf-8')) + response_dict = json.loads(response.content.decode("utf-8")) self.assertEqual(200, response.status_code) - self.assertEqual('email openid', response_dict['scope']) + self.assertEqual("email openid", response_dict["scope"]) diff --git a/oidc_provider/tests/cases/test_utils.py b/oidc_provider/tests/cases/test_utils.py index 24c9ae65..c0e64ee7 100644 --- a/oidc_provider/tests/cases/test_utils.py +++ b/oidc_provider/tests/cases/test_utils.py @@ -1,4 +1,5 @@ import time +from datetime import date from datetime import datetime from hashlib import sha224 from unittest import mock @@ -116,6 +117,30 @@ def test_create_id_token_with_include_claims_setting_and_extra(self): self.assertIn("pizza", id_token_data) self.assertEqual(id_token_data["pizza"], "Margherita") + def test_token_saving_id_token_with_non_serialized_objects(self): + client = create_fake_client("code") + token = create_token(self.user, client, scope=["openid", "email", "pizza"]) + token.id_token = { + "iss": "http://localhost:8000/openid", + "sub": "1", + "aud": "test-aud", + "exp": 1733946683, + "iat": 1733946083, + "auth_time": 1733946082, + "email": "johndoe@example.com", + "email_verified": True, + "_extra_datetime": datetime(2002, 10, 15, 9), + "_extra_date": date(2000, 12, 25), + "_extra_object": object, + } + token.save() + + # A raw datetime/date object should be serialized. + self.assertEqual(token.id_token["_extra_datetime"], "2002-10-15 09:00:00") + self.assertEqual(token.id_token["_extra_date"], "2000-12-25") + # Even a raw object should be serialized wit str() at least. + self.assertEqual(token.id_token["_extra_object"], "") + class BrowserStateTest(TestCase): @override_settings(OIDC_UNAUTHENTICATED_SESSION_MANAGEMENT_KEY="my_static_key") diff --git a/tox.ini b/tox.ini index 088c1390..42e252d0 100644 --- a/tox.ini +++ b/tox.ini @@ -3,8 +3,9 @@ envlist= docs, py38-django{32,40,41,42}, py39-django{32,40,41,42}, - py310-django{32,40,41,42}, - py311-django{32,40,41,42}, + py310-django{32,40,41,42,50,51}, + py311-django{41,42,50,51}, + py312-django{42,50,51}, flake8 [testenv] @@ -21,6 +22,8 @@ deps = django40: django>=4.0,<4.1 django41: django>=4.1,<4.2 django42: django>=4.2,<4.3 + django50: django>=5.0,<5.1 + django51: django>=5.1,<5.2 commands = pytest --cov=oidc_provider {posargs} From dd74057bb452a7edd5af1c6a619474c352fd53c2 Mon Sep 17 00:00:00 2001 From: juanifioren Date: Wed, 11 Dec 2024 23:09:29 -0300 Subject: [PATCH 19/54] docs --- docs/sections/installation.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/sections/installation.rst b/docs/sections/installation.rst index 45f23457..bffaee0d 100644 --- a/docs/sections/installation.rst +++ b/docs/sections/installation.rst @@ -6,8 +6,8 @@ Installation Requirements ============ -* Python: ``3.8`` ``3.9`` ``3.10`` ``3.11`` -* Django: ``3.2`` ``4.2`` +* Python: ``3.8`` ``3.9`` ``3.10`` ``3.11`` ``3.12`` +* Django: ``3.2`` ``4.2`` ``5.1`` Quick Installation ================== From 67f1d8958316dbcd46b45730747c2c3baa7fa7c6 Mon Sep 17 00:00:00 2001 From: juanifioren Date: Wed, 11 Dec 2024 23:40:39 -0300 Subject: [PATCH 20/54] tox --- tox.ini | 1 - 1 file changed, 1 deletion(-) diff --git a/tox.ini b/tox.ini index 42e252d0..5a22b168 100644 --- a/tox.ini +++ b/tox.ini @@ -5,7 +5,6 @@ envlist= py39-django{32,40,41,42}, py310-django{32,40,41,42,50,51}, py311-django{41,42,50,51}, - py312-django{42,50,51}, flake8 [testenv] From 41f83060f7dfba21631029923321d004b4a3d060 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Thu, 12 Dec 2024 09:33:09 -0300 Subject: [PATCH 21/54] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4e78e9aa..78ffc419 100644 --- a/README.md +++ b/README.md @@ -21,4 +21,4 @@ Support for Python 3 and latest versions of django. ## Thanks to our sponsors -[![Agilentia](https://avatars.githubusercontent.com/u/1707212?s=60&v=4)]([https://github.com/agilentia) +[![Agilentia](https://avatars.githubusercontent.com/u/1707212?s=60&v=4)](https://github.com/agilentia) From d1be6fe3d2a1c3900da81e391ea57302be57e9a6 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Fri, 13 Dec 2024 10:02:47 -0300 Subject: [PATCH 22/54] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 78ffc419..39b12ec7 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Django OpenID Connect Provider [![Python Versions](https://img.shields.io/pypi/pyversions/django-oidc-provider.svg)](https://pypi.python.org/pypi/django-oidc-provider) -[![Django Versions](https://img.shields.io/badge/Django-3.2%20%7C%204.2-green)](https://pypi.python.org/pypi/django-oidc-provider) +[![Django Versions](https://img.shields.io/badge/Django-3.2%20%7C%204.2%20%7C%205.1-green)](https://pypi.python.org/pypi/django-oidc-provider) [![PyPI Versions](https://img.shields.io/pypi/v/django-oidc-provider.svg)](https://pypi.python.org/pypi/django-oidc-provider) [![Documentation Status](https://readthedocs.org/projects/django-oidc-provider/badge/?version=master)](http://django-oidc-provider.readthedocs.io/) From 836b49b4a225df53988efc2bf894c24d505cc018 Mon Sep 17 00:00:00 2001 From: juanifioren Date: Fri, 13 Dec 2024 13:16:30 -0300 Subject: [PATCH 23/54] Docs improvement. --- docs/sections/changelog.rst | 3 ++- docs/sections/contribute.rst | 2 +- docs/sections/templates.rst | 23 +++++++++++++++++++++-- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/docs/sections/changelog.rst b/docs/sections/changelog.rst index 0dceca8d..1f9ec227 100644 --- a/docs/sections/changelog.rst +++ b/docs/sections/changelog.rst @@ -8,7 +8,8 @@ All notable changes to this project will be documented in this file. Unreleased ========== -None +* Changed: Django 5 added to test matrix. +* Changed: ID Token JSON encoder improved using DjangoJSONEncoder. 0.8.3 ===== diff --git a/docs/sections/contribute.rst b/docs/sections/contribute.rst index 121cc883..d08cd033 100644 --- a/docs/sections/contribute.rst +++ b/docs/sections/contribute.rst @@ -34,7 +34,7 @@ Improve Documentation We use `Sphinx `_ to generate this documentation. If you want to add or modify something just: -* Install Sphinx (``pip install sphinx sphinx_rtd_theme``) and the auto-build tool (``pip install sphinx-autobuild``). +* Install Sphinx and the auto-build tool (``pip install sphinx sphinx_rtd_theme sphinx-autobuild``). * Move inside the docs folder. ``cd docs/`` * Generate and watch docs by running ``sphinx-autobuild . _build/``. * Open ``http://127.0.0.1:8000`` in a browser. diff --git a/docs/sections/templates.rst b/docs/sections/templates.rst index bb1f5fa7..aff66ac0 100644 --- a/docs/sections/templates.rst +++ b/docs/sections/templates.rst @@ -6,7 +6,9 @@ Templates Add your own templates files inside a folder named ``templates/oidc_provider/``. You can copy the sample html files here and customize them with your own style. -**authorize.html**:: +authorize.html +============== +::

Request for Permission

@@ -29,7 +31,9 @@ You can copy the sample html files here and customize them with your own style. -**error.html**:: +error.html +========== +::

{{ error }}

{{ description }}

@@ -51,3 +55,18 @@ The following contexts will be passed to the ``authorize`` and ``error`` templat 'error': 'string stating the error', 'description': 'string stating description of the error' } + +end_session_prompt.html +======================= + +Read more at :doc:`Session Management > Logout consent prompt ` section. + +end_session_completed.html +========================== + +Read more at :doc:`Session Management > Other scenarios <../sections/sessionmanagement>` section. + +end_session_failed.html +======================= + +Read more at :doc:`Session Management > Other scenarios <../sections/sessionmanagement>` section. From e7b80a70808a17bd93623373d981561a611e9c9d Mon Sep 17 00:00:00 2001 From: juanifioren Date: Sat, 14 Dec 2024 12:21:23 -0300 Subject: [PATCH 24/54] Replace mock with unittest.mock --- .../tests/cases/test_authorize_endpoint.py | 687 +++++++++--------- .../tests/cases/test_end_session_endpoint.py | 23 +- .../cases/test_introspection_endpoint.py | 94 +-- oidc_provider/tests/cases/test_middleware.py | 39 +- .../cases/test_provider_info_endpoint.py | 26 +- .../tests/cases/test_token_endpoint.py | 11 +- 6 files changed, 451 insertions(+), 429 deletions(-) diff --git a/oidc_provider/tests/cases/test_authorize_endpoint.py b/oidc_provider/tests/cases/test_authorize_endpoint.py index 7ccd0a23..e5cfeb11 100644 --- a/oidc_provider/tests/cases/test_authorize_endpoint.py +++ b/oidc_provider/tests/cases/test_authorize_endpoint.py @@ -1,56 +1,57 @@ try: - from urllib.parse import urlencode, quote + from urllib.parse import quote + from urllib.parse import urlencode except ImportError: - from urllib import urlencode, quote + from urllib import quote + from urllib import urlencode try: - from urllib.parse import parse_qs, urlsplit + from urllib.parse import parse_qs + from urllib.parse import urlsplit except ImportError: - from urlparse import parse_qs, urlsplit + from urlparse import parse_qs + from urlparse import urlsplit import uuid -from mock import patch, mock +from unittest.mock import Mock +from unittest.mock import patch from django.contrib.auth.models import AnonymousUser from django.core.management import call_command + try: from django.urls import reverse except ImportError: from django.core.urlresolvers import reverse -from django.test import ( - RequestFactory, - override_settings, -) +from django.test import RequestFactory from django.test import TestCase +from django.test import override_settings from jwkest.jwt import JWT from oidc_provider import settings from oidc_provider.lib.endpoints.authorize import AuthorizeEndpoint from oidc_provider.lib.errors import RedirectUriError from oidc_provider.lib.utils.authorize import strip_prompt_login -from oidc_provider.tests.app.utils import ( - create_fake_user, - create_fake_client, - FAKE_CODE_CHALLENGE, - is_code_valid, -) +from oidc_provider.tests.app.utils import FAKE_CODE_CHALLENGE +from oidc_provider.tests.app.utils import create_fake_client +from oidc_provider.tests.app.utils import create_fake_user +from oidc_provider.tests.app.utils import is_code_valid from oidc_provider.views import AuthorizeView class AuthorizeEndpointMixin(object): - def _auth_request(self, method, data=None, is_user_authenticated=False): if data is None: data = {} - url = reverse('oidc_provider:authorize') + url = reverse("oidc_provider:authorize") - if method.lower() == 'get': - query_str = urlencode(data).replace('+', '%20') + if method.lower() == "get": + query_str = urlencode(data).replace("+", "%20") if query_str: - url += '?' + query_str + url += "?" + query_str request = self.factory.get(url) - elif method.lower() == 'post': + elif method.lower() == "post": request = self.factory.post(url, data=data) else: - raise Exception('Method unsupported for an Authorization Request.') + raise Exception("Method unsupported for an Authorization Request.") # Simulate that the user is logged. request.user = self.user if is_user_authenticated else AnonymousUser() @@ -66,15 +67,17 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin): """ def setUp(self): - call_command('creatersakey') + call_command("creatersakey") self.factory = RequestFactory() self.user = create_fake_user() - self.client = create_fake_client(response_type='code') + self.client = create_fake_client(response_type="code") self.client_with_no_consent = create_fake_client( - response_type='code', require_consent=False) - self.client_public = create_fake_client(response_type='code', is_public=True) + response_type="code", require_consent=False + ) + self.client_public = create_fake_client(response_type="code", is_public=True) self.client_public_with_no_consent = create_fake_client( - response_type='code', is_public=True, require_consent=False) + response_type="code", is_public=True, require_consent=False + ) self.state = uuid.uuid4().hex self.nonce = uuid.uuid4().hex @@ -86,7 +89,7 @@ def test_missing_parameters(self): See: https://tools.ietf.org/html/rfc6749#section-4.1.2.1 """ - response = self._auth_request('get') + response = self._auth_request("get") self.assertEqual(response.status_code, 200) self.assertEqual(bool(response.content), True) @@ -100,20 +103,20 @@ def test_invalid_response_type(self): """ # Create an authorize request with an unsupported response_type. data = { - 'client_id': self.client.client_id, - 'response_type': 'something_wrong', - 'redirect_uri': self.client.default_redirect_uri, - 'scope': 'openid email', - 'state': self.state, + "client_id": self.client.client_id, + "response_type": "something_wrong", + "redirect_uri": self.client.default_redirect_uri, + "scope": "openid email", + "state": self.state, } - response = self._auth_request('get', data) + response = self._auth_request("get", data) self.assertEqual(response.status_code, 302) - self.assertEqual(response.has_header('Location'), True) + self.assertEqual(response.has_header("Location"), True) # Should be an 'error' component in query. - self.assertIn('error=', response['Location']) + self.assertIn("error=", response["Location"]) def test_user_not_logged(self): """ @@ -123,17 +126,17 @@ def test_user_not_logged(self): See: http://openid.net/specs/openid-connect-core-1_0.html#Authenticates """ data = { - 'client_id': self.client.client_id, - 'response_type': 'code', - 'redirect_uri': self.client.default_redirect_uri, - 'scope': 'openid email', - 'state': self.state, + "client_id": self.client.client_id, + "response_type": "code", + "redirect_uri": self.client.default_redirect_uri, + "scope": "openid email", + "state": self.state, } - response = self._auth_request('get', data) + response = self._auth_request("get", data) # Check if user was redirected to the login view. - self.assertIn(settings.get('OIDC_LOGIN_URL'), response['Location']) + self.assertIn(settings.get("OIDC_LOGIN_URL"), response["Location"]) def test_user_consent_inputs(self): """ @@ -144,32 +147,32 @@ def test_user_consent_inputs(self): See: http://openid.net/specs/openid-connect-core-1_0.html#Consent """ data = { - 'client_id': self.client.client_id, - 'response_type': 'code', - 'redirect_uri': self.client.default_redirect_uri, - 'scope': 'openid email', - 'state': self.state, + "client_id": self.client.client_id, + "response_type": "code", + "redirect_uri": self.client.default_redirect_uri, + "scope": "openid email", + "state": self.state, # PKCE parameters. - 'code_challenge': FAKE_CODE_CHALLENGE, - 'code_challenge_method': 'S256', + "code_challenge": FAKE_CODE_CHALLENGE, + "code_challenge_method": "S256", } - response = self._auth_request('get', data, is_user_authenticated=True) + response = self._auth_request("get", data, is_user_authenticated=True) # Check if hidden inputs exists in the form, # also if their values are valid. input_html = '' to_check = { - 'client_id': self.client.client_id, - 'redirect_uri': self.client.default_redirect_uri, - 'response_type': 'code', - 'code_challenge': FAKE_CODE_CHALLENGE, - 'code_challenge_method': 'S256', + "client_id": self.client.client_id, + "redirect_uri": self.client.default_redirect_uri, + "response_type": "code", + "code_challenge": FAKE_CODE_CHALLENGE, + "code_challenge_method": "S256", } for key, value in iter(to_check.items()): - is_input_ok = input_html.format(key, value) in response.content.decode('utf-8') + is_input_ok = input_html.format(key, value) in response.content.decode("utf-8") self.assertEqual(is_input_ok, True, msg='Hidden input for "' + key + '" fails.') def test_user_consent_response(self): @@ -185,37 +188,36 @@ def test_user_consent_response(self): by adding them as query parameters to the redirect_uri. """ data = { - 'client_id': self.client.client_id, - 'redirect_uri': self.client.default_redirect_uri, - 'response_type': 'code', - 'scope': 'openid email', - 'state': self.state, + "client_id": self.client.client_id, + "redirect_uri": self.client.default_redirect_uri, + "response_type": "code", + "scope": "openid email", + "state": self.state, # PKCE parameters. - 'code_challenge': FAKE_CODE_CHALLENGE, - 'code_challenge_method': 'S256', + "code_challenge": FAKE_CODE_CHALLENGE, + "code_challenge_method": "S256", } - response = self._auth_request('post', data, is_user_authenticated=True) + response = self._auth_request("post", data, is_user_authenticated=True) # Because user doesn't allow app, SHOULD exists an error parameter # in the query. - self.assertIn('error=', response['Location'], msg='error param is missing in query.') + self.assertIn("error=", response["Location"], msg="error param is missing in query.") self.assertIn( - 'access_denied', response['Location'], msg='"access_denied" code is missing in query.') + "access_denied", response["Location"], msg='"access_denied" code is missing in query.' + ) # Simulate user authorization. - data['allow'] = 'Accept' # Will be the value of the button. + data["allow"] = "Accept" # Will be the value of the button. - response = self._auth_request('post', data, is_user_authenticated=True) + response = self._auth_request("post", data, is_user_authenticated=True) - is_code_ok = is_code_valid(url=response['Location'], - user=self.user, - client=self.client) - self.assertEqual(is_code_ok, True, msg='Code returned is invalid.') + is_code_ok = is_code_valid(url=response["Location"], user=self.user, client=self.client) + self.assertEqual(is_code_ok, True, msg="Code returned is invalid.") # Check if the state is returned. - state = (response['Location'].split('state='))[1].split('&')[0] - self.assertEqual(state, self.state, msg='State change or is missing.') + state = (response["Location"].split("state="))[1].split("&")[0] + self.assertEqual(state, self.state, msg="State change or is missing.") def test_user_consent_skipped(self): """ @@ -224,37 +226,36 @@ def test_user_consent_skipped(self): authorization multiple times, the server skip it. """ data = { - 'client_id': self.client_with_no_consent.client_id, - 'redirect_uri': self.client_with_no_consent.default_redirect_uri, - 'response_type': 'code', - 'scope': 'openid email', - 'state': self.state, - 'allow': 'Accept', + "client_id": self.client_with_no_consent.client_id, + "redirect_uri": self.client_with_no_consent.default_redirect_uri, + "response_type": "code", + "scope": "openid email", + "state": self.state, + "allow": "Accept", } - request = self.factory.post(reverse('oidc_provider:authorize'), - data=data) + request = self.factory.post(reverse("oidc_provider:authorize"), data=data) # Simulate that the user is logged. request.user = self.user - response = self._auth_request('post', data, is_user_authenticated=True) + response = self._auth_request("post", data, is_user_authenticated=True) - self.assertIn('code', response['Location'], msg='Code is missing in the returned url.') + self.assertIn("code", response["Location"], msg="Code is missing in the returned url.") - response = self._auth_request('post', data, is_user_authenticated=True) + response = self._auth_request("post", data, is_user_authenticated=True) - is_code_ok = is_code_valid(url=response['Location'], - user=self.user, - client=self.client_with_no_consent) - self.assertEqual(is_code_ok, True, msg='Code returned is invalid.') + is_code_ok = is_code_valid( + url=response["Location"], user=self.user, client=self.client_with_no_consent + ) + self.assertEqual(is_code_ok, True, msg="Code returned is invalid.") - del data['allow'] - response = self._auth_request('get', data, is_user_authenticated=True) + del data["allow"] + response = self._auth_request("get", data, is_user_authenticated=True) - is_code_ok = is_code_valid(url=response['Location'], - user=self.user, - client=self.client_with_no_consent) - self.assertEqual(is_code_ok, True, msg='Code returned is invalid or missing.') + is_code_ok = is_code_valid( + url=response["Location"], user=self.user, client=self.client_with_no_consent + ) + self.assertEqual(is_code_ok, True, msg="Code returned is invalid or missing.") def test_response_uri_is_properly_constructed(self): """ @@ -262,33 +263,34 @@ def test_response_uri_is_properly_constructed(self): Only 'state' and 'code' should be appended. """ data = { - 'client_id': self.client.client_id, - 'redirect_uri': self.client.default_redirect_uri, - 'response_type': 'code', - 'scope': 'openid email', - 'state': self.state, - 'allow': 'Accept', + "client_id": self.client.client_id, + "redirect_uri": self.client.default_redirect_uri, + "response_type": "code", + "scope": "openid email", + "state": self.state, + "allow": "Accept", } - response = self._auth_request('post', data, is_user_authenticated=True) + response = self._auth_request("post", data, is_user_authenticated=True) - parsed = urlsplit(response['Location']) + parsed = urlsplit(response["Location"]) params = parse_qs(parsed.query or parsed.fragment) - state = params['state'][0] + state = params["state"][0] self.assertEqual(self.state, state, msg="State returned is invalid or missing") - is_code_ok = is_code_valid(url=response['Location'], - user=self.user, - client=self.client) - self.assertTrue(is_code_ok, msg='Code returned is invalid or missing') + is_code_ok = is_code_valid(url=response["Location"], user=self.user, client=self.client) + self.assertTrue(is_code_ok, msg="Code returned is invalid or missing") self.assertEqual( - set(params.keys()), {'state', 'code'}, - msg='More than state or code appended as query params') + set(params.keys()), + {"state", "code"}, + msg="More than state or code appended as query params", + ) self.assertTrue( - response['Location'].startswith(self.client.default_redirect_uri), - msg='Different redirect_uri returned') + response["Location"].startswith(self.client.default_redirect_uri), + msg="Different redirect_uri returned", + ) def test_unknown_redirect_uris_are_rejected(self): """ @@ -296,16 +298,17 @@ def test_unknown_redirect_uris_are_rejected(self): See http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest. """ data = { - 'client_id': self.client.client_id, - 'response_type': 'code', - 'redirect_uri': 'http://neverseenthis.com', - 'scope': 'openid email', - 'state': self.state, + "client_id": self.client.client_id, + "response_type": "code", + "redirect_uri": "http://neverseenthis.com", + "scope": "openid email", + "state": self.state, } - response = self._auth_request('get', data) + response = self._auth_request("get", data) self.assertIn( - RedirectUriError.error, response.content.decode('utf-8'), msg='No redirect_uri error') + RedirectUriError.error, response.content.decode("utf-8"), msg="No redirect_uri error" + ) def test_manipulated_redirect_uris_are_rejected(self): """ @@ -313,16 +316,17 @@ def test_manipulated_redirect_uris_are_rejected(self): See http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest. """ data = { - 'client_id': self.client.client_id, - 'response_type': 'code', - 'redirect_uri': self.client.default_redirect_uri + "?some=query", - 'scope': 'openid email', - 'state': self.state, + "client_id": self.client.client_id, + "response_type": "code", + "redirect_uri": self.client.default_redirect_uri + "?some=query", + "scope": "openid email", + "state": self.state, } - response = self._auth_request('get', data) + response = self._auth_request("get", data) self.assertIn( - RedirectUriError.error, response.content.decode('utf-8'), msg='No redirect_uri error') + RedirectUriError.error, response.content.decode("utf-8"), msg="No redirect_uri error" + ) def test_public_client_auto_approval(self): """ @@ -330,16 +334,16 @@ def test_public_client_auto_approval(self): clients using Authorization Code. """ data = { - 'client_id': self.client_public_with_no_consent.client_id, - 'response_type': 'code', - 'redirect_uri': self.client_public_with_no_consent.default_redirect_uri, - 'scope': 'openid email', - 'state': self.state, + "client_id": self.client_public_with_no_consent.client_id, + "response_type": "code", + "redirect_uri": self.client_public_with_no_consent.default_redirect_uri, + "scope": "openid email", + "state": self.state, } - response = self._auth_request('get', data, is_user_authenticated=True) + response = self._auth_request("get", data, is_user_authenticated=True) - self.assertIn('Request for Permission', response.content.decode('utf-8')) + self.assertIn("Request for Permission", response.content.decode("utf-8")) def test_prompt_none_parameter(self): """ @@ -348,26 +352,26 @@ def test_prompt_none_parameter(self): See: http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest """ data = { - 'client_id': self.client.client_id, - 'response_type': next(self.client.response_type_values()), - 'redirect_uri': self.client.default_redirect_uri, - 'scope': 'openid email', - 'state': self.state, - 'prompt': 'none' + "client_id": self.client.client_id, + "response_type": next(self.client.response_type_values()), + "redirect_uri": self.client.default_redirect_uri, + "scope": "openid email", + "state": self.state, + "prompt": "none", } - response = self._auth_request('get', data) + response = self._auth_request("get", data) # An error is returned if an End-User is not already authenticated. - self.assertIn('login_required', response['Location']) + self.assertIn("login_required", response["Location"]) - response = self._auth_request('get', data, is_user_authenticated=True) + response = self._auth_request("get", data, is_user_authenticated=True) # An error is returned if the Client does not have pre-configured # consent for the requested Claims. - self.assertIn('consent_required', response['Location']) + self.assertIn("consent_required", response["Location"]) - @patch('oidc_provider.views.django_user_logout') + @patch("oidc_provider.views.django_user_logout") def test_prompt_login_parameter(self, logout_function): """ Specifies whether the Authorization Server prompts the End-User for @@ -375,31 +379,31 @@ def test_prompt_login_parameter(self, logout_function): See: http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest """ data = { - 'client_id': self.client.client_id, - 'response_type': next(self.client.response_type_values()), - 'redirect_uri': self.client.default_redirect_uri, - 'scope': 'openid email', - 'state': self.state, - 'prompt': 'login' + "client_id": self.client.client_id, + "response_type": next(self.client.response_type_values()), + "redirect_uri": self.client.default_redirect_uri, + "scope": "openid email", + "state": self.state, + "prompt": "login", } - response = self._auth_request('get', data) - self.assertIn(settings.get('OIDC_LOGIN_URL'), response['Location']) + response = self._auth_request("get", data) + self.assertIn(settings.get("OIDC_LOGIN_URL"), response["Location"]) self.assertNotIn( - quote('prompt=login'), - response['Location'], + quote("prompt=login"), + response["Location"], "Found prompt=login, this leads to infinite login loop. See " - "https://github.com/juanifioren/django-oidc-provider/issues/197." + "https://github.com/juanifioren/django-oidc-provider/issues/197.", ) - response = self._auth_request('get', data, is_user_authenticated=True) - self.assertIn(settings.get('OIDC_LOGIN_URL'), response['Location']) + response = self._auth_request("get", data, is_user_authenticated=True) + self.assertIn(settings.get("OIDC_LOGIN_URL"), response["Location"]) logout_function.assert_called_once() self.assertNotIn( - quote('prompt=login'), - response['Location'], + quote("prompt=login"), + response["Location"], "Found prompt=login, this leads to infinite login loop. See " - "https://github.com/juanifioren/django-oidc-provider/issues/197." + "https://github.com/juanifioren/django-oidc-provider/issues/197.", ) def test_prompt_login_none_parameter(self): @@ -409,21 +413,21 @@ def test_prompt_login_none_parameter(self): See: http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest """ data = { - 'client_id': self.client.client_id, - 'response_type': next(self.client.response_type_values()), - 'redirect_uri': self.client.default_redirect_uri, - 'scope': 'openid email', - 'state': self.state, - 'prompt': 'login none' + "client_id": self.client.client_id, + "response_type": next(self.client.response_type_values()), + "redirect_uri": self.client.default_redirect_uri, + "scope": "openid email", + "state": self.state, + "prompt": "login none", } - response = self._auth_request('get', data) - self.assertIn('login_required', response['Location']) + response = self._auth_request("get", data) + self.assertIn("login_required", response["Location"]) - response = self._auth_request('get', data, is_user_authenticated=True) - self.assertIn('login_required', response['Location']) + response = self._auth_request("get", data, is_user_authenticated=True) + self.assertIn("login_required", response["Location"]) - @patch('oidc_provider.views.render') + @patch("oidc_provider.views.render") def test_prompt_consent_parameter(self, render_patched): """ Specifies whether the Authorization Server prompts the End-User for @@ -431,21 +435,20 @@ def test_prompt_consent_parameter(self, render_patched): See: http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest """ data = { - 'client_id': self.client.client_id, - 'response_type': next(self.client.response_type_values()), - 'redirect_uri': self.client.default_redirect_uri, - 'scope': 'openid email', - 'state': self.state, - 'prompt': 'consent' + "client_id": self.client.client_id, + "response_type": next(self.client.response_type_values()), + "redirect_uri": self.client.default_redirect_uri, + "scope": "openid email", + "state": self.state, + "prompt": "consent", } - response = self._auth_request('get', data) - self.assertIn(settings.get('OIDC_LOGIN_URL'), response['Location']) + response = self._auth_request("get", data) + self.assertIn(settings.get("OIDC_LOGIN_URL"), response["Location"]) - response = self._auth_request('get', data, is_user_authenticated=True) + response = self._auth_request("get", data, is_user_authenticated=True) render_patched.assert_called_once() - self.assertTrue( - render_patched.call_args[0][1], settings.get('OIDC_TEMPLATES')['authorize']) + self.assertTrue(render_patched.call_args[0][1], settings.get("OIDC_TEMPLATES")["authorize"]) def test_prompt_consent_none_parameter(self): """ @@ -454,47 +457,48 @@ def test_prompt_consent_none_parameter(self): See: http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest """ data = { - 'client_id': self.client.client_id, - 'response_type': next(self.client.response_type_values()), - 'redirect_uri': self.client.default_redirect_uri, - 'scope': 'openid email', - 'state': self.state, - 'prompt': 'consent none' + "client_id": self.client.client_id, + "response_type": next(self.client.response_type_values()), + "redirect_uri": self.client.default_redirect_uri, + "scope": "openid email", + "state": self.state, + "prompt": "consent none", } - response = self._auth_request('get', data) - self.assertIn('login_required', response['Location']) + response = self._auth_request("get", data) + self.assertIn("login_required", response["Location"]) - response = self._auth_request('get', data, is_user_authenticated=True) - self.assertIn('consent_required', response['Location']) + response = self._auth_request("get", data, is_user_authenticated=True) + self.assertIn("consent_required", response["Location"]) def test_strip_prompt_login(self): """ Test for helper method test_strip_prompt_login. """ # Original paths - path0 = 'http://idp.com/?prompt=login' - path1 = 'http://idp.com/?prompt=consent login none' - path2 = ('http://idp.com/?response_type=code&client' + - '_id=112233&prompt=consent login') - path3 = ('http://idp.com/?response_type=code&client' + - '_id=112233&prompt=login none&redirect_uri' + - '=http://localhost:8000') + path0 = "http://idp.com/?prompt=login" + path1 = "http://idp.com/?prompt=consent login none" + path2 = "http://idp.com/?response_type=code&client" + "_id=112233&prompt=consent login" + path3 = ( + "http://idp.com/?response_type=code&client" + + "_id=112233&prompt=login none&redirect_uri" + + "=http://localhost:8000" + ) - self.assertNotIn('prompt', strip_prompt_login(path0)) + self.assertNotIn("prompt", strip_prompt_login(path0)) - self.assertIn('prompt', strip_prompt_login(path1)) - self.assertIn('consent', strip_prompt_login(path1)) - self.assertIn('none', strip_prompt_login(path1)) - self.assertNotIn('login', strip_prompt_login(path1)) + self.assertIn("prompt", strip_prompt_login(path1)) + self.assertIn("consent", strip_prompt_login(path1)) + self.assertIn("none", strip_prompt_login(path1)) + self.assertNotIn("login", strip_prompt_login(path1)) - self.assertIn('prompt', strip_prompt_login(path2)) - self.assertIn('consent', strip_prompt_login(path1)) - self.assertNotIn('login', strip_prompt_login(path2)) + self.assertIn("prompt", strip_prompt_login(path2)) + self.assertIn("consent", strip_prompt_login(path1)) + self.assertNotIn("login", strip_prompt_login(path2)) - self.assertIn('prompt', strip_prompt_login(path3)) - self.assertIn('none', strip_prompt_login(path3)) - self.assertNotIn('login', strip_prompt_login(path3)) + self.assertIn("prompt", strip_prompt_login(path3)) + self.assertIn("none", strip_prompt_login(path3)) + self.assertNotIn("login", strip_prompt_login(path3)) class AuthorizationImplicitFlowTestCase(TestCase, AuthorizeEndpointMixin): @@ -503,18 +507,19 @@ class AuthorizationImplicitFlowTestCase(TestCase, AuthorizeEndpointMixin): """ def setUp(self): - call_command('creatersakey') + call_command("creatersakey") self.factory = RequestFactory() self.user = create_fake_user() - self.client = create_fake_client(response_type='id_token token') - self.client_public = create_fake_client(response_type='id_token token', is_public=True) + self.client = create_fake_client(response_type="id_token token") + self.client_public = create_fake_client(response_type="id_token token", is_public=True) self.client_public_no_consent = create_fake_client( - response_type='id_token token', is_public=True, - require_consent=False) - self.client_no_access = create_fake_client(response_type='id_token') - self.client_public_no_access = create_fake_client(response_type='id_token', is_public=True) + response_type="id_token token", is_public=True, require_consent=False + ) + self.client_no_access = create_fake_client(response_type="id_token") + self.client_public_no_access = create_fake_client(response_type="id_token", is_public=True) self.client_multiple_response_types = create_fake_client( - response_type=('id_token', 'id_token token')) + response_type=("id_token", "id_token token") + ) self.state = uuid.uuid4().hex self.nonce = uuid.uuid4().hex @@ -523,16 +528,16 @@ def test_missing_nonce(self): The `nonce` parameter is REQUIRED if you use the Implicit Flow. """ data = { - 'client_id': self.client.client_id, - 'response_type': next(self.client.response_type_values()), - 'redirect_uri': self.client.default_redirect_uri, - 'scope': 'openid email', - 'state': self.state, + "client_id": self.client.client_id, + "response_type": next(self.client.response_type_values()), + "redirect_uri": self.client.default_redirect_uri, + "scope": "openid email", + "state": self.state, } - response = self._auth_request('get', data, is_user_authenticated=True) + response = self._auth_request("get", data, is_user_authenticated=True) - self.assertIn('#error=invalid_request', response['Location']) + self.assertIn("#error=invalid_request", response["Location"]) def test_idtoken_token_response(self): """ @@ -540,29 +545,29 @@ def test_idtoken_token_response(self): and access token as the result of the authorization request. """ data = { - 'client_id': self.client.client_id, - 'redirect_uri': self.client.default_redirect_uri, - 'response_type': next(self.client.response_type_values()), - 'scope': 'openid email', - 'state': self.state, - 'nonce': self.nonce, - 'allow': 'Accept', + "client_id": self.client.client_id, + "redirect_uri": self.client.default_redirect_uri, + "response_type": next(self.client.response_type_values()), + "scope": "openid email", + "state": self.state, + "nonce": self.nonce, + "allow": "Accept", } - response = self._auth_request('post', data, is_user_authenticated=True) + response = self._auth_request("post", data, is_user_authenticated=True) - self.assertIn('access_token', response['Location']) - self.assertIn('id_token', response['Location']) + self.assertIn("access_token", response["Location"]) + self.assertIn("id_token", response["Location"]) # same for public client - data['client_id'] = self.client_public.client_id, - data['redirect_uri'] = self.client_public.default_redirect_uri, - data['response_type'] = next(self.client_public.response_type_values()), + data["client_id"] = (self.client_public.client_id,) + data["redirect_uri"] = (self.client_public.default_redirect_uri,) + data["response_type"] = (next(self.client_public.response_type_values()),) - response = self._auth_request('post', data, is_user_authenticated=True) + response = self._auth_request("post", data, is_user_authenticated=True) - self.assertIn('access_token', response['Location']) - self.assertIn('id_token', response['Location']) + self.assertIn("access_token", response["Location"]) + self.assertIn("id_token", response["Location"]) def test_idtoken_response(self): """ @@ -570,29 +575,29 @@ def test_idtoken_response(self): only an id token as the result of the authorization request. """ data = { - 'client_id': self.client_no_access.client_id, - 'redirect_uri': self.client_no_access.default_redirect_uri, - 'response_type': next(self.client_no_access.response_type_values()), - 'scope': 'openid email', - 'state': self.state, - 'nonce': self.nonce, - 'allow': 'Accept', + "client_id": self.client_no_access.client_id, + "redirect_uri": self.client_no_access.default_redirect_uri, + "response_type": next(self.client_no_access.response_type_values()), + "scope": "openid email", + "state": self.state, + "nonce": self.nonce, + "allow": "Accept", } - response = self._auth_request('post', data, is_user_authenticated=True) + response = self._auth_request("post", data, is_user_authenticated=True) - self.assertNotIn('access_token', response['Location']) - self.assertIn('id_token', response['Location']) + self.assertNotIn("access_token", response["Location"]) + self.assertIn("id_token", response["Location"]) # same for public client - data['client_id'] = self.client_public_no_access.client_id, - data['redirect_uri'] = self.client_public_no_access.default_redirect_uri, - data['response_type'] = next(self.client_public_no_access.response_type_values()), + data["client_id"] = (self.client_public_no_access.client_id,) + data["redirect_uri"] = (self.client_public_no_access.default_redirect_uri,) + data["response_type"] = (next(self.client_public_no_access.response_type_values()),) - response = self._auth_request('post', data, is_user_authenticated=True) + response = self._auth_request("post", data, is_user_authenticated=True) - self.assertNotIn('access_token', response['Location']) - self.assertIn('id_token', response['Location']) + self.assertNotIn("access_token", response["Location"]) + self.assertIn("id_token", response["Location"]) def test_idtoken_token_at_hash(self): """ @@ -600,25 +605,25 @@ def test_idtoken_token_at_hash(self): `at_hash` in `id_token`. """ data = { - 'client_id': self.client.client_id, - 'redirect_uri': self.client.default_redirect_uri, - 'response_type': next(self.client.response_type_values()), - 'scope': 'openid email', - 'state': self.state, - 'nonce': self.nonce, - 'allow': 'Accept', + "client_id": self.client.client_id, + "redirect_uri": self.client.default_redirect_uri, + "response_type": next(self.client.response_type_values()), + "scope": "openid email", + "state": self.state, + "nonce": self.nonce, + "allow": "Accept", } - response = self._auth_request('post', data, is_user_authenticated=True) + response = self._auth_request("post", data, is_user_authenticated=True) - self.assertIn('id_token', response['Location']) + self.assertIn("id_token", response["Location"]) # obtain `id_token` portion of Location - components = urlsplit(response['Location']) + components = urlsplit(response["Location"]) fragment = parse_qs(components[4]) - id_token = JWT().unpack(fragment["id_token"][0].encode('utf-8')).payload() + id_token = JWT().unpack(fragment["id_token"][0].encode("utf-8")).payload() - self.assertIn('at_hash', id_token) + self.assertIn("at_hash", id_token) def test_idtoken_at_hash(self): """ @@ -626,74 +631,74 @@ def test_idtoken_at_hash(self): `at_hash` in `id_token`. """ data = { - 'client_id': self.client_no_access.client_id, - 'redirect_uri': self.client_no_access.default_redirect_uri, - 'response_type': next(self.client_no_access.response_type_values()), - 'scope': 'openid email', - 'state': self.state, - 'nonce': self.nonce, - 'allow': 'Accept', + "client_id": self.client_no_access.client_id, + "redirect_uri": self.client_no_access.default_redirect_uri, + "response_type": next(self.client_no_access.response_type_values()), + "scope": "openid email", + "state": self.state, + "nonce": self.nonce, + "allow": "Accept", } - response = self._auth_request('post', data, is_user_authenticated=True) + response = self._auth_request("post", data, is_user_authenticated=True) - self.assertIn('id_token', response['Location']) + self.assertIn("id_token", response["Location"]) # obtain `id_token` portion of Location - components = urlsplit(response['Location']) + components = urlsplit(response["Location"]) fragment = parse_qs(components[4]) - id_token = JWT().unpack(fragment["id_token"][0].encode('utf-8')).payload() + id_token = JWT().unpack(fragment["id_token"][0].encode("utf-8")).payload() - self.assertNotIn('at_hash', id_token) + self.assertNotIn("at_hash", id_token) def test_public_client_implicit_auto_approval(self): """ Public clients using Implicit Flow should be able to reuse consent. """ data = { - 'client_id': self.client_public_no_consent.client_id, - 'response_type': next(self.client_public_no_consent.response_type_values()), - 'redirect_uri': self.client_public_no_consent.default_redirect_uri, - 'scope': 'openid email', - 'state': self.state, - 'nonce': self.nonce, + "client_id": self.client_public_no_consent.client_id, + "response_type": next(self.client_public_no_consent.response_type_values()), + "redirect_uri": self.client_public_no_consent.default_redirect_uri, + "scope": "openid email", + "state": self.state, + "nonce": self.nonce, } - response = self._auth_request('get', data, is_user_authenticated=True) - response_text = response.content.decode('utf-8') - self.assertEqual(response_text, '') - components = urlsplit(response['Location']) + response = self._auth_request("get", data, is_user_authenticated=True) + response_text = response.content.decode("utf-8") + self.assertEqual(response_text, "") + components = urlsplit(response["Location"]) fragment = parse_qs(components[4]) - self.assertIn('access_token', fragment) - self.assertIn('id_token', fragment) - self.assertIn('expires_in', fragment) + self.assertIn("access_token", fragment) + self.assertIn("id_token", fragment) + self.assertIn("expires_in", fragment) def test_multiple_response_types(self): """ Clients should be able to be configured for multiple response types. """ data = { - 'client_id': self.client_multiple_response_types.client_id, - 'redirect_uri': self.client_multiple_response_types.default_redirect_uri, - 'response_type': 'id_token', - 'scope': 'openid email', - 'state': self.state, - 'nonce': self.nonce, - 'allow': 'Accept', + "client_id": self.client_multiple_response_types.client_id, + "redirect_uri": self.client_multiple_response_types.default_redirect_uri, + "response_type": "id_token", + "scope": "openid email", + "state": self.state, + "nonce": self.nonce, + "allow": "Accept", } - response = self._auth_request('post', data, is_user_authenticated=True) + response = self._auth_request("post", data, is_user_authenticated=True) - self.assertNotIn('access_token', response['Location']) - self.assertIn('id_token', response['Location']) + self.assertNotIn("access_token", response["Location"]) + self.assertIn("id_token", response["Location"]) # should also support "id_token token" response_type - data['response_type'] = 'id_token token' + data["response_type"] = "id_token token" - response = self._auth_request('post', data, is_user_authenticated=True) + response = self._auth_request("post", data, is_user_authenticated=True) - self.assertIn('access_token', response['Location']) - self.assertIn('id_token', response['Location']) + self.assertIn("access_token", response["Location"]) + self.assertIn("id_token", response["Location"]) class AuthorizationHybridFlowTestCase(TestCase, AuthorizeEndpointMixin): @@ -702,23 +707,24 @@ class AuthorizationHybridFlowTestCase(TestCase, AuthorizeEndpointMixin): """ def setUp(self): - call_command('creatersakey') + call_command("creatersakey") self.factory = RequestFactory() self.user = create_fake_user() self.client_code_idtoken_token = create_fake_client( - response_type='code id_token token', is_public=True) + response_type="code id_token token", is_public=True + ) self.state = uuid.uuid4().hex self.nonce = uuid.uuid4().hex # Base data for the auth request. self.data = { - 'client_id': self.client_code_idtoken_token.client_id, - 'redirect_uri': self.client_code_idtoken_token.default_redirect_uri, - 'response_type': next(self.client_code_idtoken_token.response_type_values()), - 'scope': 'openid email', - 'state': self.state, - 'nonce': self.nonce, - 'allow': 'Accept', + "client_id": self.client_code_idtoken_token.client_id, + "redirect_uri": self.client_code_idtoken_token.default_redirect_uri, + "response_type": next(self.client_code_idtoken_token.response_type_values()), + "scope": "openid email", + "state": self.state, + "nonce": self.nonce, + "allow": "Accept", } def test_code_idtoken_token_response(self): @@ -726,49 +732,49 @@ def test_code_idtoken_token_response(self): Implicit client requesting `id_token token` receives both id token and access token as the result of the authorization request. """ - response = self._auth_request('post', self.data, is_user_authenticated=True) + response = self._auth_request("post", self.data, is_user_authenticated=True) - self.assertIn('#', response['Location']) - self.assertIn('access_token', response['Location']) - self.assertIn('id_token', response['Location']) - self.assertIn('state', response['Location']) - self.assertIn('code', response['Location']) + self.assertIn("#", response["Location"]) + self.assertIn("access_token", response["Location"]) + self.assertIn("id_token", response["Location"]) + self.assertIn("state", response["Location"]) + self.assertIn("code", response["Location"]) # Validate code. - is_code_ok = is_code_valid(url=response['Location'], - user=self.user, - client=self.client_code_idtoken_token) - self.assertEqual(is_code_ok, True, msg='Code returned is invalid.') + is_code_ok = is_code_valid( + url=response["Location"], user=self.user, client=self.client_code_idtoken_token + ) + self.assertEqual(is_code_ok, True, msg="Code returned is invalid.") @override_settings(OIDC_TOKEN_EXPIRE=36000) def test_access_token_expiration(self): """ Add ten hours of expiration to access_token. Check for the expires_in query in fragment. """ - response = self._auth_request('post', self.data, is_user_authenticated=True) + response = self._auth_request("post", self.data, is_user_authenticated=True) - self.assertIn('expires_in=36000', response['Location']) + self.assertIn("expires_in=36000", response["Location"]) class TestCreateResponseURI(TestCase): def setUp(self): - url = reverse('oidc_provider:authorize') + url = reverse("oidc_provider:authorize") user = create_fake_user() - client = create_fake_client(response_type='code', is_public=True) + client = create_fake_client(response_type="code", is_public=True) # Base data to create a uri response data = { - 'client_id': client.client_id, - 'redirect_uri': client.default_redirect_uri, - 'response_type': next(client.response_type_values()), + "client_id": client.client_id, + "redirect_uri": client.default_redirect_uri, + "response_type": next(client.response_type_values()), } factory = RequestFactory() self.request = factory.post(url, data=data) self.request.user = user - @patch('oidc_provider.lib.endpoints.authorize.create_code') - @patch('oidc_provider.lib.endpoints.authorize.logger.exception') + @patch("oidc_provider.lib.endpoints.authorize.create_code") + @patch("oidc_provider.lib.endpoints.authorize.logger.exception") def test_create_response_uri_logs_to_error(self, log_exception, create_code): """ A lot can go wrong when creating a response uri and this is caught @@ -786,15 +792,16 @@ def test_create_response_uri_logs_to_error(self, log_exception, create_code): authorization_endpoint.create_response_uri() log_exception.assert_called_once_with( - '[Authorize] Error when trying to create response uri: %s', exception) + "[Authorize] Error when trying to create response uri: %s", exception + ) @override_settings(OIDC_SESSION_MANAGEMENT_ENABLE=True) def test_create_response_uri_generates_session_state_if_session_management_enabled(self): # RequestFactory doesn't support sessions, so we mock it - self.request.session = mock.Mock(session_key=None) + self.request.session = Mock(session_key=None) authorization_endpoint = AuthorizeEndpoint(self.request) authorization_endpoint.validate_params() uri = authorization_endpoint.create_response_uri() - self.assertIn('session_state=', uri) + self.assertIn("session_state=", uri) diff --git a/oidc_provider/tests/cases/test_end_session_endpoint.py b/oidc_provider/tests/cases/test_end_session_endpoint.py index 299cbbea..44801bff 100644 --- a/oidc_provider/tests/cases/test_end_session_endpoint.py +++ b/oidc_provider/tests/cases/test_end_session_endpoint.py @@ -1,20 +1,23 @@ +from unittest.mock import patch + try: from urllib import urlencode except ImportError: from urllib.parse import urlencode from django.core.management import call_command +from django.test import TestCase try: from django.urls import reverse except ImportError: from django.core.urlresolvers import reverse -import mock -from django.test import TestCase - -from oidc_provider.lib.utils.token import create_id_token, create_token, encode_id_token -from oidc_provider.tests.app.utils import create_fake_client, create_fake_user +from oidc_provider.lib.utils.token import create_id_token +from oidc_provider.lib.utils.token import create_token +from oidc_provider.lib.utils.token import encode_id_token +from oidc_provider.tests.app.utils import create_fake_client +from oidc_provider.tests.app.utils import create_fake_user class EndSessionTestCase(TestCase): @@ -49,7 +52,7 @@ def test_id_token_hint_not_present_user_prompted(self): # User still logged in. self.assertIn("_auth_user_id", self.client.session) - @mock.patch("oidc_provider.views.after_end_session_hook") + @patch("oidc_provider.views.after_end_session_hook") def test_id_token_hint_is_present_user_redirected_to_client_logout_url( self, after_end_session_hook ): @@ -66,7 +69,7 @@ def test_id_token_hint_is_present_user_redirected_to_client_logout_url( self.assertTrue(after_end_session_hook.called) self.assertTrue(after_end_session_hook.call_count == 1) - @mock.patch("oidc_provider.views.after_end_session_hook") + @patch("oidc_provider.views.after_end_session_hook") def test_id_token_hint_is_present_user_redirected_to_client_logout_url_with_post( self, after_end_session_hook ): @@ -155,7 +158,7 @@ def test_prompt_view_displaying_logout_decision_form_to_user_no_client(self): html=True, ) - @mock.patch("oidc_provider.views.after_end_session_hook") + @patch("oidc_provider.views.after_end_session_hook") def test_prompt_view_user_logged_out_after_form_allowed(self, after_end_session_hook): self.assertIn("_auth_user_id", self.client.session) # We want to POST to /end-session-prompt/?client_id=ABC endpoint. @@ -180,7 +183,7 @@ def test_prompt_view_user_logged_out_after_form_allowed(self, after_end_session_ self.assertTrue(after_end_session_hook.called) self.assertTrue(after_end_session_hook.call_count == 1) - @mock.patch("oidc_provider.views.after_end_session_hook") + @patch("oidc_provider.views.after_end_session_hook") def test_prompt_view_user_logged_out_after_form_not_allowed(self, after_end_session_hook): self.assertIn("_auth_user_id", self.client.session) # We want to POST to /end-session-prompt/?client_id=ABC endpoint. @@ -201,7 +204,7 @@ def test_prompt_view_user_logged_out_after_form_not_allowed(self, after_end_sess # End session hook should not be called. self.assertFalse(after_end_session_hook.called) - @mock.patch("oidc_provider.views.after_end_session_hook") + @patch("oidc_provider.views.after_end_session_hook") def test_prompt_view_user_still_logged_in_after_form_not_allowed_no_client( self, after_end_session_hook ): diff --git a/oidc_provider/tests/cases/test_introspection_endpoint.py b/oidc_provider/tests/cases/test_introspection_endpoint.py index 34a8ac73..86e12bdf 100644 --- a/oidc_provider/tests/cases/test_introspection_endpoint.py +++ b/oidc_provider/tests/cases/test_introspection_endpoint.py @@ -1,97 +1,100 @@ -import time import random +import time +from unittest.mock import patch -from mock import patch try: from urllib.parse import urlencode except ImportError: from urllib import urlencode -from django.utils.encoding import force_str + from django.core.management import call_command -from django.test import TestCase, RequestFactory, override_settings +from django.test import RequestFactory +from django.test import TestCase +from django.test import override_settings from django.utils import timezone +from django.utils.encoding import force_str + try: from django.urls import reverse except ImportError: from django.core.urlresolvers import reverse -from oidc_provider.tests.app.utils import ( - create_fake_user, - create_fake_client, - create_fake_token, - FAKE_RANDOM_STRING) from oidc_provider.lib.utils.token import create_id_token +from oidc_provider.tests.app.utils import FAKE_RANDOM_STRING +from oidc_provider.tests.app.utils import create_fake_client +from oidc_provider.tests.app.utils import create_fake_token +from oidc_provider.tests.app.utils import create_fake_user from oidc_provider.views import TokenIntrospectionView class IntrospectionTestCase(TestCase): - def setUp(self): - call_command('creatersakey') + call_command("creatersakey") self.factory = RequestFactory() self.user = create_fake_user() - self.aud = 'testaudience' - self.client = create_fake_client(response_type='id_token token') - self.resource = create_fake_client(response_type='id_token token') - self.resource.scope = ['token_introspection', self.aud] + self.aud = "testaudience" + self.client = create_fake_client(response_type="id_token token") + self.resource = create_fake_client(response_type="id_token token") + self.resource.scope = ["token_introspection", self.aud] self.resource.save() self.token = create_fake_token(self.user, self.client.scope, self.client) self.token.access_token = str(random.randint(1, 999999)).zfill(6) self.now = time.time() - with patch('oidc_provider.lib.utils.token.time.time') as time_func: + with patch("oidc_provider.lib.utils.token.time.time") as time_func: time_func.return_value = self.now self.token.id_token = create_id_token(self.token, self.user, self.aud) self.token.save() def _assert_inactive(self, response): self.assertEqual(response.status_code, 200) - self.assertJSONEqual(force_str(response.content), {'active': False}) + self.assertJSONEqual(force_str(response.content), {"active": False}) def _assert_active(self, response, **kwargs): self.assertEqual(response.status_code, 200) expected_content = { - 'active': True, - 'aud': self.aud, - 'client_id': self.client.client_id, - 'sub': str(self.user.pk), - 'iat': int(self.now), - 'exp': int(self.now + 600), - 'iss': 'http://localhost:8000/openid', + "active": True, + "aud": self.aud, + "client_id": self.client.client_id, + "sub": str(self.user.pk), + "iat": int(self.now), + "exp": int(self.now + 600), + "iss": "http://localhost:8000/openid", } expected_content.update(kwargs) self.assertJSONEqual(force_str(response.content), expected_content) def _make_request(self, **kwargs): - url = reverse('oidc_provider:token-introspection') + url = reverse("oidc_provider:token-introspection") data = { - 'client_id': kwargs.get('client_id', self.resource.client_id), - 'client_secret': kwargs.get('client_secret', self.resource.client_secret), - 'token': kwargs.get('access_token', self.token.access_token), + "client_id": kwargs.get("client_id", self.resource.client_id), + "client_secret": kwargs.get("client_secret", self.resource.client_secret), + "token": kwargs.get("access_token", self.token.access_token), } - request = self.factory.post(url, data=urlencode(data), - content_type='application/x-www-form-urlencoded') + request = self.factory.post( + url, data=urlencode(data), content_type="application/x-www-form-urlencoded" + ) return TokenIntrospectionView.as_view()(request) def test_no_client_params_returns_inactive(self): - response = self._make_request(client_id='') + response = self._make_request(client_id="") self._assert_inactive(response) def test_no_client_secret_returns_inactive(self): - response = self._make_request(client_secret='') + response = self._make_request(client_secret="") self._assert_inactive(response) def test_invalid_client_returns_inactive(self): - response = self._make_request(client_id='invalid') + response = self._make_request(client_id="invalid") self._assert_inactive(response) def test_token_not_found_returns_inactive(self): - response = self._make_request(access_token='invalid') + response = self._make_request(access_token="invalid") self._assert_inactive(response) def test_scope_no_audience_returns_inactive(self): - self.resource.scope = ['token_introspection'] + self.resource.scope = ["token_introspection"] self.resource.save() response = self._make_request() self._assert_inactive(response) @@ -106,14 +109,16 @@ def test_valid_request_returns_default_properties(self): response = self._make_request() self._assert_active(response) - @override_settings(OIDC_INTROSPECTION_PROCESSING_HOOK='oidc_provider.tests.app.utils.fake_introspection_processing_hook') # NOQA + @override_settings( + OIDC_INTROSPECTION_PROCESSING_HOOK="oidc_provider.tests.app.utils.fake_introspection_processing_hook" + ) # NOQA def test_custom_introspection_hook_called_on_valid_request(self): response = self._make_request() self._assert_active(response, test_introspection_processing_hook=FAKE_RANDOM_STRING) @override_settings(OIDC_INTROSPECTION_VALIDATE_AUDIENCE_SCOPE=False) def test_disable_audience_validation(self): - self.resource.scope = ['token_introspection'] + self.resource.scope = ["token_introspection"] self.resource.save() response = self._make_request() self._assert_active(response) @@ -122,16 +127,19 @@ def test_disable_audience_validation(self): def test_valid_client_grant_token_without_aud_validation(self): self.token.id_token = None # client_credentials tokens do not have id_token self.token.save() - self.resource.scope = ['token_introspection'] + self.resource.scope = ["token_introspection"] self.resource.save() response = self._make_request() self.assertEqual(response.status_code, 200) - self.assertJSONEqual(force_str(response.content), { - 'active': True, - 'client_id': self.client.client_id, - }) + self.assertJSONEqual( + force_str(response.content), + { + "active": True, + "client_id": self.client.client_id, + }, + ) @override_settings(OIDC_INTROSPECTION_RESPONSE_SCOPE_ENABLE=True) def test_enable_scope(self): response = self._make_request() - self._assert_active(response, scope='openid email') + self._assert_active(response, scope="openid email") diff --git a/oidc_provider/tests/cases/test_middleware.py b/oidc_provider/tests/cases/test_middleware.py index 17339285..b2971e5d 100644 --- a/oidc_provider/tests/cases/test_middleware.py +++ b/oidc_provider/tests/cases/test_middleware.py @@ -1,7 +1,8 @@ -import mock +from unittest.mock import patch +from django.test import TestCase +from django.test import override_settings from django.urls import re_path -from django.test import TestCase, override_settings from django.views.generic import View @@ -9,33 +10,37 @@ class StubbedViews: class SampleView(View): pass - urlpatterns = [re_path('^test/', SampleView.as_view())] + urlpatterns = [re_path("^test/", SampleView.as_view())] -MW_CLASSES = ('django.contrib.sessions.middleware.SessionMiddleware', - 'oidc_provider.middleware.SessionManagementMiddleware') +MW_CLASSES = ( + "django.contrib.sessions.middleware.SessionMiddleware", + "oidc_provider.middleware.SessionManagementMiddleware", +) -@override_settings(ROOT_URLCONF=StubbedViews, - MIDDLEWARE=MW_CLASSES, - MIDDLEWARE_CLASSES=MW_CLASSES, - OIDC_SESSION_MANAGEMENT_ENABLE=True) +@override_settings( + ROOT_URLCONF=StubbedViews, + MIDDLEWARE=MW_CLASSES, + MIDDLEWARE_CLASSES=MW_CLASSES, + OIDC_SESSION_MANAGEMENT_ENABLE=True, +) class MiddlewareTestCase(TestCase): - def setUp(self): - patcher = mock.patch('oidc_provider.middleware.get_browser_state_or_default') + patcher = patch("oidc_provider.middleware.get_browser_state_or_default") self.mock_get_state = patcher.start() def test_session_management_middleware_sets_cookie_on_response(self): - response = self.client.get('/test/') + response = self.client.get("/test/") - self.assertIn('op_browser_state', response.cookies) - self.assertEqual(response.cookies['op_browser_state'].value, - str(self.mock_get_state.return_value)) + self.assertIn("op_browser_state", response.cookies) + self.assertEqual( + response.cookies["op_browser_state"].value, str(self.mock_get_state.return_value) + ) self.mock_get_state.assert_called_once_with(response.wsgi_request) @override_settings(OIDC_SESSION_MANAGEMENT_ENABLE=False) def test_session_management_middleware_does_not_set_cookie_if_session_management_disabled(self): - response = self.client.get('/test/') + response = self.client.get("/test/") - self.assertNotIn('op_browser_state', response.cookies) + self.assertNotIn("op_browser_state", response.cookies) diff --git a/oidc_provider/tests/cases/test_provider_info_endpoint.py b/oidc_provider/tests/cases/test_provider_info_endpoint.py index 1dfe2777..c26fc821 100644 --- a/oidc_provider/tests/cases/test_provider_info_endpoint.py +++ b/oidc_provider/tests/cases/test_provider_info_endpoint.py @@ -1,32 +1,32 @@ -from mock import patch +from unittest.mock import patch from django.core.cache import cache +from django.test import RequestFactory +from django.test import TestCase +from django.test import override_settings + try: from django.urls import reverse except ImportError: from django.core.urlresolvers import reverse -from django.test import RequestFactory -from django.test import TestCase, override_settings - from oidc_provider.views import ProviderInfoView class ProviderInfoTestCase(TestCase): - def setUp(self): self.factory = RequestFactory() def tearDown(self): cache.clear() - @patch('oidc_provider.views.ProviderInfoView._build_cache_key') + @patch("oidc_provider.views.ProviderInfoView._build_cache_key") def test_response(self, build_cache_key): """ See if the endpoint is returning the corresponding server information by checking status, content type, etc. """ - url = reverse('oidc_provider:provider-info') + url = reverse("oidc_provider:provider-info") request = self.factory.get(url) @@ -36,18 +36,18 @@ def test_response(self, build_cache_key): build_cache_key.assert_not_called() self.assertEqual(response.status_code, 200) - self.assertEqual(response['Content-Type'] == 'application/json', True) + self.assertEqual(response["Content-Type"] == "application/json", True) self.assertEqual(bool(response.content), True) @override_settings(OIDC_DISCOVERY_CACHE_ENABLE=True) - @patch('oidc_provider.views.ProviderInfoView._build_cache_key') + @patch("oidc_provider.views.ProviderInfoView._build_cache_key") def test_response_with_cache_enabled(self, build_cache_key): """ Enable caching on the discovery endpoint and ensure data is being saved on cache. """ - build_cache_key.return_value = 'key' + build_cache_key.return_value = "key" - url = reverse('oidc_provider:provider-info') + url = reverse("oidc_provider:provider-info") request = self.factory.get(url) @@ -55,9 +55,9 @@ def test_response_with_cache_enabled(self, build_cache_key): self.assertEqual(response.status_code, 200) build_cache_key.assert_called_once() - assert 'authorization_endpoint' in cache.get('key') + assert "authorization_endpoint" in cache.get("key") response = ProviderInfoView.as_view()(request) self.assertEqual(response.status_code, 200) - self.assertEqual(response['Content-Type'] == 'application/json', True) + self.assertEqual(response["Content-Type"] == "application/json", True) self.assertEqual(bool(response.content), True) diff --git a/oidc_provider/tests/cases/test_token_endpoint.py b/oidc_provider/tests/cases/test_token_endpoint.py index df0d6ad1..a32187df 100644 --- a/oidc_provider/tests/cases/test_token_endpoint.py +++ b/oidc_provider/tests/cases/test_token_endpoint.py @@ -2,21 +2,21 @@ import time import uuid from base64 import b64encode - -from django.db import DatabaseError +from unittest.mock import patch try: from urllib.parse import urlencode except ImportError: from urllib import urlencode -from django.core.management import call_command -from django.http import JsonResponse - try: from django.urls import reverse except ImportError: from django.core.urlresolvers import reverse + +from django.core.management import call_command +from django.db import DatabaseError +from django.http import JsonResponse from django.test import RequestFactory from django.test import TestCase from django.test import override_settings @@ -24,7 +24,6 @@ from jwkest.jwk import KEYS from jwkest.jws import JWS from jwkest.jwt import JWT -from mock import patch from oidc_provider.lib.endpoints.introspection import INTROSPECTION_SCOPE from oidc_provider.lib.utils.oauth2 import protected_resource_view From 1001d262e32febaa95e277a90bef1af9b90fbda2 Mon Sep 17 00:00:00 2001 From: juanifioren Date: Sat, 14 Dec 2024 12:41:35 -0300 Subject: [PATCH 25/54] Replace mock with unittest.mock --- docs/sections/changelog.rst | 1 + oidc_provider/tests/cases/test_utils.py | 6 +-- setup.py | 59 ++++++++++++------------- tox.ini | 1 - 4 files changed, 32 insertions(+), 35 deletions(-) diff --git a/docs/sections/changelog.rst b/docs/sections/changelog.rst index 1f9ec227..2ad4ca02 100644 --- a/docs/sections/changelog.rst +++ b/docs/sections/changelog.rst @@ -10,6 +10,7 @@ Unreleased * Changed: Django 5 added to test matrix. * Changed: ID Token JSON encoder improved using DjangoJSONEncoder. +* Changed: Use unittest.mock in tests. Remove mock library. 0.8.3 ===== diff --git a/oidc_provider/tests/cases/test_utils.py b/oidc_provider/tests/cases/test_utils.py index c0e64ee7..119e4ba4 100644 --- a/oidc_provider/tests/cases/test_utils.py +++ b/oidc_provider/tests/cases/test_utils.py @@ -2,7 +2,7 @@ from datetime import date from datetime import datetime from hashlib import sha224 -from unittest import mock +from unittest.mock import Mock from django.http import HttpRequest from django.test import TestCase @@ -146,12 +146,12 @@ class BrowserStateTest(TestCase): @override_settings(OIDC_UNAUTHENTICATED_SESSION_MANAGEMENT_KEY="my_static_key") def test_get_browser_state_uses_value_from_settings_to_calculate_browser_state(self): request = HttpRequest() - request.session = mock.Mock(session_key=None) + request.session = Mock(session_key=None) state = get_browser_state_or_default(request) self.assertEqual(state, sha224("my_static_key".encode("utf-8")).hexdigest()) def test_get_browser_state_uses_session_key_to_calculate_browser_state_if_available(self): request = HttpRequest() - request.session = mock.Mock(session_key="my_session_key") + request.session = Mock(session_key="my_session_key") state = get_browser_state_or_default(request) self.assertEqual(state, sha224("my_session_key".encode("utf-8")).hexdigest()) diff --git a/setup.py b/setup.py index f20b52b8..77cca0c2 100644 --- a/setup.py +++ b/setup.py @@ -1,8 +1,7 @@ import os -from setuptools import ( - find_packages, - setup, -) + +from setuptools import find_packages +from setuptools import setup version = {} with open("./oidc_provider/version.py") as fp: @@ -12,40 +11,38 @@ os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) setup( - name='django-oidc-provider', - version=version['__version__'], + name="django-oidc-provider", + version=version["__version__"], packages=find_packages(), include_package_data=True, - license='MIT License', - description='OpenID Connect Provider implementation for Django.', - long_description='http://github.com/juanifioren/django-oidc-provider', - url='http://github.com/juanifioren/django-oidc-provider', - author='Juan Ignacio Fiorentino', - author_email='juanifioren@gmail.com', + license="MIT License", + description="OpenID Connect Provider implementation for Django.", + long_description="http://github.com/juanifioren/django-oidc-provider", + url="http://github.com/juanifioren/django-oidc-provider", + author="Juan Ignacio Fiorentino", + author_email="juanifioren@gmail.com", zip_safe=False, classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Environment :: Web Environment', - 'Framework :: Django', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: MIT License', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: 3.11', - 'Topic :: Internet :: WWW/HTTP', - 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', + "Development Status :: 5 - Production/Stable", + "Environment :: Web Environment", + "Framework :: Django", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Internet :: WWW/HTTP :: Dynamic Content", ], - test_suite='runtests.runtests', + test_suite="runtests.runtests", tests_require=[ - 'pyjwkest>=1.3.0', - 'mock>=2.0.0', + "pyjwkest>=1.3.0", ], - install_requires=[ - 'pyjwkest>=1.3.0', + "pyjwkest>=1.3.0", ], ) diff --git a/tox.ini b/tox.ini index 5a22b168..4710a1ba 100644 --- a/tox.ini +++ b/tox.ini @@ -11,7 +11,6 @@ envlist= changedir= oidc_provider deps = - mock psycopg2-binary pytest pytest-django From 114a9754da9fc4e4fdab5951b61ddec2e3655291 Mon Sep 17 00:00:00 2001 From: juanifioren Date: Sat, 14 Dec 2024 13:14:48 -0300 Subject: [PATCH 26/54] Replace mock with unittest.mock --- oidc_provider/tests/cases/test_introspection_endpoint.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/oidc_provider/tests/cases/test_introspection_endpoint.py b/oidc_provider/tests/cases/test_introspection_endpoint.py index 86e12bdf..916c1d3b 100644 --- a/oidc_provider/tests/cases/test_introspection_endpoint.py +++ b/oidc_provider/tests/cases/test_introspection_endpoint.py @@ -110,8 +110,8 @@ def test_valid_request_returns_default_properties(self): self._assert_active(response) @override_settings( - OIDC_INTROSPECTION_PROCESSING_HOOK="oidc_provider.tests.app.utils.fake_introspection_processing_hook" - ) # NOQA + OIDC_INTROSPECTION_PROCESSING_HOOK="oidc_provider.tests.app.utils.fake_introspection_processing_hook" # noqa + ) def test_custom_introspection_hook_called_on_valid_request(self): response = self._make_request() self._assert_active(response, test_introspection_processing_hook=FAKE_RANDOM_STRING) From 61a1ff159151052d6bfc61390fe47eb605e298d4 Mon Sep 17 00:00:00 2001 From: juanifioren Date: Thu, 26 Dec 2024 21:48:10 -0300 Subject: [PATCH 27/54] Request parameter + max_age parameter --- docs/sections/changelog.rst | 2 + oidc_provider/lib/endpoints/authorize.py | 33 +++- oidc_provider/tests/app/utils.py | 3 +- .../tests/cases/test_authorize_endpoint.py | 187 +++++++++++++----- .../tests/cases/test_token_endpoint.py | 3 +- oidc_provider/views.py | 54 +++-- tox.ini | 11 +- 7 files changed, 212 insertions(+), 81 deletions(-) diff --git a/docs/sections/changelog.rst b/docs/sections/changelog.rst index 2ad4ca02..2b5fd7d0 100644 --- a/docs/sections/changelog.rst +++ b/docs/sections/changelog.rst @@ -8,6 +8,8 @@ All notable changes to this project will be documented in this file. Unreleased ========== +* Added: support of max_age parameter on authorization request. +* Added: Passing Request Parameters as JWTs now returning request_not_supported error. * Changed: Django 5 added to test matrix. * Changed: ID Token JSON encoder improved using DjangoJSONEncoder. * Changed: Use unittest.mock in tests. Remove mock library. diff --git a/oidc_provider/lib/endpoints/authorize.py b/oidc_provider/lib/endpoints/authorize.py index c14d7689..719da792 100644 --- a/oidc_provider/lib/endpoints/authorize.py +++ b/oidc_provider/lib/endpoints/authorize.py @@ -3,6 +3,8 @@ from hashlib import md5 from hashlib import sha256 +from oidc_provider.compat import get_attr_or_callable + try: from urllib import urlencode @@ -16,6 +18,7 @@ from urllib.parse import urlunsplit from uuid import uuid4 +from django.utils import dateformat from django.utils import timezone from oidc_provider import settings @@ -74,11 +77,12 @@ def _extract_params(self): self.params["scope"] = query_dict.get("scope", "").split() self.params["state"] = query_dict.get("state", "") self.params["nonce"] = query_dict.get("nonce", "") - + # https://openid.net/specs/openid-connect-core-1_0.html#RequestObject + self.params["request"] = query_dict.get("request", "") self.params["prompt"] = self._allowed_prompt_params.intersection( set(query_dict.get("prompt", "").split()) ) - + self.params["max_age"] = query_dict.get("max_age", "") self.params["code_challenge"] = query_dict.get("code_challenge", "") self.params["code_challenge_method"] = query_dict.get("code_challenge_method", "") @@ -105,6 +109,12 @@ def validate_params(self): self.params["redirect_uri"], "unsupported_response_type", self.grant_type ) + # Passing Request Parameters as JWT not supported. + if self.params["request"]: + raise AuthorizeError( + self.params["redirect_uri"], "request_not_supported", self.grant_type + ) + if not self.is_authentication and ( self.grant_type == "hybrid" or self.params["response_type"] in ["id_token", "id_token token"] @@ -302,6 +312,25 @@ def is_client_allowed_to_skip_consent(self): or self.params["response_type"] in implicit_flow_resp_types ) + def is_authentication_age_is_greater_than_max_age(self): + """ + If the End-User authentication age is greater than the max_age value present in the + Authorization request, the OP MUST attempt to actively re-authenticate the End-User. + """ + if not get_attr_or_callable(self.request.user, "is_authenticated"): + return False + try: + max_age = int(self.params["max_age"]) + except ValueError: + return False + + auth_time = int( + dateformat.format(self.request.user.last_login or self.request.user.date_joined, "U") + ) + max_allowed_time = int(dateformat.format(timezone.now(), "U")) - max_age + + return auth_time < max_allowed_time + def get_scopes_information(self): """ Return a list with the description of all the scopes requested. diff --git a/oidc_provider/tests/app/utils.py b/oidc_provider/tests/app/utils.py index 491af0e5..5c3608cc 100644 --- a/oidc_provider/tests/app/utils.py +++ b/oidc_provider/tests/app/utils.py @@ -26,6 +26,7 @@ ) FAKE_CODE_CHALLENGE = "YlYXEqXuRm-Xgi2BOUiK50JW1KsGTX6F1TDnZSC8VTg" FAKE_CODE_VERIFIER = "SmxGa0XueyNh5bDgTcSrqzAh2_FmXEqU8kDT6CuXicw" +FAKE_USER_PASSWORD = "1234" def create_fake_user(): @@ -39,7 +40,7 @@ def create_fake_user(): user.email = "johndoe@example.com" user.first_name = "John" user.last_name = "Doe" - user.set_password("1234") + user.set_password(FAKE_USER_PASSWORD) user.save() diff --git a/oidc_provider/tests/cases/test_authorize_endpoint.py b/oidc_provider/tests/cases/test_authorize_endpoint.py index e5cfeb11..5846a423 100644 --- a/oidc_provider/tests/cases/test_authorize_endpoint.py +++ b/oidc_provider/tests/cases/test_authorize_endpoint.py @@ -1,3 +1,5 @@ +from datetime import datetime + try: from urllib.parse import quote from urllib.parse import urlencode @@ -14,13 +16,14 @@ from unittest.mock import Mock from unittest.mock import patch -from django.contrib.auth.models import AnonymousUser -from django.core.management import call_command +from freezegun import freeze_time try: from django.urls import reverse except ImportError: from django.core.urlresolvers import reverse +from django.contrib.auth.models import AnonymousUser +from django.core.management import call_command from django.test import RequestFactory from django.test import TestCase from django.test import override_settings @@ -70,7 +73,7 @@ def setUp(self): call_command("creatersakey") self.factory = RequestFactory() self.user = create_fake_user() - self.client = create_fake_client(response_type="code") + self.client_code = create_fake_client(response_type="code") self.client_with_no_consent = create_fake_client( response_type="code", require_consent=False ) @@ -103,9 +106,9 @@ def test_invalid_response_type(self): """ # Create an authorize request with an unsupported response_type. data = { - "client_id": self.client.client_id, + "client_id": self.client_code.client_id, "response_type": "something_wrong", - "redirect_uri": self.client.default_redirect_uri, + "redirect_uri": self.client_code.default_redirect_uri, "scope": "openid email", "state": self.state, } @@ -118,6 +121,31 @@ def test_invalid_response_type(self): # Should be an 'error' component in query. self.assertIn("error=", response["Location"]) + def test_passing_request_parameters_as_jwt_not_supported(self): + """ + The OP MUST return the request_not_supported error if the parameter value is a + Request Object value. + + See: https://openid.net/specs/openid-connect-core-1_0.html#JWTRequests + """ + # Create an authorize request with an unsupported response_type. + data = { + "client_id": self.client_code.client_id, + "response_type": "code", + "redirect_uri": self.client_code.default_redirect_uri, + "scope": "openid email", + "state": self.state, + "request": "eyJhbGciOiJub25lIn0...", + } + + response = self._auth_request("get", data) + + self.assertEqual(response.status_code, 302) + self.assertEqual(response.has_header("Location"), True) + + # Should be an 'error' component in query. + self.assertIn("error=request_not_supported", response["Location"]) + def test_user_not_logged(self): """ The Authorization Server attempts to Authenticate the End-User by @@ -126,9 +154,9 @@ def test_user_not_logged(self): See: http://openid.net/specs/openid-connect-core-1_0.html#Authenticates """ data = { - "client_id": self.client.client_id, + "client_id": self.client_code.client_id, "response_type": "code", - "redirect_uri": self.client.default_redirect_uri, + "redirect_uri": self.client_code.default_redirect_uri, "scope": "openid email", "state": self.state, } @@ -147,9 +175,9 @@ def test_user_consent_inputs(self): See: http://openid.net/specs/openid-connect-core-1_0.html#Consent """ data = { - "client_id": self.client.client_id, + "client_id": self.client_code.client_id, "response_type": "code", - "redirect_uri": self.client.default_redirect_uri, + "redirect_uri": self.client_code.default_redirect_uri, "scope": "openid email", "state": self.state, # PKCE parameters. @@ -164,8 +192,8 @@ def test_user_consent_inputs(self): input_html = '' to_check = { - "client_id": self.client.client_id, - "redirect_uri": self.client.default_redirect_uri, + "client_id": self.client_code.client_id, + "redirect_uri": self.client_code.default_redirect_uri, "response_type": "code", "code_challenge": FAKE_CODE_CHALLENGE, "code_challenge_method": "S256", @@ -188,8 +216,8 @@ def test_user_consent_response(self): by adding them as query parameters to the redirect_uri. """ data = { - "client_id": self.client.client_id, - "redirect_uri": self.client.default_redirect_uri, + "client_id": self.client_code.client_id, + "redirect_uri": self.client_code.default_redirect_uri, "response_type": "code", "scope": "openid email", "state": self.state, @@ -212,7 +240,9 @@ def test_user_consent_response(self): response = self._auth_request("post", data, is_user_authenticated=True) - is_code_ok = is_code_valid(url=response["Location"], user=self.user, client=self.client) + is_code_ok = is_code_valid( + url=response["Location"], user=self.user, client=self.client_code + ) self.assertEqual(is_code_ok, True, msg="Code returned is invalid.") # Check if the state is returned. @@ -263,8 +293,8 @@ def test_response_uri_is_properly_constructed(self): Only 'state' and 'code' should be appended. """ data = { - "client_id": self.client.client_id, - "redirect_uri": self.client.default_redirect_uri, + "client_id": self.client_code.client_id, + "redirect_uri": self.client_code.default_redirect_uri, "response_type": "code", "scope": "openid email", "state": self.state, @@ -278,7 +308,9 @@ def test_response_uri_is_properly_constructed(self): state = params["state"][0] self.assertEqual(self.state, state, msg="State returned is invalid or missing") - is_code_ok = is_code_valid(url=response["Location"], user=self.user, client=self.client) + is_code_ok = is_code_valid( + url=response["Location"], user=self.user, client=self.client_code + ) self.assertTrue(is_code_ok, msg="Code returned is invalid or missing") self.assertEqual( @@ -288,7 +320,7 @@ def test_response_uri_is_properly_constructed(self): ) self.assertTrue( - response["Location"].startswith(self.client.default_redirect_uri), + response["Location"].startswith(self.client_code.default_redirect_uri), msg="Different redirect_uri returned", ) @@ -298,7 +330,7 @@ def test_unknown_redirect_uris_are_rejected(self): See http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest. """ data = { - "client_id": self.client.client_id, + "client_id": self.client_code.client_id, "response_type": "code", "redirect_uri": "http://neverseenthis.com", "scope": "openid email", @@ -316,9 +348,9 @@ def test_manipulated_redirect_uris_are_rejected(self): See http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest. """ data = { - "client_id": self.client.client_id, + "client_id": self.client_code.client_id, "response_type": "code", - "redirect_uri": self.client.default_redirect_uri + "?some=query", + "redirect_uri": self.client_code.default_redirect_uri + "?some=query", "scope": "openid email", "state": self.state, } @@ -352,9 +384,9 @@ def test_prompt_none_parameter(self): See: http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest """ data = { - "client_id": self.client.client_id, - "response_type": next(self.client.response_type_values()), - "redirect_uri": self.client.default_redirect_uri, + "client_id": self.client_code.client_id, + "response_type": next(self.client_code.response_type_values()), + "redirect_uri": self.client_code.default_redirect_uri, "scope": "openid email", "state": self.state, "prompt": "none", @@ -372,16 +404,16 @@ def test_prompt_none_parameter(self): self.assertIn("consent_required", response["Location"]) @patch("oidc_provider.views.django_user_logout") - def test_prompt_login_parameter(self, logout_function): + def test_prompt_login_parameter(self, logout_patched): """ Specifies whether the Authorization Server prompts the End-User for reauthentication and consent. See: http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest """ data = { - "client_id": self.client.client_id, - "response_type": next(self.client.response_type_values()), - "redirect_uri": self.client.default_redirect_uri, + "client_id": self.client_code.client_id, + "response_type": next(self.client_code.response_type_values()), + "redirect_uri": self.client_code.default_redirect_uri, "scope": "openid email", "state": self.state, "prompt": "login", @@ -392,18 +424,16 @@ def test_prompt_login_parameter(self, logout_function): self.assertNotIn( quote("prompt=login"), response["Location"], - "Found prompt=login, this leads to infinite login loop. See " - "https://github.com/juanifioren/django-oidc-provider/issues/197.", + "Found prompt=login, this leads to infinite login loop.", ) response = self._auth_request("get", data, is_user_authenticated=True) self.assertIn(settings.get("OIDC_LOGIN_URL"), response["Location"]) - logout_function.assert_called_once() + logout_patched.assert_called_once() self.assertNotIn( quote("prompt=login"), response["Location"], - "Found prompt=login, this leads to infinite login loop. See " - "https://github.com/juanifioren/django-oidc-provider/issues/197.", + "Found prompt=login, this leads to infinite login loop.", ) def test_prompt_login_none_parameter(self): @@ -413,9 +443,9 @@ def test_prompt_login_none_parameter(self): See: http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest """ data = { - "client_id": self.client.client_id, - "response_type": next(self.client.response_type_values()), - "redirect_uri": self.client.default_redirect_uri, + "client_id": self.client_code.client_id, + "response_type": next(self.client_code.response_type_values()), + "redirect_uri": self.client_code.default_redirect_uri, "scope": "openid email", "state": self.state, "prompt": "login none", @@ -435,9 +465,9 @@ def test_prompt_consent_parameter(self, render_patched): See: http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest """ data = { - "client_id": self.client.client_id, - "response_type": next(self.client.response_type_values()), - "redirect_uri": self.client.default_redirect_uri, + "client_id": self.client_code.client_id, + "response_type": next(self.client_code.response_type_values()), + "redirect_uri": self.client_code.default_redirect_uri, "scope": "openid email", "state": self.state, "prompt": "consent", @@ -457,9 +487,9 @@ def test_prompt_consent_none_parameter(self): See: http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest """ data = { - "client_id": self.client.client_id, - "response_type": next(self.client.response_type_values()), - "redirect_uri": self.client.default_redirect_uri, + "client_id": self.client_code.client_id, + "response_type": next(self.client_code.response_type_values()), + "redirect_uri": self.client_code.default_redirect_uri, "scope": "openid email", "state": self.state, "prompt": "consent none", @@ -471,6 +501,59 @@ def test_prompt_consent_none_parameter(self): response = self._auth_request("get", data, is_user_authenticated=True) self.assertIn("consent_required", response["Location"]) + @patch("oidc_provider.views.django_user_logout") + @freeze_time("2024-01-20 00:00:00", tz_offset=0, as_kwarg="frozen_time") + def test_max_age_should_re_authenticate_user(self, logout_patched, frozen_time): + """ + Authentication age is greater than the max_age value present in the + Authorization request, the OP MUST attempt to actively re-authenticate the End-User. + See: https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest + """ + self.user.last_login = datetime.now() + self.user.save() + + frozen_time.move_to("2024-01-20 00:15:00") + + data = { + "client_id": self.client_code.client_id, + "response_type": next(self.client_code.response_type_values()), + "redirect_uri": self.client_code.default_redirect_uri, + "scope": "openid email", + "state": self.state, + "max_age": "600", + } + + response = self._auth_request("get", data, is_user_authenticated=True) + + self.assertIn(settings.get("OIDC_LOGIN_URL"), response["Location"]) + logout_patched.assert_called_once() + + @freeze_time("2024-01-20 00:00:00", tz_offset=0, as_kwarg="frozen_time") + @patch("oidc_provider.views.render") + @patch("oidc_provider.views.django_user_logout") + def test_max_age_should_not_re_authenticate_user( + self, logout_patched, render_patched, frozen_time + ): + self.user.last_login = datetime.now() + self.user.save() + + frozen_time.move_to("2024-01-20 00:08:00") + + data = { + "client_id": self.client_code.client_id, + "response_type": next(self.client_code.response_type_values()), + "redirect_uri": self.client_code.default_redirect_uri, + "scope": "openid email", + "state": self.state, + "max_age": "600", + } + + self._auth_request("get", data, is_user_authenticated=True) + + logout_patched.assert_not_called() + render_patched.assert_called_once() + self.assertTrue(render_patched.call_args[0][1], settings.get("OIDC_TEMPLATES")["authorize"]) + def test_strip_prompt_login(self): """ Test for helper method test_strip_prompt_login. @@ -510,7 +593,7 @@ def setUp(self): call_command("creatersakey") self.factory = RequestFactory() self.user = create_fake_user() - self.client = create_fake_client(response_type="id_token token") + self.client_code = create_fake_client(response_type="id_token token") self.client_public = create_fake_client(response_type="id_token token", is_public=True) self.client_public_no_consent = create_fake_client( response_type="id_token token", is_public=True, require_consent=False @@ -528,9 +611,9 @@ def test_missing_nonce(self): The `nonce` parameter is REQUIRED if you use the Implicit Flow. """ data = { - "client_id": self.client.client_id, - "response_type": next(self.client.response_type_values()), - "redirect_uri": self.client.default_redirect_uri, + "client_id": self.client_code.client_id, + "response_type": next(self.client_code.response_type_values()), + "redirect_uri": self.client_code.default_redirect_uri, "scope": "openid email", "state": self.state, } @@ -545,9 +628,9 @@ def test_idtoken_token_response(self): and access token as the result of the authorization request. """ data = { - "client_id": self.client.client_id, - "redirect_uri": self.client.default_redirect_uri, - "response_type": next(self.client.response_type_values()), + "client_id": self.client_code.client_id, + "redirect_uri": self.client_code.default_redirect_uri, + "response_type": next(self.client_code.response_type_values()), "scope": "openid email", "state": self.state, "nonce": self.nonce, @@ -605,9 +688,9 @@ def test_idtoken_token_at_hash(self): `at_hash` in `id_token`. """ data = { - "client_id": self.client.client_id, - "redirect_uri": self.client.default_redirect_uri, - "response_type": next(self.client.response_type_values()), + "client_id": self.client_code.client_id, + "redirect_uri": self.client_code.default_redirect_uri, + "response_type": next(self.client_code.response_type_values()), "scope": "openid email", "state": self.state, "nonce": self.nonce, diff --git a/oidc_provider/tests/cases/test_token_endpoint.py b/oidc_provider/tests/cases/test_token_endpoint.py index a32187df..ee60a0c7 100644 --- a/oidc_provider/tests/cases/test_token_endpoint.py +++ b/oidc_provider/tests/cases/test_token_endpoint.py @@ -33,6 +33,7 @@ from oidc_provider.tests.app.utils import FAKE_CODE_VERIFIER from oidc_provider.tests.app.utils import FAKE_NONCE from oidc_provider.tests.app.utils import FAKE_RANDOM_STRING +from oidc_provider.tests.app.utils import FAKE_USER_PASSWORD from oidc_provider.tests.app.utils import create_fake_client from oidc_provider.tests.app.utils import create_fake_user from oidc_provider.views import JwksView @@ -60,7 +61,7 @@ def setUp(self): def _password_grant_post_data(self, scope=None): result = { "username": "johndoe", - "password": "1234", + "password": FAKE_USER_PASSWORD, "grant_type": "password", "scope": TokenTestCase.SCOPE, } diff --git a/oidc_provider/views.py b/oidc_provider/views.py index 45798e59..a102ab6f 100644 --- a/oidc_provider/views.py +++ b/oidc_provider/views.py @@ -4,9 +4,14 @@ try: from urllib import urlencode - from urlparse import parse_qs, urlsplit, urlunsplit + from urlparse import parse_qs + from urlparse import urlsplit + from urlparse import urlunsplit except ImportError: - from urllib.parse import urlsplit, parse_qs, urlunsplit, urlencode + from urllib.parse import parse_qs + from urllib.parse import urlencode + from urllib.parse import urlsplit + from urllib.parse import urlunsplit from Cryptodome.PublicKey import RSA from django.contrib.auth.views import redirect_to_login @@ -19,40 +24,41 @@ from django.contrib.auth import logout as django_user_logout from django.core.cache import cache from django.db import transaction -from django.http import HttpResponse, JsonResponse +from django.http import HttpResponse +from django.http import JsonResponse from django.shortcuts import render from django.template.loader import render_to_string from django.utils.decorators import method_decorator from django.views.decorators.clickjacking import xframe_options_exempt from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods -from django.views.generic import TemplateView, View +from django.views.generic import TemplateView +from django.views.generic import View from jwkest import long_to_base64 -from oidc_provider import settings, signals +from oidc_provider import settings +from oidc_provider import signals from oidc_provider.compat import get_attr_or_callable from oidc_provider.lib.claims import StandardScopeClaims from oidc_provider.lib.endpoints.authorize import AuthorizeEndpoint from oidc_provider.lib.endpoints.introspection import TokenIntrospectionEndpoint from oidc_provider.lib.endpoints.token import TokenEndpoint -from oidc_provider.lib.errors import ( - AuthorizeError, - ClientIdError, - RedirectUriError, - TokenError, - TokenIntrospectionError, - UserAuthError, -) +from oidc_provider.lib.errors import AuthorizeError +from oidc_provider.lib.errors import ClientIdError +from oidc_provider.lib.errors import RedirectUriError +from oidc_provider.lib.errors import TokenError +from oidc_provider.lib.errors import TokenIntrospectionError +from oidc_provider.lib.errors import UserAuthError from oidc_provider.lib.utils.authorize import strip_prompt_login -from oidc_provider.lib.utils.common import ( - cors_allow_any, - get_issuer, - get_site_url, - redirect, -) +from oidc_provider.lib.utils.common import cors_allow_any +from oidc_provider.lib.utils.common import get_issuer +from oidc_provider.lib.utils.common import get_site_url +from oidc_provider.lib.utils.common import redirect from oidc_provider.lib.utils.oauth2 import protected_resource_view from oidc_provider.lib.utils.token import client_id_from_id_token -from oidc_provider.models import Client, ResponseType, RSAKey +from oidc_provider.models import Client +from oidc_provider.models import ResponseType +from oidc_provider.models import RSAKey logger = logging.getLogger(__name__) @@ -106,6 +112,12 @@ def get(self, request, *args, **kwargs): authorize.params["redirect_uri"], "consent_required", authorize.grant_type ) + if authorize.is_authentication_age_is_greater_than_max_age(): + django_user_logout(request) + return redirect_to_login( + request.get_full_path(), settings.get("OIDC_LOGIN_URL") + ) + if not authorize.client.require_consent and ( authorize.is_client_allowed_to_skip_consent() and "consent" not in authorize.params["prompt"] @@ -297,6 +309,8 @@ def _build_response_dict(self, request): dic["token_endpoint_auth_methods_supported"] = ["client_secret_post", "client_secret_basic"] + dic["request_parameter_supported"] = False + if settings.get("OIDC_SESSION_MANAGEMENT_ENABLE"): dic["check_session_iframe"] = site_url + reverse("oidc_provider:check-session-iframe") diff --git a/tox.ini b/tox.ini index 4710a1ba..26091bc5 100644 --- a/tox.ini +++ b/tox.ini @@ -11,17 +11,18 @@ envlist= changedir= oidc_provider deps = - psycopg2-binary - pytest - pytest-django - pytest-flake8 - pytest-cov django32: django>=3.2,<3.3 django40: django>=4.0,<4.1 django41: django>=4.1,<4.2 django42: django>=4.2,<4.3 django50: django>=5.0,<5.1 django51: django>=5.1,<5.2 + freezegun + psycopg2-binary + pytest + pytest-django + pytest-flake8 + pytest-cov commands = pytest --cov=oidc_provider {posargs} From cb476c47a6f75e133e9ced2c747f7265468ec80b Mon Sep 17 00:00:00 2001 From: juanifioren Date: Fri, 27 Dec 2024 10:15:49 -0300 Subject: [PATCH 28/54] Request parameter + max_age parameter --- oidc_provider/lib/endpoints/authorize.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/oidc_provider/lib/endpoints/authorize.py b/oidc_provider/lib/endpoints/authorize.py index 719da792..90a7b212 100644 --- a/oidc_provider/lib/endpoints/authorize.py +++ b/oidc_provider/lib/endpoints/authorize.py @@ -1,4 +1,5 @@ import logging +from datetime import datetime from datetime import timedelta from hashlib import md5 from hashlib import sha256 @@ -327,7 +328,7 @@ def is_authentication_age_is_greater_than_max_age(self): auth_time = int( dateformat.format(self.request.user.last_login or self.request.user.date_joined, "U") ) - max_allowed_time = int(dateformat.format(timezone.now(), "U")) - max_age + max_allowed_time = int(dateformat.format(datetime.now(), "U")) - max_age return auth_time < max_allowed_time From 0948f5e00000573f4ac3ab23de967f49709f826b Mon Sep 17 00:00:00 2001 From: orenzhang Date: Mon, 17 Feb 2025 17:23:10 +0800 Subject: [PATCH 29/54] feat(translation): add simplified chinese translation --- oidc_provider/apps.py | 3 +- .../locale/zh_Hans/LC_MESSAGES/django.mo | Bin 0 -> 4129 bytes .../locale/zh_Hans/LC_MESSAGES/django.po | 258 ++++++++++++++++++ 3 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 oidc_provider/locale/zh_Hans/LC_MESSAGES/django.mo create mode 100644 oidc_provider/locale/zh_Hans/LC_MESSAGES/django.po diff --git a/oidc_provider/apps.py b/oidc_provider/apps.py index 9e7ba201..6d478dbf 100644 --- a/oidc_provider/apps.py +++ b/oidc_provider/apps.py @@ -1,7 +1,8 @@ from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ class OIDCProviderConfig(AppConfig): name = 'oidc_provider' - verbose_name = u'OpenID Connect Provider' + verbose_name = _(u'OpenID Connect Provider') diff --git a/oidc_provider/locale/zh_Hans/LC_MESSAGES/django.mo b/oidc_provider/locale/zh_Hans/LC_MESSAGES/django.mo new file mode 100644 index 0000000000000000000000000000000000000000..a247c136175c65cfe80b13a7ebcc7391f2e71012 GIT binary patch literal 4129 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 A+yDRo literal 0 HcmV?d00001 diff --git a/oidc_provider/locale/zh_Hans/LC_MESSAGES/django.po b/oidc_provider/locale/zh_Hans/LC_MESSAGES/django.po new file mode 100644 index 00000000..17b26c38 --- /dev/null +++ b/oidc_provider/locale/zh_Hans/LC_MESSAGES/django.po @@ -0,0 +1,258 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#: admin.py:53 +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-17 17:14+0800\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: admin.py:58 +msgid "Credentials" +msgstr "凭证" + +#: admin.py:61 +msgid "Information" +msgstr "信息" + +#: admin.py:64 +msgid "Session Management" +msgstr "会话管理" + +#: apps.py:8 +msgid "OpenID Connect Provider" +msgstr "OIDC提供方" + +#: lib/claims.py:122 +msgid "Basic profile" +msgstr "基础配置" + +#: lib/claims.py:123 +msgid "" +"Access to your basic information. Includes names, gender, birthdate and " +"other information." +msgstr "获取你的基础信息,包含用户名、性别、生日和其他信息" + +#: lib/claims.py:150 +msgid "Email" +msgstr "电子邮件" + +#: lib/claims.py:151 +msgid "Access to your email address." +msgstr "获取你的电子邮件地址" + +#: lib/claims.py:163 +msgid "Phone number" +msgstr "联系电话" + +#: lib/claims.py:164 +msgid "Access to your phone number." +msgstr "获取你的联系电话" + +#: lib/claims.py:176 +msgid "Address information" +msgstr "联系地址" + +#: lib/claims.py:177 +msgid "" +"Access to your address. Includes country, locality, street and other " +"information." +msgstr "获取你的联系地址,包含国家、地理位置、街道和其他信息" + +#: models.py:44 +msgid "Response Type Value" +msgstr "响应类型值" + +#: models.py:58 +msgid "Name" +msgstr "名称" + +#: models.py:60 +msgid "Owner" +msgstr "拥有者" + +#: models.py:66 +msgid "Client Type" +msgstr "客户端类型" + +#: models.py:67 +msgid "" +"Confidential clients are capable of maintaining the confidentiality " +"of their credentials. Public clients are incapable." +msgstr "" +"机密 客户端能够保证凭证信息的机密性 公共 客户端无法保证凭证信息" +"的机密性" + +#: models.py:69 +msgid "Client ID" +msgstr "客户端ID" + +#: models.py:70 +msgid "Client SECRET" +msgstr "客户端密钥" + +#: models.py:76 +msgid "JWT Algorithm" +msgstr "JWT算法" + +#: models.py:77 +msgid "Algorithm used to encode ID Tokens." +msgstr "用于加密身份令牌的算法" + +#: models.py:78 +msgid "Date Created" +msgstr "创建日期" + +#: models.py:80 +msgid "Website URL" +msgstr "网页URL" + +#: models.py:85 +msgid "Terms URL" +msgstr "团队URL" + +#: models.py:86 +msgid "External reference to the privacy policy of the client." +msgstr "额外的客户端隐私政策" + +#: models.py:88 +msgid "Contact Email" +msgstr "联系邮件" + +#: models.py:90 +msgid "Logo Image" +msgstr "Logo" + +#: models.py:93 +msgid "Reuse Consent?" +msgstr "复用授权" + +#: models.py:94 +msgid "" +"If enabled, server will save the user consent given to a specific client, so " +"that user won't be prompted for the same authorization multiple times." +msgstr "如果启用,服务器会记录用户对客户端的授权信息,用户不需要每次都授权" + +#: models.py:98 +msgid "Require Consent?" +msgstr "需要用户授权?" + +#: models.py:99 +msgid "If disabled, the Server will NEVER ask the user for consent." +msgstr "如果关闭,服务器将不会需要用户授权" + +#: models.py:101 +msgid "Redirect URIs" +msgstr "重定向URI" + +#: models.py:102 models.py:107 +msgid "Enter each URI on a new line." +msgstr "一行一个URI" + +#: models.py:106 +msgid "Post Logout Redirect URIs" +msgstr "登出后的重定向URI" + +#: models.py:111 models.py:164 +msgid "Scopes" +msgstr "授权范围" + +#: models.py:112 +msgid "Specifies the authorized scope values for the client app." +msgstr "指定客户端的可用授权范围" + +#: models.py:115 models.py:162 +msgid "Client" +msgstr "客户端" + +#: models.py:116 +msgid "Clients" +msgstr "客户端" + +#: models.py:163 +msgid "Expiration Date" +msgstr "失效日期" + +#: models.py:187 models.py:206 models.py:242 +msgid "User" +msgstr "用户" + +#: models.py:188 +msgid "Code" +msgstr "授权码" + +#: models.py:189 +msgid "Nonce" +msgstr "随机字符串" + +#: models.py:190 +msgid "Is Authentication?" +msgstr "是否为认证?" + +#: models.py:191 +msgid "Code Challenge" +msgstr "授权码验证" + +#: models.py:193 +msgid "Code Challenge Method" +msgstr "授权码验证方式" + +#: models.py:196 +msgid "Authorization Code" +msgstr "授权码" + +#: models.py:197 +msgid "Authorization Codes" +msgstr "授权码" + +#: models.py:207 +msgid "Access Token" +msgstr "访问令牌" + +#: models.py:208 +msgid "Refresh Token" +msgstr "刷新令牌" + +#: models.py:209 +msgid "ID Token" +msgstr "身份令牌" + +#: models.py:212 +msgid "Token" +msgstr "令牌" + +#: models.py:213 +msgid "Tokens" +msgstr "令牌" + +#: models.py:243 +msgid "Date Given" +msgstr "授予日期" + +#: models.py:252 +msgid "Key" +msgstr "私钥" + +#: models.py:252 +msgid "Paste your private RSA Key here." +msgstr "在此粘贴你的RSA私钥" + +#: models.py:256 +msgid "RSA Key" +msgstr "RSA密钥" + +#: models.py:257 +msgid "RSA Keys" +msgstr "RSA密钥" From 76f17f58e5f15d8adc7a33712f4fed9d7154e4c5 Mon Sep 17 00:00:00 2001 From: juanifioren Date: Tue, 18 Feb 2025 12:00:36 -0300 Subject: [PATCH 30/54] Update changelog. --- docs/sections/changelog.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/sections/changelog.rst b/docs/sections/changelog.rst index 2b5fd7d0..45cc7dfa 100644 --- a/docs/sections/changelog.rst +++ b/docs/sections/changelog.rst @@ -10,6 +10,7 @@ Unreleased * Added: support of max_age parameter on authorization request. * Added: Passing Request Parameters as JWTs now returning request_not_supported error. +* Added: Simplified chinese translation. * Changed: Django 5 added to test matrix. * Changed: ID Token JSON encoder improved using DjangoJSONEncoder. * Changed: Use unittest.mock in tests. Remove mock library. From c44c4240892d76b33ada3e8883f3fe561ca78007 Mon Sep 17 00:00:00 2001 From: Stefan Foulis Date: Tue, 11 Mar 2025 16:37:38 +0100 Subject: [PATCH 31/54] Add python 3.12 and 3.13 to tests --- .github/workflows/main.yml | 2 ++ tox.ini | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 662af4f4..babb71dd 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -18,6 +18,8 @@ jobs: 3.9 3.10 3.11 + 3.12 + 3.13 - name: Install tox run: | python -m pip install --upgrade pip diff --git a/tox.ini b/tox.ini index 26091bc5..925411c5 100644 --- a/tox.ini +++ b/tox.ini @@ -5,6 +5,8 @@ envlist= py39-django{32,40,41,42}, py310-django{32,40,41,42,50,51}, py311-django{41,42,50,51}, + py312-django{41,42,50,51}, + py313-django{41,42,50,51}, flake8 [testenv] From b4afd68f205582bceb6adb3f54bd3b4388496f37 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Thu, 27 Mar 2025 14:55:04 -0300 Subject: [PATCH 32/54] Update setup.py --- setup.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/setup.py b/setup.py index 77cca0c2..59de70e0 100644 --- a/setup.py +++ b/setup.py @@ -35,6 +35,8 @@ "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", ], From 4e1e5bfcbbba4420d6079e072cb9b552ad8b6a0c Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Thu, 27 Mar 2025 15:00:01 -0300 Subject: [PATCH 33/54] Update changelog.rst --- docs/sections/changelog.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/sections/changelog.rst b/docs/sections/changelog.rst index 45cc7dfa..60ff26b1 100644 --- a/docs/sections/changelog.rst +++ b/docs/sections/changelog.rst @@ -8,10 +8,11 @@ All notable changes to this project will be documented in this file. Unreleased ========== +* Added: test package against Python 3.12 and 3.13. +* Added: test package against Django 5. * Added: support of max_age parameter on authorization request. * Added: Passing Request Parameters as JWTs now returning request_not_supported error. * Added: Simplified chinese translation. -* Changed: Django 5 added to test matrix. * Changed: ID Token JSON encoder improved using DjangoJSONEncoder. * Changed: Use unittest.mock in tests. Remove mock library. From 41825b9cadc48a77c44e582199ee51c1e7e38778 Mon Sep 17 00:00:00 2001 From: Juani Date: Sat, 24 May 2025 13:33:57 -0300 Subject: [PATCH 34/54] Bump version 0.8.4 --- docs/sections/changelog.rst | 7 +++++++ oidc_provider/version.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/sections/changelog.rst b/docs/sections/changelog.rst index 60ff26b1..2351fc5f 100644 --- a/docs/sections/changelog.rst +++ b/docs/sections/changelog.rst @@ -8,6 +8,13 @@ All notable changes to this project will be documented in this file. Unreleased ========== +Nothing yet. + +0.8.4 +===== + +*2025-05-24* + * Added: test package against Python 3.12 and 3.13. * Added: test package against Django 5. * Added: support of max_age parameter on authorization request. diff --git a/oidc_provider/version.py b/oidc_provider/version.py index 732155f8..fa3ddd8c 100644 --- a/oidc_provider/version.py +++ b/oidc_provider/version.py @@ -1 +1 @@ -__version__ = "0.8.3" +__version__ = "0.8.4" From 07db4105902fedb48d32a5d6d6ba1d078f9a6db8 Mon Sep 17 00:00:00 2001 From: Alexey Sinyawskiy Date: Thu, 20 Feb 2025 00:02:01 +0300 Subject: [PATCH 35/54] Added translation to Russian --- oidc_provider/locale/ru/LC_MESSAGES/django.mo | Bin 0 -> 2869 bytes oidc_provider/locale/ru/LC_MESSAGES/django.po | 192 ++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 oidc_provider/locale/ru/LC_MESSAGES/django.mo create mode 100644 oidc_provider/locale/ru/LC_MESSAGES/django.po diff --git a/oidc_provider/locale/ru/LC_MESSAGES/django.mo b/oidc_provider/locale/ru/LC_MESSAGES/django.mo new file mode 100644 index 0000000000000000000000000000000000000000..488e852450802461ec311ab800c3934131b63448 GIT binary patch literal 2869 zcma)7U2GIp6uv5;)*loV6)~O%ArL!Di^Q1i4=iQD4gFb5ixHn@XK%Ly+nM#wEKCQ>=brPO z@0>gRWyO-W1;*<5`3JZL&(F8u2cvYW5J!M71D^-}1>6J7+$O~1z>k4^_G{n+z+ZtY zfLG%AvL!;?gZV1pGT=JkUBC^%+ktJsyMbN6CSVrGeqRDE2aW>S-*F&ZiuZx|6CdG+ zfS&;Q{C7b1_dW0q;7>q~`x}ts{u$%nK>P`@RERf_<5J)cn4W~uL7)pK?*V@Ywg3;q zSsU;?a4ql;AoE*!rw~s7HvxG*0DKgvfDZvDfE@R1y#86d{td7h>p#Zp%aHVwm=lnB zr-6K~7x)Zt5cn8y0?2uO3cMFs1tx&s#`{;|{pAq%5v;EUatT-B;d4CL4-eMyK0K@| z`{MfX;JSd8iO?(4UjNJtZ zLV#>JS9G04u3ay7o)9U=kwHMAPosWWk)6*g%oCLHoMJgI1H>wOp&D(bqVL#6L~EuX zR8odydwKE^SCQ)t`D(-tUEf>eF=q#^bB)D9lqd!o z7%j~&1!5ah?@|(wuTOTk`=uw+UI>w-?F`dke};UIZ1UtQRCGO=5a|OYSJf%71Ca@c ziE&prW_h#NDMv*wv=Ui6KnPQ*l5+QRy#8G&!aku6aF%UITEtuP7>ItEca?NPrV)r; zj$e|281VN=FA#%)R3fYVz1X)hd26>+0qU8gY^rN#YDb!Or~7wh`g)uCWyx2e)e{ul zytTDl2&@4=Nln>4t6%PS`HaPRBz3HZIq+ksw+Y5?z z=&K~X;O})@32nC8D)a-_-iOXLulBwz1uk*I@kcsw&?}Vfg0u#tJ(8sERPT;K=GETa zy`|_Td{tPm0iT{sPkJGV)GXBX_4 z1Z6$Km&FS%w~0U5ub$6r{Wmn`ZA~x!c?Ny zk-$tePVhVpu2qm2Pmq3#vx_F-wh~Rj7nqquqVZ*MjbY_H z%hJGBS8ovfGGdJxxn>)rooPHaa~+Y%=r}yX1V7Ofh(K_C2?$*vQiTkwFY7gfCR2@^ zpyec=V4y2(Z#GZC(8$b9ej)Bg$jo}wcT7C4#<4odibgdI1^)g79W6vsokQ=T@AsOS0`wCGd+V|teI{$8E}qhZ)VH) r5_h-pKY-95np;~jS@N|3(b$knz>;vDCxkwY34eA>Ese-%CzSAClq{a@ literal 0 HcmV?d00001 diff --git a/oidc_provider/locale/ru/LC_MESSAGES/django.po b/oidc_provider/locale/ru/LC_MESSAGES/django.po new file mode 100644 index 00000000..649bb11d --- /dev/null +++ b/oidc_provider/locale/ru/LC_MESSAGES/django.po @@ -0,0 +1,192 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# Wojciech Bartosiak , 2016. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2016-09-06 13:01+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: Wojciech Bartosiak \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 " +"|| n%100>=20) ? 1 : 2);\n" + +#: lib/claims.py:95 +msgid "Basic profile" +msgstr "Базовый профиль" + +#: lib/claims.py:96 +msgid "" +"Access to your basic information. Includes names, gender, birthdate and " +"other information." +msgstr "" +"Доступ к персональным данным. Включающим имя, пол, дата рождения " +"и другую информацию." + +#: lib/claims.py:119 +msgid "Email" +msgstr "" + +#: lib/claims.py:120 +msgid "Access to your email address." +msgstr "Доступ к вашему Email адресу." + +#: lib/claims.py:131 +msgid "Phone number" +msgstr "Номер телефона" + +#: lib/claims.py:132 +msgid "Access to your phone number." +msgstr "Доступ к вашему номеру телефона." + +#: lib/claims.py:143 +msgid "Address information" +msgstr "Адресная информация." + +#: lib/claims.py:144 +msgid "" +"Access to your address. Includes country, locality, street and other " +"information." +msgstr "" +"Доступ к вашему адресу. Включая страну, город, улицу и другие " +"данные." + +#: models.py:32 +msgid "Name" +msgstr "Название" + +#: models.py:33 +msgid "Client Type" +msgstr "Тип клиента" + +#: models.py:33 +msgid "" +"Confidential clients are capable of maintaining the confidentiality " +"of their credentials. Public clients are incapable." +msgstr "" +"Конфиденциальные клиенты способны сохранить конфиденциальность " +"своих учетных данных. Публичные не способны." + +#: models.py:34 +msgid "Client ID" +msgstr "" + +#: models.py:35 +msgid "Client SECRET" +msgstr "" + +#: models.py:36 +msgid "Response Type" +msgstr "" + +#: models.py:37 +msgid "JWT Algorithm" +msgstr "" + +#: models.py:38 +msgid "Date Created" +msgstr "Дата создания" + +#: models.py:40 +msgid "Redirect URIs" +msgstr "Список доверенных Redirect URI" + +#: models.py:40 +msgid "Enter each URI on a new line." +msgstr "Каждый URI с новой строки." + +#: models.py:43 models.py:70 +msgid "Client" +msgstr "Клиентская ИС" + +#: models.py:44 +msgid "Clients" +msgstr "КИС" + +#: models.py:69 +msgid "User" +msgstr "Пользователь" + +#: models.py:71 +msgid "Expiration Date" +msgstr "Срок действия" + +#: models.py:72 +msgid "Scopes" +msgstr "Области данных" + +#: models.py:99 +msgid "Code" +msgstr "" + +#: models.py:100 +msgid "Nonce" +msgstr "" + +#: models.py:101 +msgid "Is Authentication?" +msgstr "Это аутентификация?" + +#: models.py:102 +msgid "Code Challenge" +msgstr "" + +#: models.py:103 +msgid "Code Challenge Method" +msgstr "" + +#: models.py:106 +msgid "Authorization Code" +msgstr "Код авторизации" + +#: models.py:107 +msgid "Authorization Codes" +msgstr "Коды авторизации" + +#: models.py:112 +msgid "Access Token" +msgstr "" + +#: models.py:113 +msgid "Refresh Token" +msgstr "" + +#: models.py:114 +msgid "ID Token" +msgstr "" + +#: models.py:128 +msgid "Token" +msgstr "" + +#: models.py:129 +msgid "Tokens" +msgstr "Токены" + +#: models.py:146 +msgid "Date Given" +msgstr "Дата выдачи" + +#: models.py:154 +msgid "Key" +msgstr "Ключ" + +#: models.py:154 +msgid "Paste your private RSA Key here." +msgstr "Добавь сюда приватный ключ RSA." + +#: models.py:157 +msgid "RSA Key" +msgstr "Ключ RSA" + +#: models.py:158 +msgid "RSA Keys" +msgstr "Ключи RSA" From 144fe6e89bd4694883161d3e5f17c0ae02f3461b Mon Sep 17 00:00:00 2001 From: Alexey Sinyawskiy Date: Thu, 20 Feb 2025 00:05:50 +0300 Subject: [PATCH 36/54] Added translation to Russian --- oidc_provider/locale/ru/LC_MESSAGES/django.mo | Bin 2869 -> 2881 bytes oidc_provider/locale/ru/LC_MESSAGES/django.po | 6 +++--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/oidc_provider/locale/ru/LC_MESSAGES/django.mo b/oidc_provider/locale/ru/LC_MESSAGES/django.mo index 488e852450802461ec311ab800c3934131b63448..382a073deb7d3e145193e1040e4afbd7efa58dcb 100644 GIT binary patch delta 290 zcmdlgc2I1>pZXp~1_oAE28J693=EUl7#M1RbT&H!!)ze^0!SMG=@t$K25TU_6-cWA z>5o8K7D!8QGBC&kX)_=V;)elgNg!PUq(y;rACLwb$*=?}z8Wfi6iDj;#cy*m#DO(% za4|5b00oqQG|+4YcOWeQqyvDo8IVo{($YYB29V|f(p!KuACNu>r1gOG1*rUgsJt{c z&~ZRowpDDxpZYFF1_oAE28J693=9+47#M1RbS66k!)ze^3`iRQ=_U>a25TU_8Az)E z>Gwcd7D$V6GBC&kX%ip~;)eifNg!PWq(y;r50C~L$*>42z7i^a7)a{?#cy&l#DO)i zaWOEc00k6)G|+4YS0D}2=m(_DfOII3mIl&2K$-_guK?0~Kzb{X)&tT$q8H! E0Somj)c^nh diff --git a/oidc_provider/locale/ru/LC_MESSAGES/django.po b/oidc_provider/locale/ru/LC_MESSAGES/django.po index 649bb11d..1bd7c108 100644 --- a/oidc_provider/locale/ru/LC_MESSAGES/django.po +++ b/oidc_provider/locale/ru/LC_MESSAGES/django.po @@ -1,16 +1,16 @@ # SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. -# Wojciech Bartosiak , 2016. +# Sinyawskiy Aleksey , 2025. # #, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2016-09-06 13:01+0200\n" +"POT-Creation-Date: 2025-02-20 13:01+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: Wojciech Bartosiak \n" +"Last-Translator: Sinyawskiy Aleksey \n" "Language-Team: LANGUAGE \n" "Language: \n" "MIME-Version: 1.0\n" From e7b9dcfdbf66febb6f28e12622f0bcd499003d11 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Tue, 10 Jun 2025 13:37:55 -0300 Subject: [PATCH 37/54] Update changelog.rst --- docs/sections/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sections/changelog.rst b/docs/sections/changelog.rst index 2351fc5f..4f9325ca 100644 --- a/docs/sections/changelog.rst +++ b/docs/sections/changelog.rst @@ -8,7 +8,7 @@ All notable changes to this project will be documented in this file. Unreleased ========== -Nothing yet. +* Added: Translation to Russian. 0.8.4 ===== From 1582ffcd0aaf96c1c3a5b0ffc76286cc0d2e6daa Mon Sep 17 00:00:00 2001 From: Juani Date: Sun, 24 Aug 2025 20:53:09 -0300 Subject: [PATCH 38/54] Update example folder. --- example/Dockerfile | 19 ++++++++++++++++++- example/README.md | 38 ++++---------------------------------- 2 files changed, 22 insertions(+), 35 deletions(-) diff --git a/example/Dockerfile b/example/Dockerfile index abe0b7ec..dd977efa 100644 --- a/example/Dockerfile +++ b/example/Dockerfile @@ -1,6 +1,23 @@ -FROM python:3-onbuild +FROM python:3.11-slim + +WORKDIR /usr/src/app + +# Copy requirements and install dependencies +COPY requirements.txt . +RUN pip install --upgrade pip && \ + pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . RUN [ "python", "manage.py", "migrate" ] RUN [ "python", "manage.py", "creatersakey" ] + +# Create superuser with admin:admin credentials +ENV DJANGO_SUPERUSER_USERNAME=admin +ENV DJANGO_SUPERUSER_EMAIL=admin@example.com +ENV DJANGO_SUPERUSER_PASSWORD=admin +RUN [ "python", "manage.py", "createsuperuser", "--noinput" ] + EXPOSE 8000 CMD [ "python", "manage.py", "runserver", "0.0.0.0:8000" ] diff --git a/example/README.md b/example/README.md index c3f78c7c..e3ce1b9d 100644 --- a/example/README.md +++ b/example/README.md @@ -1,48 +1,18 @@ # Example Project -![Example Project](https://s17.postimg.org/4jjj8lavj/Screen_Shot_2016_09_07_at_15_58_43.png) - On this example you'll be running your own OIDC provider in a second. This is a Django app with all the necessary things to work with `django-oidc-provider` package. -## Setup & Running - -- [Manually](#manually) -- [Using Docker](#using-docker) - -### Manually - -Setup project environment with [virtualenv](https://virtualenv.pypa.io) and [pip](https://pip.pypa.io). - -```bash -$ virtualenv -p /usr/bin/python3 project_env - -$ source project_env/bin/activate - -$ git clone https://github.com/juanifioren/django-oidc-provider.git -$ cd django-oidc-provider/example -$ pip install -r requirements.txt -``` - -Run your provider. - -```bash -$ python manage.py migrate -$ python manage.py creatersakey -$ python manage.py createsuperuser -$ python manage.py runserver -``` - -Open your browser and go to `http://localhost:8000`. Voilà! - -### Using Docker +## Setup & running using Docker Build and run the container. ```bash $ docker build -t django-oidc-provider . -$ docker run -d -p 8000:8000 django-oidc-provider +$ docker run -p 8000:8000 --name django-oidc-provider-app django-oidc-provider ``` +Go to http://localhost:8000/ and create your Client. + ## Install package for development After you run `pip install -r requirements.txt`. From 6070687ecf7b2e3e5ef6af7e2ea90d15f487fe9f Mon Sep 17 00:00:00 2001 From: Stefan Foulis Date: Tue, 19 Aug 2025 17:44:56 +0200 Subject: [PATCH 39/54] Enforce consistent formatting using ruff - replaces flake8 checks with ruff - ruff linting for consistent import sorting - consistent ruff auto-formatting in vscode config (including import sorting) - gh-action: linting and formatting checks using ruff in a separate jobs (for faster feedback) --- .github/workflows/main.yml | 24 ++++++++++++++++++++++-- .vscode/settings.json | 4 ++-- pyproject.toml | 8 ++++++++ tox.ini | 10 +++++----- 4 files changed, 37 insertions(+), 9 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index babb71dd..4ff2934b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -7,10 +7,28 @@ on: branches: ["develop"] jobs: + formatting: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/ruff-action@v3 + with: + args: "--version" + - run: "ruff format --check --diff" + + linting: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/ruff-action@v3 + with: + args: "--version" + - run: "ruff check --diff" + tests: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: actions/setup-python@v4 with: python-version: | @@ -25,4 +43,6 @@ jobs: python -m pip install --upgrade pip pip install tox - name: Run tox - run: tox + # we skip the 'ruff' env here, because we are running it in a separate + # parallel job already + run: tox --skip-env ruff diff --git a/.vscode/settings.json b/.vscode/settings.json index e64211ad..001feee6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,8 +2,8 @@ "[python]": { "editor.formatOnSave": true, "editor.codeActionsOnSave": { - "source.fixAll": "explicit", - "source.organizeImports": "explicit" + "source.fixAll.ruff": "explicit", + "source.organizeImports.ruff": "explicit" }, "editor.defaultFormatter": "charliermarsh.ruff" } diff --git a/pyproject.toml b/pyproject.toml index 17304614..defdc295 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,13 @@ [tool.ruff] line-length = 100 +[tool.ruff.lint] +select = [ + # Pyflakes + "F", + # isort + "I", +] + [tool.ruff.lint.isort] force-single-line = true \ No newline at end of file diff --git a/tox.ini b/tox.ini index 925411c5..76feec13 100644 --- a/tox.ini +++ b/tox.ini @@ -7,7 +7,7 @@ envlist= py311-django{41,42,50,51}, py312-django{41,42,50,51}, py313-django{41,42,50,51}, - flake8 + ruff [testenv] changedir= @@ -23,7 +23,6 @@ deps = psycopg2-binary pytest pytest-django - pytest-flake8 pytest-cov commands = @@ -41,12 +40,13 @@ commands = mkdir -p _static/ sphinx-build -v -W -b html -d {envtmpdir}/doctrees -D html_static_path="_static" . {envtmpdir}/html -[testenv:flake8] +[testenv:ruff] basepython = python3.11 deps = - flake8 + ruff commands = - flake8 . --exclude=venv/,.tox/,migrations --max-line-length 100 + ruff check --diff + ruff format --check --diff [pytest] DJANGO_SETTINGS_MODULE = oidc_provider.tests.settings From 2c774de24574164f21134409544ab0464e5d5111 Mon Sep 17 00:00:00 2001 From: Stefan Foulis Date: Tue, 19 Aug 2025 19:10:08 +0200 Subject: [PATCH 40/54] run "ruff check --fix" and "ruff format" on entire codebase --- docs/conf.py | 55 +++-- example/app/urls.py | 16 +- example/app/wsgi.py | 3 +- example/manage.py | 4 +- oidc_provider/admin.py | 93 ++++--- oidc_provider/apps.py | 5 +- oidc_provider/lib/claims.py | 155 ++++++------ oidc_provider/lib/endpoints/introspection.py | 86 ++++--- oidc_provider/lib/errors.py | 168 ++++++------- oidc_provider/lib/utils/authorize.py | 22 +- oidc_provider/lib/utils/common.py | 51 ++-- oidc_provider/lib/utils/oauth2.py | 48 ++-- oidc_provider/lib/utils/token.py | 18 +- .../management/commands/creatersakey.py | 9 +- oidc_provider/middleware.py | 4 +- oidc_provider/migrations/0001_initial.py | 150 +++++++----- oidc_provider/migrations/0002_userconsent.py | 25 +- oidc_provider/migrations/0003_code_nonce.py | 12 +- .../migrations/0004_remove_userinfo.py | 9 +- .../migrations/0005_token_refresh_token.py | 10 +- .../migrations/0006_unique_user_client.py | 7 +- .../migrations/0007_auto_20160111_1844.py | 40 +-- oidc_provider/migrations/0008_rsakey.py | 17 +- .../migrations/0009_auto_20160202_1945.py | 16 +- .../migrations/0010_code_is_authentication.py | 10 +- .../migrations/0011_client_client_type.py | 21 +- .../migrations/0012_auto_20160405_2041.py | 12 +- .../migrations/0013_auto_20160407_1912.py | 14 +- .../migrations/0014_client_jwt_alg.py | 17 +- .../migrations/0015_change_client_code.py | 89 +++---- .../0016_userconsent_and_verbosenames.py | 228 ++++++++++-------- .../migrations/0017_auto_20160811_1954.py | 80 +++--- .../0018_hybridflow_and_clientattrs.py | 71 +++--- .../migrations/0019_auto_20161005_1552.py | 12 +- .../0020_client__post_logout_redirect_uris.py | 17 +- .../0021_refresh_token_not_unique.py | 14 +- .../migrations/0022_auto_20170331_1626.py | 24 +- oidc_provider/migrations/0023_client_owner.py | 22 +- .../migrations/0024_auto_20180327_1959.py | 16 +- .../migrations/0025_user_field_codetoken.py | 30 ++- .../0026_client_multiple_response_types.py | 60 +++-- .../migrations/0027_alter_rsakey_options.py | 11 +- oidc_provider/settings.py | 40 ++- oidc_provider/signals.py | 1 - oidc_provider/tests/app/urls.py | 26 +- oidc_provider/tests/cases/test_claims.py | 91 +++---- oidc_provider/tests/cases/test_commands.py | 9 +- oidc_provider/tests/cases/test_settings.py | 21 +- .../tests/cases/test_userinfo_endpoint.py | 71 +++--- oidc_provider/tests/settings.py | 78 +++--- oidc_provider/urls.py | 6 +- 51 files changed, 1142 insertions(+), 972 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index f17fbd1e..54880468 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -31,7 +31,7 @@ extensions = [] # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: @@ -41,28 +41,28 @@ # source_encoding = 'utf-8-sig' # The master toctree document. -master_doc = 'index' +master_doc = "index" # General information about the project. -project = u'django-oidc-provider' -copyright = u'2025, Juan Ignacio Fiorentino' -author = u'Juan Ignacio Fiorentino' +project = "django-oidc-provider" +copyright = "2025, Juan Ignacio Fiorentino" +author = "Juan Ignacio Fiorentino" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. -version = u'0.8' +version = "0.8" # The full version, including alpha/beta/rc tags. -release = u'0.8' +release = "0.8" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = 'en' +language = "en" # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: @@ -72,7 +72,7 @@ # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. -exclude_patterns = ['_build'] +exclude_patterns = ["_build"] # The reST default role (used for this markup: `text`) to use for all # documents. @@ -90,7 +90,7 @@ # show_authors = False # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = "sphinx" # A list of ignored prefixes for module index sorting. # modindex_common_prefix = [] @@ -106,7 +106,7 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'sphinx_rtd_theme' +html_theme = "sphinx_rtd_theme" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the @@ -135,7 +135,7 @@ # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = ["_static"] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied @@ -198,20 +198,17 @@ # html_search_scorer = 'scorer.js' # Output file base name for HTML help builder. -htmlhelp_basename = 'django-oidc-providerdoc' +htmlhelp_basename = "django-oidc-providerdoc" # -- Options for LaTeX output --------------------------------------------- latex_elements = { # The paper size ('letterpaper' or 'a4paper'). # 'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). # 'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. # 'preamble': '', - # Latex figure (float) alignment # 'figure_align': 'htbp', } @@ -220,8 +217,13 @@ # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, 'django-oidc-provider.tex', u'django-oidc-provider Documentation', - u'Juan Ignacio Fiorentino', 'manual'), + ( + master_doc, + "django-oidc-provider.tex", + "django-oidc-provider Documentation", + "Juan Ignacio Fiorentino", + "manual", + ), ] # The name of an image file (relative to this directory) to place at the top of @@ -250,8 +252,7 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - (master_doc, 'django-oidc-provider', u'django-oidc-provider Documentation', - [author], 1) + (master_doc, "django-oidc-provider", "django-oidc-provider Documentation", [author], 1) ] # If true, show URL addresses after external links. @@ -264,9 +265,15 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - (master_doc, 'django-oidc-provider', u'django-oidc-provider Documentation', - author, 'django-oidc-provider', 'One line description of project.', - 'Miscellaneous'), + ( + master_doc, + "django-oidc-provider", + "django-oidc-provider Documentation", + author, + "django-oidc-provider", + "One line description of project.", + "Miscellaneous", + ), ] # Documents to append as an appendix to all manuals. @@ -328,7 +335,7 @@ # epub_post_files = [] # A list of files that should not be packed into the epub file. -epub_exclude_files = ['search.html'] +epub_exclude_files = ["search.html"] # The depth of the table of contents in toc.ncx. # epub_tocdepth = 3 diff --git a/example/app/urls.py b/example/app/urls.py index 93e8b895..1cb40cf1 100644 --- a/example/app/urls.py +++ b/example/app/urls.py @@ -1,13 +1,15 @@ -from django.contrib.auth import views as auth_views -from django.urls import include, re_path from django.contrib import admin +from django.contrib.auth import views as auth_views +from django.urls import include +from django.urls import re_path from django.views.generic import TemplateView - urlpatterns = [ - re_path(r"^$", TemplateView.as_view(template_name='home.html'), name='home'), - re_path(r"^accounts/login/$", auth_views.LoginView.as_view(template_name='login.html'), name='login'), # noqa - re_path(r"^accounts/logout/$", auth_views.LogoutView.as_view(next_page='/'), name='logout'), - re_path(r"^", include('oidc_provider.urls', namespace='oidc_provider')), + re_path(r"^$", TemplateView.as_view(template_name="home.html"), name="home"), + re_path( + r"^accounts/login/$", auth_views.LoginView.as_view(template_name="login.html"), name="login" + ), # noqa + re_path(r"^accounts/logout/$", auth_views.LogoutView.as_view(next_page="/"), name="logout"), + re_path(r"^", include("oidc_provider.urls", namespace="oidc_provider")), re_path(r"^admin/", admin.site.urls), ] diff --git a/example/app/wsgi.py b/example/app/wsgi.py index 7c75d281..d468f8da 100644 --- a/example/app/wsgi.py +++ b/example/app/wsgi.py @@ -2,7 +2,6 @@ from django.core.wsgi import get_wsgi_application - -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings") application = get_wsgi_application() diff --git a/example/manage.py b/example/manage.py index 7adfe491..72238252 100755 --- a/example/manage.py +++ b/example/manage.py @@ -2,8 +2,8 @@ import os import sys -if __name__ == '__main__': - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings") from django.core.management import execute_from_command_line diff --git a/oidc_provider/admin.py b/oidc_provider/admin.py index 86a90fc7..e060e092 100644 --- a/oidc_provider/admin.py +++ b/oidc_provider/admin.py @@ -2,45 +2,47 @@ from random import randint from uuid import uuid4 -from django.forms import ModelForm from django.contrib import admin +from django.forms import ModelForm from django.utils.translation import gettext_lazy as _ -from oidc_provider.models import Client, Code, Token, RSAKey +from oidc_provider.models import Client +from oidc_provider.models import Code +from oidc_provider.models import RSAKey +from oidc_provider.models import Token class ClientForm(ModelForm): - class Meta: model = Client exclude = [] def __init__(self, *args, **kwargs): super(ClientForm, self).__init__(*args, **kwargs) - self.fields['client_id'].required = False - self.fields['client_id'].widget.attrs['disabled'] = 'true' - self.fields['client_secret'].required = False - self.fields['client_secret'].widget.attrs['disabled'] = 'true' + self.fields["client_id"].required = False + self.fields["client_id"].widget.attrs["disabled"] = "true" + self.fields["client_secret"].required = False + self.fields["client_secret"].widget.attrs["disabled"] = "true" def clean_client_id(self): - instance = getattr(self, 'instance', None) + instance = getattr(self, "instance", None) if instance and instance.pk: return instance.client_id else: return str(randint(1, 999999)).zfill(6) def clean_client_secret(self): - instance = getattr(self, 'instance', None) + instance = getattr(self, "instance", None) - secret = '' + secret = "" if instance and instance.pk: - if (self.cleaned_data['client_type'] == 'confidential') and not instance.client_secret: + if (self.cleaned_data["client_type"] == "confidential") and not instance.client_secret: secret = sha224(uuid4().hex.encode()).hexdigest() - elif (self.cleaned_data['client_type'] == 'confidential') and instance.client_secret: + elif (self.cleaned_data["client_type"] == "confidential") and instance.client_secret: secret = instance.client_secret else: - if (self.cleaned_data['client_type'] == 'confidential'): + if self.cleaned_data["client_type"] == "confidential": secret = sha224(uuid4().hex.encode()).hexdigest() return secret @@ -48,34 +50,51 @@ def clean_client_secret(self): @admin.register(Client) class ClientAdmin(admin.ModelAdmin): - fieldsets = [ - [_(u''), { - 'fields': ( - 'name', 'owner', 'client_type', 'response_types', '_redirect_uris', 'jwt_alg', - 'require_consent', 'reuse_consent'), - }], - [_(u'Credentials'), { - 'fields': ('client_id', 'client_secret', '_scope'), - }], - [_(u'Information'), { - 'fields': ('contact_email', 'website_url', 'terms_url', 'logo', 'date_created'), - }], - [_(u'Session Management'), { - 'fields': ('_post_logout_redirect_uris',), - }], + [ + _(""), + { + "fields": ( + "name", + "owner", + "client_type", + "response_types", + "_redirect_uris", + "jwt_alg", + "require_consent", + "reuse_consent", + ), + }, + ], + [ + _("Credentials"), + { + "fields": ("client_id", "client_secret", "_scope"), + }, + ], + [ + _("Information"), + { + "fields": ("contact_email", "website_url", "terms_url", "logo", "date_created"), + }, + ], + [ + _("Session Management"), + { + "fields": ("_post_logout_redirect_uris",), + }, + ], ] form = ClientForm - list_display = ['name', 'client_id', 'response_type_descriptions', 'date_created'] - readonly_fields = ['date_created'] - search_fields = ['name'] - raw_id_fields = ['owner'] + list_display = ["name", "client_id", "response_type_descriptions", "date_created"] + readonly_fields = ["date_created"] + search_fields = ["name"] + raw_id_fields = ["owner"] @admin.register(Code) class CodeAdmin(admin.ModelAdmin): - - raw_id_fields = ['user'] + raw_id_fields = ["user"] def has_add_permission(self, request): return False @@ -83,8 +102,7 @@ def has_add_permission(self, request): @admin.register(Token) class TokenAdmin(admin.ModelAdmin): - - raw_id_fields = ['user'] + raw_id_fields = ["user"] def has_add_permission(self, request): return False @@ -92,5 +110,4 @@ def has_add_permission(self, request): @admin.register(RSAKey) class RSAKeyAdmin(admin.ModelAdmin): - - readonly_fields = ['kid'] + readonly_fields = ["kid"] diff --git a/oidc_provider/apps.py b/oidc_provider/apps.py index 6d478dbf..119d06ff 100644 --- a/oidc_provider/apps.py +++ b/oidc_provider/apps.py @@ -3,6 +3,5 @@ class OIDCProviderConfig(AppConfig): - - name = 'oidc_provider' - verbose_name = _(u'OpenID Connect Provider') + name = "oidc_provider" + verbose_name = _("OpenID Connect Provider") diff --git a/oidc_provider/lib/claims.py b/oidc_provider/lib/claims.py index c641e5b5..bfe384c1 100644 --- a/oidc_provider/lib/claims.py +++ b/oidc_provider/lib/claims.py @@ -4,43 +4,41 @@ from oidc_provider import settings - STANDARD_CLAIMS = { - 'name': '', - 'given_name': '', - 'family_name': '', - 'middle_name': '', - 'nickname': '', - 'preferred_username': '', - 'profile': '', - 'picture': '', - 'website': '', - 'gender': '', - 'birthdate': '', - 'zoneinfo': '', - 'locale': '', - 'updated_at': '', - 'email': '', - 'email_verified': '', - 'phone_number': '', - 'phone_number_verified': '', - 'address': { - 'formatted': '', - 'street_address': '', - 'locality': '', - 'region': '', - 'postal_code': '', - 'country': '', + "name": "", + "given_name": "", + "family_name": "", + "middle_name": "", + "nickname": "", + "preferred_username": "", + "profile": "", + "picture": "", + "website": "", + "gender": "", + "birthdate": "", + "zoneinfo": "", + "locale": "", + "updated_at": "", + "email": "", + "email_verified": "", + "phone_number": "", + "phone_number_verified": "", + "address": { + "formatted": "", + "street_address": "", + "locality": "", + "region": "", + "postal_code": "", + "country": "", }, } class ScopeClaims(object): - def __init__(self, token): self.user = token.user claims = copy.deepcopy(STANDARD_CLAIMS) - self.userinfo = settings.get('OIDC_USERINFO', import_str=True)(claims, self.user) + self.userinfo = settings.get("OIDC_USERINFO", import_str=True)(claims, self.user) self.scopes = token.scope self.client = token.client @@ -55,7 +53,7 @@ def create_response_dic(self): for scope in self.scopes: if scope in self._scopes_registered(): - dic.update(getattr(self, 'scope_' + scope)()) + dic.update(getattr(self, "scope_" + scope)()) dic = self._clean_dic(dic) @@ -69,8 +67,8 @@ def _scopes_registered(self): scopes = [] for name in dir(self.__class__): - if name.startswith('scope_'): - scope = name.split('scope_')[1] + if name.startswith("scope_"): + scope = name.split("scope_")[1] scopes.append(scope) return scopes @@ -81,8 +79,7 @@ def _clean_dic(self, dic): """ aux_dic = dic.copy() for key, value in iter(dic.items()): - - if value is None or value == '': + if value is None or value == "": del aux_dic[key] elif type(value) is dict: cleaned_dict = self._clean_dic(value) @@ -99,15 +96,17 @@ def get_scopes_info(cls, scopes=None): scopes_info = [] for name in dir(cls): - if name.startswith('info_'): - scope_name = name.split('info_')[1] + if name.startswith("info_"): + scope_name = name.split("info_")[1] if scope_name in scopes: touple_info = getattr(cls, name) - scopes_info.append({ - 'scope': scope_name, - 'name': touple_info[0], - 'description': touple_info[1], - }) + scopes_info.append( + { + "scope": scope_name, + "name": touple_info[0], + "description": touple_info[1], + } + ) return scopes_info @@ -119,73 +118,77 @@ class StandardScopeClaims(ScopeClaims): """ info_profile = ( - _(u'Basic profile'), - _(u'Access to your basic information. Includes names, gender, birthdate ' - 'and other information.'), + _("Basic profile"), + _( + "Access to your basic information. Includes names, gender, birthdate " + "and other information." + ), ) def scope_profile(self): dic = { - 'name': self.userinfo.get('name'), - 'given_name': (self.userinfo.get('given_name') or - getattr(self.user, 'first_name', None)), - 'family_name': (self.userinfo.get('family_name') or - getattr(self.user, 'last_name', None)), - 'middle_name': self.userinfo.get('middle_name'), - 'nickname': self.userinfo.get('nickname') or getattr(self.user, 'username', None), - 'preferred_username': self.userinfo.get('preferred_username'), - 'profile': self.userinfo.get('profile'), - 'picture': self.userinfo.get('picture'), - 'website': self.userinfo.get('website'), - 'gender': self.userinfo.get('gender'), - 'birthdate': self.userinfo.get('birthdate'), - 'zoneinfo': self.userinfo.get('zoneinfo'), - 'locale': self.userinfo.get('locale'), - 'updated_at': self.userinfo.get('updated_at'), + "name": self.userinfo.get("name"), + "given_name": ( + self.userinfo.get("given_name") or getattr(self.user, "first_name", None) + ), + "family_name": ( + self.userinfo.get("family_name") or getattr(self.user, "last_name", None) + ), + "middle_name": self.userinfo.get("middle_name"), + "nickname": self.userinfo.get("nickname") or getattr(self.user, "username", None), + "preferred_username": self.userinfo.get("preferred_username"), + "profile": self.userinfo.get("profile"), + "picture": self.userinfo.get("picture"), + "website": self.userinfo.get("website"), + "gender": self.userinfo.get("gender"), + "birthdate": self.userinfo.get("birthdate"), + "zoneinfo": self.userinfo.get("zoneinfo"), + "locale": self.userinfo.get("locale"), + "updated_at": self.userinfo.get("updated_at"), } return dic info_email = ( - _(u'Email'), - _(u'Access to your email address.'), + _("Email"), + _("Access to your email address."), ) def scope_email(self): dic = { - 'email': self.userinfo.get('email') or getattr(self.user, 'email', None), - 'email_verified': self.userinfo.get('email_verified'), + "email": self.userinfo.get("email") or getattr(self.user, "email", None), + "email_verified": self.userinfo.get("email_verified"), } return dic info_phone = ( - _(u'Phone number'), - _(u'Access to your phone number.'), + _("Phone number"), + _("Access to your phone number."), ) def scope_phone(self): dic = { - 'phone_number': self.userinfo.get('phone_number'), - 'phone_number_verified': self.userinfo.get('phone_number_verified'), + "phone_number": self.userinfo.get("phone_number"), + "phone_number_verified": self.userinfo.get("phone_number_verified"), } return dic info_address = ( - _(u'Address information'), - _(u'Access to your address. Includes country, locality, street and other information.'), + _("Address information"), + _("Access to your address. Includes country, locality, street and other information."), ) def scope_address(self): dic = { - 'address': { - 'formatted': self.userinfo.get('address', {}).get('formatted'), - 'street_address': self.userinfo.get('address', {}).get('street_address'), - 'locality': self.userinfo.get('address', {}).get('locality'), - 'region': self.userinfo.get('address', {}).get('region'), - 'postal_code': self.userinfo.get('address', {}).get('postal_code'), - 'country': self.userinfo.get('address', {}).get('country'), + "address": { + "formatted": self.userinfo.get("address", {}).get("formatted"), + "street_address": self.userinfo.get("address", {}).get("street_address"), + "locality": self.userinfo.get("address", {}).get("locality"), + "region": self.userinfo.get("address", {}).get("region"), + "postal_code": self.userinfo.get("address", {}).get("postal_code"), + "country": self.userinfo.get("address", {}).get("country"), } } diff --git a/oidc_provider/lib/endpoints/introspection.py b/oidc_provider/lib/endpoints/introspection.py index c1e8a8e6..2ff023cb 100644 --- a/oidc_provider/lib/endpoints/introspection.py +++ b/oidc_provider/lib/endpoints/introspection.py @@ -2,19 +2,19 @@ from django.http import JsonResponse +from oidc_provider import settings from oidc_provider.lib.errors import TokenIntrospectionError from oidc_provider.lib.utils.common import run_processing_hook from oidc_provider.lib.utils.oauth2 import extract_client_auth -from oidc_provider.models import Token, Client -from oidc_provider import settings +from oidc_provider.models import Client +from oidc_provider.models import Token logger = logging.getLogger(__name__) -INTROSPECTION_SCOPE = 'token_introspection' +INTROSPECTION_SCOPE = "token_introspection" class TokenIntrospectionEndpoint(object): - def __init__(self, request): self.request = request self.params = {} @@ -25,72 +25,80 @@ def __init__(self, request): def _extract_params(self): # Introspection only supports POST requests - self.params['token'] = self.request.POST.get('token') + self.params["token"] = self.request.POST.get("token") client_id, client_secret = extract_client_auth(self.request) - self.params['client_id'] = client_id - self.params['client_secret'] = client_secret + self.params["client_id"] = client_id + self.params["client_secret"] = client_secret def validate_params(self): - if not (self.params['client_id'] and self.params['client_secret']): - logger.debug('[Introspection] No client credentials provided') + if not (self.params["client_id"] and self.params["client_secret"]): + logger.debug("[Introspection] No client credentials provided") raise TokenIntrospectionError() - if not self.params['token']: - logger.debug('[Introspection] No token provided') + if not self.params["token"]: + logger.debug("[Introspection] No token provided") raise TokenIntrospectionError() try: - self.token = Token.objects.get(access_token=self.params['token']) + self.token = Token.objects.get(access_token=self.params["token"]) except Token.DoesNotExist: - logger.debug('[Introspection] Token does not exist: %s', self.params['token']) + logger.debug("[Introspection] Token does not exist: %s", self.params["token"]) raise TokenIntrospectionError() if self.token.has_expired(): - logger.debug('[Introspection] Token is not valid: %s', self.params['token']) + logger.debug("[Introspection] Token is not valid: %s", self.params["token"]) raise TokenIntrospectionError() try: self.client = Client.objects.get( - client_id=self.params['client_id'], - client_secret=self.params['client_secret']) + client_id=self.params["client_id"], client_secret=self.params["client_secret"] + ) except Client.DoesNotExist: - logger.debug('[Introspection] No valid client for id: %s', - self.params['client_id']) + logger.debug("[Introspection] No valid client for id: %s", self.params["client_id"]) raise TokenIntrospectionError() if INTROSPECTION_SCOPE not in self.client.scope: - logger.debug('[Introspection] Client %s does not have introspection scope', - self.params['client_id']) + logger.debug( + "[Introspection] Client %s does not have introspection scope", + self.params["client_id"], + ) raise TokenIntrospectionError() self.id_token = self.token.id_token - if settings.get('OIDC_INTROSPECTION_VALIDATE_AUDIENCE_SCOPE'): + if settings.get("OIDC_INTROSPECTION_VALIDATE_AUDIENCE_SCOPE"): if not self.token.id_token: - logger.debug('[Introspection] Token not an authentication token: %s', - self.params['token']) + logger.debug( + "[Introspection] Token not an authentication token: %s", self.params["token"] + ) raise TokenIntrospectionError() - audience = self.token.id_token.get('aud') + audience = self.token.id_token.get("aud") if not audience: - logger.debug('[Introspection] No audience found for token: %s', - self.params['token']) + logger.debug( + "[Introspection] No audience found for token: %s", self.params["token"] + ) raise TokenIntrospectionError() if audience not in self.client.scope: - logger.debug('[Introspection] Client %s does not audience scope %s', - self.params['client_id'], audience) + logger.debug( + "[Introspection] Client %s does not audience scope %s", + self.params["client_id"], + audience, + ) raise TokenIntrospectionError() def create_response_dic(self): response_dic = {} if self.id_token: - for k in ('aud', 'sub', 'exp', 'iat', 'iss'): + for k in ("aud", "sub", "exp", "iat", "iss"): response_dic[k] = self.id_token[k] - response_dic['active'] = True - response_dic['client_id'] = self.token.client.client_id - if settings.get('OIDC_INTROSPECTION_RESPONSE_SCOPE_ENABLE'): - response_dic['scope'] = ' '.join(self.token.scope) - response_dic = run_processing_hook(response_dic, - 'OIDC_INTROSPECTION_PROCESSING_HOOK', - client=self.client, - id_token=self.id_token) + response_dic["active"] = True + response_dic["client_id"] = self.token.client.client_id + if settings.get("OIDC_INTROSPECTION_RESPONSE_SCOPE_ENABLE"): + response_dic["scope"] = " ".join(self.token.scope) + response_dic = run_processing_hook( + response_dic, + "OIDC_INTROSPECTION_PROCESSING_HOOK", + client=self.client, + id_token=self.id_token, + ) return response_dic @@ -100,7 +108,7 @@ def response(cls, dic, status=200): Create and return a response object. """ response = JsonResponse(dic, status=status) - response['Cache-Control'] = 'no-store' - response['Pragma'] = 'no-cache' + response["Cache-Control"] = "no-store" + response["Pragma"] = "no-cache" return response diff --git a/oidc_provider/lib/errors.py b/oidc_provider/lib/errors.py index 318fb969..f440e6ed 100644 --- a/oidc_provider/lib/errors.py +++ b/oidc_provider/lib/errors.py @@ -5,16 +5,16 @@ class RedirectUriError(Exception): - - error = 'Redirect URI Error' - description = 'The request fails due to a missing, invalid, or mismatching' \ - ' redirection URI (redirect_uri).' + error = "Redirect URI Error" + description = ( + "The request fails due to a missing, invalid, or mismatching" + " redirection URI (redirect_uri)." + ) class ClientIdError(Exception): - - error = 'Client ID Error' - description = 'The client identifier (client_id) is missing or invalid.' + error = "Client ID Error" + description = "The client identifier (client_id) is missing or invalid." class UserAuthError(Exception): @@ -22,13 +22,14 @@ class UserAuthError(Exception): Specific to the Resource Owner Password Credentials flow when the Resource Owners credentials are not valid. """ - error = 'access_denied' - description = 'The resource owner or authorization server denied the request.' + + error = "access_denied" + description = "The resource owner or authorization server denied the request." def create_dict(self): return { - 'error': self.error, - 'error_description': self.description, + "error": self.error, + "error_description": self.description, } @@ -38,64 +39,43 @@ class TokenIntrospectionError(Exception): to an "active: false" response, as per the spec. See https://tools.ietf.org/html/rfc7662 """ + pass class AuthorizeError(Exception): - _errors = { # Oauth2 errors. # https://tools.ietf.org/html/rfc6749#section-4.1.2.1 - 'invalid_request': 'The request is otherwise malformed', - - 'unauthorized_client': 'The client is not authorized to request an ' - 'authorization code using this method', - - 'access_denied': 'The resource owner or authorization server denied ' - 'the request', - - 'unsupported_response_type': 'The authorization server does not ' - 'support obtaining an authorization code ' - 'using this method', - - 'invalid_scope': 'The requested scope is invalid, unknown, or ' - 'malformed', - - 'server_error': 'The authorization server encountered an error', - - 'temporarily_unavailable': 'The authorization server is currently ' - 'unable to handle the request due to a ' - 'temporary overloading or maintenance of ' - 'the server', - + "invalid_request": "The request is otherwise malformed", + "unauthorized_client": "The client is not authorized to request an " + "authorization code using this method", + "access_denied": "The resource owner or authorization server denied the request", + "unsupported_response_type": "The authorization server does not " + "support obtaining an authorization code " + "using this method", + "invalid_scope": "The requested scope is invalid, unknown, or malformed", + "server_error": "The authorization server encountered an error", + "temporarily_unavailable": "The authorization server is currently " + "unable to handle the request due to a " + "temporary overloading or maintenance of " + "the server", # OpenID errors. # http://openid.net/specs/openid-connect-core-1_0.html#AuthError - 'interaction_required': 'The Authorization Server requires End-User ' - 'interaction of some form to proceed', - - 'login_required': 'The Authorization Server requires End-User ' - 'authentication', - - 'account_selection_required': 'The End-User is required to select a ' - 'session at the Authorization Server', - - 'consent_required': 'The Authorization Server requires End-User' - 'consent', - - 'invalid_request_uri': 'The request_uri in the Authorization Request ' - 'returns an error or contains invalid data', - - 'invalid_request_object': 'The request parameter contains an invalid ' - 'Request Object', - - 'request_not_supported': 'The provider does not support use of the ' - 'request parameter', - - 'request_uri_not_supported': 'The provider does not support use of the ' - 'request_uri parameter', - - 'registration_not_supported': 'The provider does not support use of ' - 'the registration parameter', + "interaction_required": "The Authorization Server requires End-User " + "interaction of some form to proceed", + "login_required": "The Authorization Server requires End-User authentication", + "account_selection_required": "The End-User is required to select a " + "session at the Authorization Server", + "consent_required": "The Authorization Server requires End-Userconsent", + "invalid_request_uri": "The request_uri in the Authorization Request " + "returns an error or contains invalid data", + "invalid_request_object": "The request parameter contains an invalid Request Object", + "request_not_supported": "The provider does not support use of the request parameter", + "request_uri_not_supported": "The provider does not support use of the " + "request_uri parameter", + "registration_not_supported": "The provider does not support use of " + "the registration parameter", } def __init__(self, redirect_uri, error, grant_type): @@ -109,16 +89,14 @@ def create_uri(self, redirect_uri, state): # See: # http://openid.net/specs/openid-connect-core-1_0.html#ImplicitAuthError - hash_or_question = '#' if self.grant_type == 'implicit' else '?' + hash_or_question = "#" if self.grant_type == "implicit" else "?" - uri = '{0}{1}error={2}&error_description={3}'.format( - redirect_uri, - hash_or_question, - self.error, - description) + uri = "{0}{1}error={2}&error_description={3}".format( + redirect_uri, hash_or_question, self.error, description + ) # Add state if present. - uri = uri + ('&state={0}'.format(state) if state else '') + uri = uri + ("&state={0}".format(state) if state else "") return uri @@ -130,25 +108,20 @@ class TokenError(Exception): """ _errors = { - 'invalid_request': 'The request is otherwise malformed', - - 'invalid_client': 'Client authentication failed (e.g., unknown client, ' - 'no client authentication included, or unsupported ' - 'authentication method)', - - 'invalid_grant': 'The provided authorization grant or refresh token is ' - 'invalid, expired, revoked, does not match the ' - 'redirection URI used in the authorization request, ' - 'or was issued to another client', - - 'unauthorized_client': 'The authenticated client is not authorized to ' - 'use this authorization grant type', - - 'unsupported_grant_type': 'The authorization grant type is not ' - 'supported by the authorization server', - - 'invalid_scope': 'The requested scope is invalid, unknown, malformed, ' - 'or exceeds the scope granted by the resource owner', + "invalid_request": "The request is otherwise malformed", + "invalid_client": "Client authentication failed (e.g., unknown client, " + "no client authentication included, or unsupported " + "authentication method)", + "invalid_grant": "The provided authorization grant or refresh token is " + "invalid, expired, revoked, does not match the " + "redirection URI used in the authorization request, " + "or was issued to another client", + "unauthorized_client": "The authenticated client is not authorized to " + "use this authorization grant type", + "unsupported_grant_type": "The authorization grant type is not " + "supported by the authorization server", + "invalid_scope": "The requested scope is invalid, unknown, malformed, " + "or exceeds the scope granted by the resource owner", } def __init__(self, error): @@ -157,8 +130,8 @@ def __init__(self, error): def create_dict(self): dic = { - 'error': self.error, - 'error_description': self.description, + "error": self.error, + "error_description": self.description, } return dic @@ -171,21 +144,20 @@ class BearerTokenError(Exception): """ _errors = { - 'invalid_request': ( - 'The request is otherwise malformed', 400 - ), - 'invalid_token': ( - 'The access token provided is expired, revoked, malformed, ' - 'or invalid for other reasons', 401 + "invalid_request": ("The request is otherwise malformed", 400), + "invalid_token": ( + "The access token provided is expired, revoked, malformed, " + "or invalid for other reasons", + 401, ), - 'insufficient_scope': ( - 'The request requires higher privileges than provided by ' - 'the access token', 403 + "insufficient_scope": ( + "The request requires higher privileges than provided by the access token", + 403, ), } def __init__(self, code): self.code = code - error_tuple = self._errors.get(code, ('', '')) + error_tuple = self._errors.get(code, ("", "")) self.description = error_tuple[0] self.status = error_tuple[1] diff --git a/oidc_provider/lib/utils/authorize.py b/oidc_provider/lib/utils/authorize.py index 006c9cc0..8183e237 100644 --- a/oidc_provider/lib/utils/authorize.py +++ b/oidc_provider/lib/utils/authorize.py @@ -1,8 +1,14 @@ try: from urllib import urlencode - from urlparse import urlsplit, parse_qs, urlunsplit + + from urlparse import parse_qs + from urlparse import urlsplit + from urlparse import urlunsplit except ImportError: - from urllib.parse import urlsplit, parse_qs, urlunsplit, urlencode + from urllib.parse import parse_qs + from urllib.parse import urlencode + from urllib.parse import urlsplit + from urllib.parse import urlunsplit def strip_prompt_login(path): @@ -11,11 +17,11 @@ def strip_prompt_login(path): """ uri = urlsplit(path) query_params = parse_qs(uri.query) - prompt_list = query_params.get('prompt', '')[0].split() - if 'login' in prompt_list: - prompt_list.remove('login') - query_params['prompt'] = ' '.join(prompt_list) - if not query_params['prompt']: - del query_params['prompt'] + prompt_list = query_params.get("prompt", "")[0].split() + if "login" in prompt_list: + prompt_list.remove("login") + query_params["prompt"] = " ".join(prompt_list) + if not query_params["prompt"]: + del query_params["prompt"] uri = uri._replace(query=urlencode(query_params, doseq=True)) return urlunsplit(uri) diff --git a/oidc_provider/lib/utils/common.py b/oidc_provider/lib/utils/common.py index 8d4623ec..e26a331e 100644 --- a/oidc_provider/lib/utils/common.py +++ b/oidc_provider/lib/utils/common.py @@ -6,7 +6,6 @@ from oidc_provider import settings - if django.VERSION >= (1, 11): from django.urls import reverse else: @@ -17,8 +16,8 @@ def redirect(uri): """ Custom Response object for redirecting to a Non-HTTP url scheme. """ - response = HttpResponse('', status=302) - response['Location'] = uri + response = HttpResponse("", status=302) + response["Location"] = uri return response @@ -31,15 +30,15 @@ def get_site_url(site_url=None, request=None): 2. valid `SITE_URL` in settings 3. construct from `request` object """ - site_url = site_url or settings.get('SITE_URL') + site_url = site_url or settings.get("SITE_URL") if site_url: return site_url elif request: - return '{}://{}'.format(request.scheme, request.get_host()) + return "{}://{}".format(request.scheme, request.get_host()) else: - raise Exception('Either pass `site_url`, ' - 'or set `SITE_URL` in settings, ' - 'or pass `request` object.') + raise Exception( + "Either pass `site_url`, or set `SITE_URL` in settings, or pass `request` object." + ) def get_issuer(site_url=None, request=None): @@ -48,8 +47,7 @@ def get_issuer(site_url=None, request=None): appended. """ site_url = get_site_url(site_url=site_url, request=request) - path = reverse('oidc_provider:provider-info') \ - .split('/.well-known/openid-configuration')[0] + path = reverse("oidc_provider:provider-info").split("/.well-known/openid-configuration")[0] issuer = site_url + path return str(issuer) @@ -78,8 +76,8 @@ def default_after_userlogin_hook(request, user, client): def default_after_end_session_hook( - request, id_token=None, post_logout_redirect_uri=None, - state=None, client=None, next_page=None): + request, id_token=None, post_logout_redirect_uri=None, state=None, client=None, next_page=None +): """ Default function for setting OIDC_AFTER_END_SESSION_HOOK. @@ -108,8 +106,7 @@ def default_after_end_session_hook( return None -def default_idtoken_processing_hook( - id_token, user, token, request, **kwargs): +def default_idtoken_processing_hook(id_token, user, token, request, **kwargs): """ Hook to perform some additional actions to `id_token` dictionary just before serialization. @@ -146,9 +143,8 @@ def get_browser_state_or_default(request): """ Determine value to use as session state. """ - key = (request.session.session_key or - settings.get('OIDC_UNAUTHENTICATED_SESSION_MANAGEMENT_KEY')) - return sha224(key.encode('utf-8')).hexdigest() + key = request.session.session_key or settings.get("OIDC_UNAUTHENTICATED_SESSION_MANAGEMENT_KEY") + return sha224(key.encode("utf-8")).hexdigest() def run_processing_hook(subject, hook_settings_name, **kwargs): @@ -168,19 +164,20 @@ def cors_allow_any(request, response): Add headers to permit CORS requests from any origin, with or without credentials, with any headers. """ - origin = request.META.get('HTTP_ORIGIN') + origin = request.META.get("HTTP_ORIGIN") if not origin: return response # From the CORS spec: The string "*" cannot be used for a resource that supports credentials. - response['Access-Control-Allow-Origin'] = origin - patch_vary_headers(response, ['Origin']) - response['Access-Control-Allow-Credentials'] = 'true' - - if request.method == 'OPTIONS': - if 'HTTP_ACCESS_CONTROL_REQUEST_HEADERS' in request.META: - response['Access-Control-Allow-Headers'] \ - = request.META['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'] - response['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS' + response["Access-Control-Allow-Origin"] = origin + patch_vary_headers(response, ["Origin"]) + response["Access-Control-Allow-Credentials"] = "true" + + if request.method == "OPTIONS": + if "HTTP_ACCESS_CONTROL_REQUEST_HEADERS" in request.META: + response["Access-Control-Allow-Headers"] = request.META[ + "HTTP_ACCESS_CONTROL_REQUEST_HEADERS" + ] + response["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS" return response diff --git a/oidc_provider/lib/utils/oauth2.py b/oidc_provider/lib/utils/oauth2.py index a3fe7a09..28918020 100644 --- a/oidc_provider/lib/utils/oauth2.py +++ b/oidc_provider/lib/utils/oauth2.py @@ -1,13 +1,12 @@ -from base64 import b64decode import logging import re +from base64 import b64decode from django.http import HttpResponse from oidc_provider.lib.errors import BearerTokenError from oidc_provider.models import Token - logger = logging.getLogger(__name__) @@ -19,12 +18,12 @@ def extract_access_token(request): Return a string. """ - auth_header = request.META.get('HTTP_AUTHORIZATION', '') + auth_header = request.META.get("HTTP_AUTHORIZATION", "") - if re.compile(r'^[Bb]earer\s{1}.+$').match(auth_header): + if re.compile(r"^[Bb]earer\s{1}.+$").match(auth_header): access_token = auth_header.split()[1] else: - access_token = request.GET.get('access_token', '') + access_token = request.GET.get("access_token", "") return access_token @@ -37,18 +36,18 @@ def extract_client_auth(request): Return a tuple `(client_id, client_secret)`. """ - auth_header = request.META.get('HTTP_AUTHORIZATION', '') + auth_header = request.META.get("HTTP_AUTHORIZATION", "") - if re.compile(r'^Basic\s{1}.+$').match(auth_header): + if re.compile(r"^Basic\s{1}.+$").match(auth_header): b64_user_pass = auth_header.split()[1] try: - user_pass = b64decode(b64_user_pass).decode('utf-8').split(':') + user_pass = b64decode(b64_user_pass).decode("utf-8").split(":") client_id, client_secret = tuple(user_pass) except Exception: - client_id = client_secret = '' + client_id = client_secret = "" else: - client_id = request.POST.get('client_id', '') - client_secret = request.POST.get('client_secret', '') + client_id = request.POST.get("client_id", "") + client_secret = request.POST.get("client_secret", "") return (client_id, client_secret) @@ -63,30 +62,31 @@ def protected_resource_view(scopes=None): scopes = [] def wrapper(view): - def view_wrapper(request, *args, **kwargs): + def view_wrapper(request, *args, **kwargs): access_token = extract_access_token(request) try: try: - kwargs['token'] = Token.objects.get(access_token=access_token) + kwargs["token"] = Token.objects.get(access_token=access_token) except Token.DoesNotExist: - logger.debug('[UserInfo] Token does not exist: %s', access_token) - raise BearerTokenError('invalid_token') + logger.debug("[UserInfo] Token does not exist: %s", access_token) + raise BearerTokenError("invalid_token") - if kwargs['token'].has_expired(): - logger.debug('[UserInfo] Token has expired: %s', access_token) - raise BearerTokenError('invalid_token') + if kwargs["token"].has_expired(): + logger.debug("[UserInfo] Token has expired: %s", access_token) + raise BearerTokenError("invalid_token") - if not set(scopes).issubset(set(kwargs['token'].scope)): - logger.debug('[UserInfo] Missing openid scope.') - raise BearerTokenError('insufficient_scope') + if not set(scopes).issubset(set(kwargs["token"].scope)): + logger.debug("[UserInfo] Missing openid scope.") + raise BearerTokenError("insufficient_scope") except BearerTokenError as error: response = HttpResponse(status=error.status) - response['WWW-Authenticate'] = 'error="{0}", error_description="{1}"'.format( - error.code, error.description) + response["WWW-Authenticate"] = 'error="{0}", error_description="{1}"'.format( + error.code, error.description + ) return response - return view(request, *args, **kwargs) + return view(request, *args, **kwargs) return view_wrapper diff --git a/oidc_provider/lib/utils/token.py b/oidc_provider/lib/utils/token.py index 403440ad..9083b36e 100644 --- a/oidc_provider/lib/utils/token.py +++ b/oidc_provider/lib/utils/token.py @@ -1,22 +1,22 @@ -from datetime import timedelta import time import uuid +from datetime import timedelta from Cryptodome.PublicKey.RSA import importKey -from django.utils import dateformat, timezone +from django.utils import dateformat +from django.utils import timezone from jwkest.jwk import RSAKey as jwk_RSAKey from jwkest.jwk import SYMKey from jwkest.jws import JWS from jwkest.jwt import JWT -from oidc_provider.lib.utils.common import get_issuer, run_processing_hook -from oidc_provider.lib.claims import StandardScopeClaims -from oidc_provider.models import ( - Code, - RSAKey, - Token, -) from oidc_provider import settings +from oidc_provider.lib.claims import StandardScopeClaims +from oidc_provider.lib.utils.common import get_issuer +from oidc_provider.lib.utils.common import run_processing_hook +from oidc_provider.models import Code +from oidc_provider.models import RSAKey +from oidc_provider.models import Token def create_id_token(token, user, aud, nonce="", at_hash="", request=None, scope=None): diff --git a/oidc_provider/management/commands/creatersakey.py b/oidc_provider/management/commands/creatersakey.py index 9d609c56..3057f851 100644 --- a/oidc_provider/management/commands/creatersakey.py +++ b/oidc_provider/management/commands/creatersakey.py @@ -1,16 +1,17 @@ from Cryptodome.PublicKey import RSA from django.core.management.base import BaseCommand + from oidc_provider.models import RSAKey class Command(BaseCommand): - help = 'Randomly generate a new RSA key for the OpenID server' + help = "Randomly generate a new RSA key for the OpenID server" def handle(self, *args, **options): try: key = RSA.generate(2048) - rsakey = RSAKey(key=key.exportKey('PEM').decode('utf8')) + rsakey = RSAKey(key=key.exportKey("PEM").decode("utf8")) rsakey.save() - self.stdout.write(u'RSA key successfully created with kid: {0}'.format(rsakey.kid)) + self.stdout.write("RSA key successfully created with kid: {0}".format(rsakey.kid)) except Exception as e: - self.stdout.write('Something goes wrong: {0}'.format(e)) + self.stdout.write("Something goes wrong: {0}".format(e)) diff --git a/oidc_provider/middleware.py b/oidc_provider/middleware.py index 3516bc44..59c037ac 100644 --- a/oidc_provider/middleware.py +++ b/oidc_provider/middleware.py @@ -16,6 +16,6 @@ class SessionManagementMiddleware(MiddlewareMixin): """ def process_response(self, request, response): - if settings.get('OIDC_SESSION_MANAGEMENT_ENABLE'): - response.set_cookie('op_browser_state', get_browser_state_or_default(request)) + if settings.get("OIDC_SESSION_MANAGEMENT_ENABLE"): + response.set_cookie("op_browser_state", get_browser_state_or_default(request)) return response diff --git a/oidc_provider/migrations/0001_initial.py b/oidc_provider/migrations/0001_initial.py index 2af079ab..a1ce8ee3 100644 --- a/oidc_provider/migrations/0001_initial.py +++ b/oidc_provider/migrations/0001_initial.py @@ -1,102 +1,136 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -from django.db import models, migrations from django.conf import settings +from django.db import migrations +from django.db import models class Migration(migrations.Migration): - dependencies = [ - ('auth', '0001_initial'), + ("auth", "0001_initial"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='Client', + name="Client", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('name', models.CharField(default=b'', max_length=100)), - ('client_id', models.CharField(unique=True, max_length=255)), - ('client_secret', models.CharField(unique=True, max_length=255)), - ('response_type', models.CharField(max_length=30, choices=[ - (b'code', b'code (Authorization Code Flow)'), (b'id_token', b'id_token (Implicit Flow)'), - (b'id_token token', b'id_token token (Implicit Flow)')])), - ('_redirect_uris', models.TextField(default=b'')), + ( + "id", + models.AutoField( + verbose_name="ID", serialize=False, auto_created=True, primary_key=True + ), + ), + ("name", models.CharField(default=b"", max_length=100)), + ("client_id", models.CharField(unique=True, max_length=255)), + ("client_secret", models.CharField(unique=True, max_length=255)), + ( + "response_type", + models.CharField( + max_length=30, + choices=[ + (b"code", b"code (Authorization Code Flow)"), + (b"id_token", b"id_token (Implicit Flow)"), + (b"id_token token", b"id_token token (Implicit Flow)"), + ], + ), + ), + ("_redirect_uris", models.TextField(default=b"")), ], - options={ - }, + options={}, bases=(models.Model,), ), migrations.CreateModel( - name='Code', + name="Code", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('expires_at', models.DateTimeField()), - ('_scope', models.TextField(default=b'')), - ('code', models.CharField(unique=True, max_length=255)), - ('client', models.ForeignKey(to='oidc_provider.Client', on_delete=models.CASCADE)), + ( + "id", + models.AutoField( + verbose_name="ID", serialize=False, auto_created=True, primary_key=True + ), + ), + ("expires_at", models.DateTimeField()), + ("_scope", models.TextField(default=b"")), + ("code", models.CharField(unique=True, max_length=255)), + ("client", models.ForeignKey(to="oidc_provider.Client", on_delete=models.CASCADE)), ], options={ - 'abstract': False, + "abstract": False, }, bases=(models.Model,), ), migrations.CreateModel( - name='Token', + name="Token", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('expires_at', models.DateTimeField()), - ('_scope', models.TextField(default=b'')), - ('access_token', models.CharField(unique=True, max_length=255)), - ('_id_token', models.TextField()), - ('client', models.ForeignKey(to='oidc_provider.Client', on_delete=models.CASCADE)), + ( + "id", + models.AutoField( + verbose_name="ID", serialize=False, auto_created=True, primary_key=True + ), + ), + ("expires_at", models.DateTimeField()), + ("_scope", models.TextField(default=b"")), + ("access_token", models.CharField(unique=True, max_length=255)), + ("_id_token", models.TextField()), + ("client", models.ForeignKey(to="oidc_provider.Client", on_delete=models.CASCADE)), ], options={ - 'abstract': False, + "abstract": False, }, bases=(models.Model,), ), migrations.CreateModel( - name='UserInfo', + name="UserInfo", fields=[ - ('user', models.OneToOneField(primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), - ('given_name', models.CharField(max_length=255, null=True, blank=True)), - ('family_name', models.CharField(max_length=255, null=True, blank=True)), - ('middle_name', models.CharField(max_length=255, null=True, blank=True)), - ('nickname', models.CharField(max_length=255, null=True, blank=True)), - ('gender', models.CharField(max_length=100, null=True, choices=[(b'F', b'Female'), (b'M', b'Male')])), - ('birthdate', models.DateField(null=True)), - ('zoneinfo', models.CharField(default=b'', max_length=100, null=True, blank=True)), - ('preferred_username', models.CharField(max_length=255, null=True, blank=True)), - ('profile', models.URLField(default=b'', null=True, blank=True)), - ('picture', models.URLField(default=b'', null=True, blank=True)), - ('website', models.URLField(default=b'', null=True, blank=True)), - ('email_verified', models.NullBooleanField(default=False)), - ('locale', models.CharField(max_length=100, null=True, blank=True)), - ('phone_number', models.CharField(max_length=255, null=True, blank=True)), - ('phone_number_verified', models.NullBooleanField(default=False)), - ('address_street_address', models.CharField(max_length=255, null=True, blank=True)), - ('address_locality', models.CharField(max_length=255, null=True, blank=True)), - ('address_region', models.CharField(max_length=255, null=True, blank=True)), - ('address_postal_code', models.CharField(max_length=255, null=True, blank=True)), - ('address_country', models.CharField(max_length=255, null=True, blank=True)), - ('updated_at', models.DateTimeField(auto_now=True, null=True)), + ( + "user", + models.OneToOneField( + primary_key=True, + serialize=False, + to=settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + ), + ), + ("given_name", models.CharField(max_length=255, null=True, blank=True)), + ("family_name", models.CharField(max_length=255, null=True, blank=True)), + ("middle_name", models.CharField(max_length=255, null=True, blank=True)), + ("nickname", models.CharField(max_length=255, null=True, blank=True)), + ( + "gender", + models.CharField( + max_length=100, null=True, choices=[(b"F", b"Female"), (b"M", b"Male")] + ), + ), + ("birthdate", models.DateField(null=True)), + ("zoneinfo", models.CharField(default=b"", max_length=100, null=True, blank=True)), + ("preferred_username", models.CharField(max_length=255, null=True, blank=True)), + ("profile", models.URLField(default=b"", null=True, blank=True)), + ("picture", models.URLField(default=b"", null=True, blank=True)), + ("website", models.URLField(default=b"", null=True, blank=True)), + ("email_verified", models.NullBooleanField(default=False)), + ("locale", models.CharField(max_length=100, null=True, blank=True)), + ("phone_number", models.CharField(max_length=255, null=True, blank=True)), + ("phone_number_verified", models.NullBooleanField(default=False)), + ("address_street_address", models.CharField(max_length=255, null=True, blank=True)), + ("address_locality", models.CharField(max_length=255, null=True, blank=True)), + ("address_region", models.CharField(max_length=255, null=True, blank=True)), + ("address_postal_code", models.CharField(max_length=255, null=True, blank=True)), + ("address_country", models.CharField(max_length=255, null=True, blank=True)), + ("updated_at", models.DateTimeField(auto_now=True, null=True)), ], - options={ - }, + options={}, bases=(models.Model,), ), migrations.AddField( - model_name='token', - name='user', + model_name="token", + name="user", field=models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE), preserve_default=True, ), migrations.AddField( - model_name='code', - name='user', + model_name="code", + name="user", field=models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE), preserve_default=True, ), diff --git a/oidc_provider/migrations/0002_userconsent.py b/oidc_provider/migrations/0002_userconsent.py index d2a0f12b..93ce5446 100644 --- a/oidc_provider/migrations/0002_userconsent.py +++ b/oidc_provider/migrations/0002_userconsent.py @@ -1,29 +1,34 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -from django.db import models, migrations from django.conf import settings +from django.db import migrations +from django.db import models class Migration(migrations.Migration): - dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('oidc_provider', '0001_initial'), + ("oidc_provider", "0001_initial"), ] operations = [ migrations.CreateModel( - name='UserConsent', + name="UserConsent", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('expires_at', models.DateTimeField()), - ('_scope', models.TextField(default=b'')), - ('client', models.ForeignKey(to='oidc_provider.Client', on_delete=models.CASCADE)), - ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), + ( + "id", + models.AutoField( + verbose_name="ID", serialize=False, auto_created=True, primary_key=True + ), + ), + ("expires_at", models.DateTimeField()), + ("_scope", models.TextField(default=b"")), + ("client", models.ForeignKey(to="oidc_provider.Client", on_delete=models.CASCADE)), + ("user", models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), ], options={ - 'abstract': False, + "abstract": False, }, bases=(models.Model,), ), diff --git a/oidc_provider/migrations/0003_code_nonce.py b/oidc_provider/migrations/0003_code_nonce.py index 0d496157..96f143aa 100644 --- a/oidc_provider/migrations/0003_code_nonce.py +++ b/oidc_provider/migrations/0003_code_nonce.py @@ -1,19 +1,19 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -from django.db import models, migrations +from django.db import migrations +from django.db import models class Migration(migrations.Migration): - dependencies = [ - ('oidc_provider', '0002_userconsent'), + ("oidc_provider", "0002_userconsent"), ] operations = [ migrations.AddField( - model_name='code', - name='nonce', - field=models.CharField(default=b'', max_length=255, blank=True), + model_name="code", + name="nonce", + field=models.CharField(default=b"", max_length=255, blank=True), ), ] diff --git a/oidc_provider/migrations/0004_remove_userinfo.py b/oidc_provider/migrations/0004_remove_userinfo.py index d4208e00..26cc7b61 100644 --- a/oidc_provider/migrations/0004_remove_userinfo.py +++ b/oidc_provider/migrations/0004_remove_userinfo.py @@ -5,17 +5,16 @@ class Migration(migrations.Migration): - dependencies = [ - ('oidc_provider', '0003_code_nonce'), + ("oidc_provider", "0003_code_nonce"), ] operations = [ migrations.RemoveField( - model_name='userinfo', - name='user', + model_name="userinfo", + name="user", ), migrations.DeleteModel( - name='UserInfo', + name="UserInfo", ), ] diff --git a/oidc_provider/migrations/0005_token_refresh_token.py b/oidc_provider/migrations/0005_token_refresh_token.py index e571318d..7f21eff0 100644 --- a/oidc_provider/migrations/0005_token_refresh_token.py +++ b/oidc_provider/migrations/0005_token_refresh_token.py @@ -1,19 +1,19 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -from django.db import models, migrations +from django.db import migrations +from django.db import models class Migration(migrations.Migration): - dependencies = [ - ('oidc_provider', '0004_remove_userinfo'), + ("oidc_provider", "0004_remove_userinfo"), ] operations = [ migrations.AddField( - model_name='token', - name='refresh_token', + model_name="token", + name="refresh_token", field=models.CharField(max_length=255, unique=True, null=True), preserve_default=True, ), diff --git a/oidc_provider/migrations/0006_unique_user_client.py b/oidc_provider/migrations/0006_unique_user_client.py index 1ce586eb..f61d2b89 100644 --- a/oidc_provider/migrations/0006_unique_user_client.py +++ b/oidc_provider/migrations/0006_unique_user_client.py @@ -5,14 +5,13 @@ class Migration(migrations.Migration): - dependencies = [ - ('oidc_provider', '0005_token_refresh_token'), + ("oidc_provider", "0005_token_refresh_token"), ] operations = [ migrations.AlterUniqueTogether( - name='userconsent', - unique_together=set([('user', 'client')]), + name="userconsent", + unique_together=set([("user", "client")]), ), ] diff --git a/oidc_provider/migrations/0007_auto_20160111_1844.py b/oidc_provider/migrations/0007_auto_20160111_1844.py index e9f06c27..dc80bbe2 100644 --- a/oidc_provider/migrations/0007_auto_20160111_1844.py +++ b/oidc_provider/migrations/0007_auto_20160111_1844.py @@ -3,38 +3,48 @@ from __future__ import unicode_literals import datetime -from django.db import migrations, models +from django.db import migrations +from django.db import models -class Migration(migrations.Migration): +class Migration(migrations.Migration): dependencies = [ - ('oidc_provider', '0006_unique_user_client'), + ("oidc_provider", "0006_unique_user_client"), ] operations = [ migrations.AlterModelOptions( - name='client', - options={'verbose_name': 'Client', 'verbose_name_plural': 'Clients'}, + name="client", + options={"verbose_name": "Client", "verbose_name_plural": "Clients"}, ), migrations.AlterModelOptions( - name='code', - options={'verbose_name': 'Authorization Code', 'verbose_name_plural': 'Authorization Codes'}, + name="code", + options={ + "verbose_name": "Authorization Code", + "verbose_name_plural": "Authorization Codes", + }, ), migrations.AlterModelOptions( - name='token', - options={'verbose_name': 'Token', 'verbose_name_plural': 'Tokens'}, + name="token", + options={"verbose_name": "Token", "verbose_name_plural": "Tokens"}, ), migrations.AddField( - model_name='client', - name='date_created', + model_name="client", + name="date_created", field=models.DateField( - auto_now_add=True, default=datetime.datetime(2016, 1, 11, 18, 44, 32, 192477, tzinfo=datetime.timezone.utc)), + auto_now_add=True, + default=datetime.datetime( + 2016, 1, 11, 18, 44, 32, 192477, tzinfo=datetime.timezone.utc + ), + ), preserve_default=False, ), migrations.AlterField( - model_name='client', - name='_redirect_uris', - field=models.TextField(default=b'', help_text='Enter each URI on a new line.', verbose_name='Redirect URI'), + model_name="client", + name="_redirect_uris", + field=models.TextField( + default=b"", help_text="Enter each URI on a new line.", verbose_name="Redirect URI" + ), ), ] diff --git a/oidc_provider/migrations/0008_rsakey.py b/oidc_provider/migrations/0008_rsakey.py index 6c76d6d1..d1d57280 100644 --- a/oidc_provider/migrations/0008_rsakey.py +++ b/oidc_provider/migrations/0008_rsakey.py @@ -2,21 +2,26 @@ # Generated by Django 1.9 on 2016-01-25 17:48 from __future__ import unicode_literals -from django.db import migrations, models +from django.db import migrations +from django.db import models class Migration(migrations.Migration): - dependencies = [ - ('oidc_provider', '0007_auto_20160111_1844'), + ("oidc_provider", "0007_auto_20160111_1844"), ] operations = [ migrations.CreateModel( - name='RSAKey', + name="RSAKey", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('key', models.TextField()), + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("key", models.TextField()), ], ), ] diff --git a/oidc_provider/migrations/0009_auto_20160202_1945.py b/oidc_provider/migrations/0009_auto_20160202_1945.py index c4b986df..c8d6ad1b 100644 --- a/oidc_provider/migrations/0009_auto_20160202_1945.py +++ b/oidc_provider/migrations/0009_auto_20160202_1945.py @@ -2,23 +2,23 @@ # Generated by Django 1.9 on 2016-02-02 19:45 from __future__ import unicode_literals -from django.db import migrations, models +from django.db import migrations +from django.db import models class Migration(migrations.Migration): - dependencies = [ - ('oidc_provider', '0008_rsakey'), + ("oidc_provider", "0008_rsakey"), ] operations = [ migrations.AlterModelOptions( - name='rsakey', - options={'verbose_name': 'RSA Key', 'verbose_name_plural': 'RSA Keys'}, + name="rsakey", + options={"verbose_name": "RSA Key", "verbose_name_plural": "RSA Keys"}, ), migrations.AlterField( - model_name='rsakey', - name='key', - field=models.TextField(help_text='Paste your private RSA Key here.'), + model_name="rsakey", + name="key", + field=models.TextField(help_text="Paste your private RSA Key here."), ), ] diff --git a/oidc_provider/migrations/0010_code_is_authentication.py b/oidc_provider/migrations/0010_code_is_authentication.py index 0ecf8628..688ddadd 100644 --- a/oidc_provider/migrations/0010_code_is_authentication.py +++ b/oidc_provider/migrations/0010_code_is_authentication.py @@ -2,19 +2,19 @@ # Generated by Django 1.9 on 2016-02-16 20:32 from __future__ import unicode_literals -from django.db import migrations, models +from django.db import migrations +from django.db import models class Migration(migrations.Migration): - dependencies = [ - ('oidc_provider', '0009_auto_20160202_1945'), + ("oidc_provider", "0009_auto_20160202_1945"), ] operations = [ migrations.AddField( - model_name='code', - name='is_authentication', + model_name="code", + name="is_authentication", field=models.BooleanField(default=False), ), ] diff --git a/oidc_provider/migrations/0011_client_client_type.py b/oidc_provider/migrations/0011_client_client_type.py index 563096fa..93d85b93 100644 --- a/oidc_provider/migrations/0011_client_client_type.py +++ b/oidc_provider/migrations/0011_client_client_type.py @@ -2,24 +2,25 @@ # Generated by Django 1.9 on 2016-04-04 19:56 from __future__ import unicode_literals -from django.db import migrations, models +from django.db import migrations +from django.db import models class Migration(migrations.Migration): - dependencies = [ - ('oidc_provider', '0010_code_is_authentication'), + ("oidc_provider", "0010_code_is_authentication"), ] operations = [ migrations.AddField( - model_name='client', - name='client_type', + model_name="client", + name="client_type", field=models.CharField( - choices=[(b'confidential', b'Confidential'), (b'public', b'Public')], - default=b'confidential', - help_text='Confidential clients are capable of maintaining the confidentiality of their ' - 'credentials. Public clients are incapable.', - max_length=30), + choices=[(b"confidential", b"Confidential"), (b"public", b"Public")], + default=b"confidential", + help_text="Confidential clients are capable of maintaining the confidentiality of their " + "credentials. Public clients are incapable.", + max_length=30, + ), ), ] diff --git a/oidc_provider/migrations/0012_auto_20160405_2041.py b/oidc_provider/migrations/0012_auto_20160405_2041.py index c04b6130..706eebe6 100644 --- a/oidc_provider/migrations/0012_auto_20160405_2041.py +++ b/oidc_provider/migrations/0012_auto_20160405_2041.py @@ -2,19 +2,19 @@ # Generated by Django 1.9 on 2016-04-05 20:41 from __future__ import unicode_literals -from django.db import migrations, models +from django.db import migrations +from django.db import models class Migration(migrations.Migration): - dependencies = [ - ('oidc_provider', '0011_client_client_type'), + ("oidc_provider", "0011_client_client_type"), ] operations = [ migrations.AlterField( - model_name='client', - name='client_secret', - field=models.CharField(blank=True, default=b'', max_length=255), + model_name="client", + name="client_secret", + field=models.CharField(blank=True, default=b"", max_length=255), ), ] diff --git a/oidc_provider/migrations/0013_auto_20160407_1912.py b/oidc_provider/migrations/0013_auto_20160407_1912.py index 19cb4448..a936e200 100644 --- a/oidc_provider/migrations/0013_auto_20160407_1912.py +++ b/oidc_provider/migrations/0013_auto_20160407_1912.py @@ -2,24 +2,24 @@ # Generated by Django 1.9 on 2016-04-07 19:12 from __future__ import unicode_literals -from django.db import migrations, models +from django.db import migrations +from django.db import models class Migration(migrations.Migration): - dependencies = [ - ('oidc_provider', '0012_auto_20160405_2041'), + ("oidc_provider", "0012_auto_20160405_2041"), ] operations = [ migrations.AddField( - model_name='code', - name='code_challenge', + model_name="code", + name="code_challenge", field=models.CharField(max_length=255, null=True), ), migrations.AddField( - model_name='code', - name='code_challenge_method', + model_name="code", + name="code_challenge_method", field=models.CharField(max_length=255, null=True), ), ] diff --git a/oidc_provider/migrations/0014_client_jwt_alg.py b/oidc_provider/migrations/0014_client_jwt_alg.py index 18a34c2a..5d48eae3 100644 --- a/oidc_provider/migrations/0014_client_jwt_alg.py +++ b/oidc_provider/migrations/0014_client_jwt_alg.py @@ -2,23 +2,24 @@ # Generated by Django 1.9 on 2016-04-25 18:02 from __future__ import unicode_literals -from django.db import migrations, models +from django.db import migrations +from django.db import models class Migration(migrations.Migration): - dependencies = [ - ('oidc_provider', '0013_auto_20160407_1912'), + ("oidc_provider", "0013_auto_20160407_1912"), ] operations = [ migrations.AddField( - model_name='client', - name='jwt_alg', + model_name="client", + name="jwt_alg", field=models.CharField( - choices=[(b'HS256', b'HS256'), (b'RS256', b'RS256')], - default=b'RS256', + choices=[(b"HS256", b"HS256"), (b"RS256", b"RS256")], + default=b"RS256", max_length=10, - verbose_name='JWT Algorithm'), + verbose_name="JWT Algorithm", + ), ), ] diff --git a/oidc_provider/migrations/0015_change_client_code.py b/oidc_provider/migrations/0015_change_client_code.py index a4f67e1b..9f4de017 100644 --- a/oidc_provider/migrations/0015_change_client_code.py +++ b/oidc_provider/migrations/0015_change_client_code.py @@ -2,77 +2,84 @@ # Generated by Django 1.9.7 on 2016-06-10 13:55 from __future__ import unicode_literals -from django.db import migrations, models +from django.db import migrations +from django.db import models class Migration(migrations.Migration): - dependencies = [ - ('oidc_provider', '0014_client_jwt_alg'), + ("oidc_provider", "0014_client_jwt_alg"), ] operations = [ migrations.AlterField( - model_name='client', - name='_redirect_uris', - field=models.TextField(default='', help_text='Enter each URI on a new line.', verbose_name='Redirect URI'), + model_name="client", + name="_redirect_uris", + field=models.TextField( + default="", help_text="Enter each URI on a new line.", verbose_name="Redirect URI" + ), ), migrations.AlterField( - model_name='client', - name='client_secret', - field=models.CharField(blank=True, default='', max_length=255), + model_name="client", + name="client_secret", + field=models.CharField(blank=True, default="", max_length=255), ), migrations.AlterField( - model_name='client', - name='client_type', + model_name="client", + name="client_type", field=models.CharField( - choices=[('confidential', 'Confidential'), ('public', 'Public')], - default='confidential', - help_text='Confidential clients are capable of maintaining the confidentiality of their' - ' credentials. Public clients are incapable.', - max_length=30), + choices=[("confidential", "Confidential"), ("public", "Public")], + default="confidential", + help_text="Confidential clients are capable of maintaining the confidentiality of their" + " credentials. Public clients are incapable.", + max_length=30, + ), ), migrations.AlterField( - model_name='client', - name='jwt_alg', + model_name="client", + name="jwt_alg", field=models.CharField( - choices=[('HS256', 'HS256'), ('RS256', 'RS256')], - default='RS256', + choices=[("HS256", "HS256"), ("RS256", "RS256")], + default="RS256", max_length=10, - verbose_name='JWT Algorithm'), + verbose_name="JWT Algorithm", + ), ), migrations.AlterField( - model_name='client', - name='name', - field=models.CharField(default='', max_length=100), + model_name="client", + name="name", + field=models.CharField(default="", max_length=100), ), migrations.AlterField( - model_name='client', - name='response_type', + model_name="client", + name="response_type", field=models.CharField( choices=[ - ('code', 'code (Authorization Code Flow)'), ('id_token', 'id_token (Implicit Flow)'), - ('id_token token', 'id_token token (Implicit Flow)')], - max_length=30), + ("code", "code (Authorization Code Flow)"), + ("id_token", "id_token (Implicit Flow)"), + ("id_token token", "id_token token (Implicit Flow)"), + ], + max_length=30, + ), ), migrations.AlterField( - model_name='code', - name='_scope', - field=models.TextField(default=''), + model_name="code", + name="_scope", + field=models.TextField(default=""), ), migrations.AlterField( - model_name='code', - name='nonce', - field=models.CharField(blank=True, default='', max_length=255), + model_name="code", + name="nonce", + field=models.CharField(blank=True, default="", max_length=255), ), migrations.AlterField( - model_name='token', - name='_scope', - field=models.TextField(default=''), + model_name="token", + name="_scope", + field=models.TextField(default=""), ), migrations.AlterField( - model_name='userconsent', - name='_scope', - field=models.TextField(default=''), + model_name="userconsent", + name="_scope", + field=models.TextField(default=""), ), ] diff --git a/oidc_provider/migrations/0016_userconsent_and_verbosenames.py b/oidc_provider/migrations/0016_userconsent_and_verbosenames.py index bdcca3d4..62633829 100644 --- a/oidc_provider/migrations/0016_userconsent_and_verbosenames.py +++ b/oidc_provider/migrations/0016_userconsent_and_verbosenames.py @@ -3,181 +3,215 @@ from __future__ import unicode_literals import datetime -from django.conf import settings -from django.db import migrations, models + import django.db.models.deletion +from django.conf import settings +from django.db import migrations +from django.db import models class Migration(migrations.Migration): - dependencies = [ - ('oidc_provider', '0015_change_client_code'), + ("oidc_provider", "0015_change_client_code"), ] operations = [ migrations.AddField( - model_name='userconsent', - name='date_given', + model_name="userconsent", + name="date_given", field=models.DateTimeField( - default=datetime.datetime(2016, 6, 10, 17, 53, 48, 889808, tzinfo=datetime.timezone.utc), verbose_name='Date Given'), + default=datetime.datetime( + 2016, 6, 10, 17, 53, 48, 889808, tzinfo=datetime.timezone.utc + ), + verbose_name="Date Given", + ), preserve_default=False, ), migrations.AlterField( - model_name='client', - name='_redirect_uris', + model_name="client", + name="_redirect_uris", field=models.TextField( - default=b'', help_text='Enter each URI on a new line.', verbose_name='Redirect URIs'), + default=b"", help_text="Enter each URI on a new line.", verbose_name="Redirect URIs" + ), ), migrations.AlterField( - model_name='client', - name='client_id', - field=models.CharField(max_length=255, unique=True, verbose_name='Client ID'), + model_name="client", + name="client_id", + field=models.CharField(max_length=255, unique=True, verbose_name="Client ID"), ), migrations.AlterField( - model_name='client', - name='client_secret', - field=models.CharField(blank=True, default=b'', max_length=255, verbose_name='Client SECRET'), + model_name="client", + name="client_secret", + field=models.CharField( + blank=True, default=b"", max_length=255, verbose_name="Client SECRET" + ), ), migrations.AlterField( - model_name='client', - name='client_type', + model_name="client", + name="client_type", field=models.CharField( - choices=[(b'confidential', b'Confidential'), (b'public', b'Public')], - default=b'confidential', - help_text='Confidential clients are capable of maintaining the confidentiality of their ' - 'credentials. Public clients are incapable.', + choices=[(b"confidential", b"Confidential"), (b"public", b"Public")], + default=b"confidential", + help_text="Confidential clients are capable of maintaining the confidentiality of their " + "credentials. Public clients are incapable.", max_length=30, - verbose_name='Client Type'), + verbose_name="Client Type", + ), ), migrations.AlterField( - model_name='client', - name='date_created', - field=models.DateField(auto_now_add=True, verbose_name='Date Created'), + model_name="client", + name="date_created", + field=models.DateField(auto_now_add=True, verbose_name="Date Created"), ), migrations.AlterField( - model_name='client', - name='name', - field=models.CharField(default=b'', max_length=100, verbose_name='Name'), + model_name="client", + name="name", + field=models.CharField(default=b"", max_length=100, verbose_name="Name"), ), migrations.AlterField( - model_name='client', - name='response_type', + model_name="client", + name="response_type", field=models.CharField( choices=[ - (b'code', b'code (Authorization Code Flow)'), (b'id_token', b'id_token (Implicit Flow)'), - (b'id_token token', b'id_token token (Implicit Flow)')], + (b"code", b"code (Authorization Code Flow)"), + (b"id_token", b"id_token (Implicit Flow)"), + (b"id_token token", b"id_token token (Implicit Flow)"), + ], max_length=30, - verbose_name='Response Type'), + verbose_name="Response Type", + ), ), migrations.AlterField( - model_name='code', - name='_scope', - field=models.TextField(default=b'', verbose_name='Scopes'), + model_name="code", + name="_scope", + field=models.TextField(default=b"", verbose_name="Scopes"), ), migrations.AlterField( - model_name='code', - name='client', + model_name="code", + name="client", field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, to='oidc_provider.Client', verbose_name='Client'), + on_delete=django.db.models.deletion.CASCADE, + to="oidc_provider.Client", + verbose_name="Client", + ), ), migrations.AlterField( - model_name='code', - name='code', - field=models.CharField(max_length=255, unique=True, verbose_name='Code'), + model_name="code", + name="code", + field=models.CharField(max_length=255, unique=True, verbose_name="Code"), ), migrations.AlterField( - model_name='code', - name='code_challenge', - field=models.CharField(max_length=255, null=True, verbose_name='Code Challenge'), + model_name="code", + name="code_challenge", + field=models.CharField(max_length=255, null=True, verbose_name="Code Challenge"), ), migrations.AlterField( - model_name='code', - name='code_challenge_method', - field=models.CharField(max_length=255, null=True, verbose_name='Code Challenge Method'), + model_name="code", + name="code_challenge_method", + field=models.CharField(max_length=255, null=True, verbose_name="Code Challenge Method"), ), migrations.AlterField( - model_name='code', - name='expires_at', - field=models.DateTimeField(verbose_name='Expiration Date'), + model_name="code", + name="expires_at", + field=models.DateTimeField(verbose_name="Expiration Date"), ), migrations.AlterField( - model_name='code', - name='is_authentication', - field=models.BooleanField(default=False, verbose_name='Is Authentication?'), + model_name="code", + name="is_authentication", + field=models.BooleanField(default=False, verbose_name="Is Authentication?"), ), migrations.AlterField( - model_name='code', - name='nonce', - field=models.CharField(blank=True, default=b'', max_length=255, verbose_name='Nonce'), + model_name="code", + name="nonce", + field=models.CharField(blank=True, default=b"", max_length=255, verbose_name="Nonce"), ), migrations.AlterField( - model_name='code', - name='user', + model_name="code", + name="user", field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User'), + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + verbose_name="User", + ), ), migrations.AlterField( - model_name='rsakey', - name='key', - field=models.TextField(help_text='Paste your private RSA Key here.', verbose_name='Key'), + model_name="rsakey", + name="key", + field=models.TextField( + help_text="Paste your private RSA Key here.", verbose_name="Key" + ), ), migrations.AlterField( - model_name='token', - name='_id_token', - field=models.TextField(verbose_name='ID Token'), + model_name="token", + name="_id_token", + field=models.TextField(verbose_name="ID Token"), ), migrations.AlterField( - model_name='token', - name='_scope', - field=models.TextField(default=b'', verbose_name='Scopes'), + model_name="token", + name="_scope", + field=models.TextField(default=b"", verbose_name="Scopes"), ), migrations.AlterField( - model_name='token', - name='access_token', - field=models.CharField(max_length=255, unique=True, verbose_name='Access Token'), + model_name="token", + name="access_token", + field=models.CharField(max_length=255, unique=True, verbose_name="Access Token"), ), migrations.AlterField( - model_name='token', - name='client', + model_name="token", + name="client", field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, to='oidc_provider.Client', verbose_name='Client'), + on_delete=django.db.models.deletion.CASCADE, + to="oidc_provider.Client", + verbose_name="Client", + ), ), migrations.AlterField( - model_name='token', - name='expires_at', - field=models.DateTimeField(verbose_name='Expiration Date'), + model_name="token", + name="expires_at", + field=models.DateTimeField(verbose_name="Expiration Date"), ), migrations.AlterField( - model_name='token', - name='refresh_token', - field=models.CharField(max_length=255, null=True, unique=True, verbose_name='Refresh Token'), + model_name="token", + name="refresh_token", + field=models.CharField( + max_length=255, null=True, unique=True, verbose_name="Refresh Token" + ), ), migrations.AlterField( - model_name='token', - name='user', + model_name="token", + name="user", field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User'), + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + verbose_name="User", + ), ), migrations.AlterField( - model_name='userconsent', - name='_scope', - field=models.TextField(default=b'', verbose_name='Scopes'), + model_name="userconsent", + name="_scope", + field=models.TextField(default=b"", verbose_name="Scopes"), ), migrations.AlterField( - model_name='userconsent', - name='client', + model_name="userconsent", + name="client", field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, to='oidc_provider.Client', verbose_name='Client'), + on_delete=django.db.models.deletion.CASCADE, + to="oidc_provider.Client", + verbose_name="Client", + ), ), migrations.AlterField( - model_name='userconsent', - name='expires_at', - field=models.DateTimeField(verbose_name='Expiration Date'), + model_name="userconsent", + name="expires_at", + field=models.DateTimeField(verbose_name="Expiration Date"), ), migrations.AlterField( - model_name='userconsent', - name='user', + model_name="userconsent", + name="user", field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User'), + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + verbose_name="User", + ), ), ] diff --git a/oidc_provider/migrations/0017_auto_20160811_1954.py b/oidc_provider/migrations/0017_auto_20160811_1954.py index 2d564e30..dc663ce7 100644 --- a/oidc_provider/migrations/0017_auto_20160811_1954.py +++ b/oidc_provider/migrations/0017_auto_20160811_1954.py @@ -2,70 +2,78 @@ # Generated by Django 1.9.7 on 2016-08-11 19:54 from __future__ import unicode_literals -from django.db import migrations, models +from django.db import migrations +from django.db import models class Migration(migrations.Migration): - dependencies = [ - ('oidc_provider', '0016_userconsent_and_verbosenames'), + ("oidc_provider", "0016_userconsent_and_verbosenames"), ] operations = [ migrations.AlterField( - model_name='client', - name='_redirect_uris', - field=models.TextField(default='', help_text='Enter each URI on a new line.', verbose_name='Redirect URIs'), + model_name="client", + name="_redirect_uris", + field=models.TextField( + default="", help_text="Enter each URI on a new line.", verbose_name="Redirect URIs" + ), ), migrations.AlterField( - model_name='client', - name='client_secret', - field=models.CharField(blank=True, default='', max_length=255, verbose_name='Client SECRET'), + model_name="client", + name="client_secret", + field=models.CharField( + blank=True, default="", max_length=255, verbose_name="Client SECRET" + ), ), migrations.AlterField( - model_name='client', - name='client_type', + model_name="client", + name="client_type", field=models.CharField( - choices=[('confidential', 'Confidential'), ('public', 'Public')], - default='confidential', - help_text='Confidential clients are capable of maintaining the confidentiality of their ' - 'credentials. Public clients are incapable.', + choices=[("confidential", "Confidential"), ("public", "Public")], + default="confidential", + help_text="Confidential clients are capable of maintaining the confidentiality of their " + "credentials. Public clients are incapable.", max_length=30, - verbose_name='Client Type'), + verbose_name="Client Type", + ), ), migrations.AlterField( - model_name='client', - name='name', - field=models.CharField(default='', max_length=100, verbose_name='Name'), + model_name="client", + name="name", + field=models.CharField(default="", max_length=100, verbose_name="Name"), ), migrations.AlterField( - model_name='client', - name='response_type', + model_name="client", + name="response_type", field=models.CharField( choices=[ - ('code', 'code (Authorization Code Flow)'), ('id_token', 'id_token (Implicit Flow)'), - ('id_token token', 'id_token token (Implicit Flow)')], + ("code", "code (Authorization Code Flow)"), + ("id_token", "id_token (Implicit Flow)"), + ("id_token token", "id_token token (Implicit Flow)"), + ], max_length=30, - verbose_name='Response Type'), + verbose_name="Response Type", + ), ), migrations.AlterField( - model_name='code', - name='_scope', - field=models.TextField(default='', verbose_name='Scopes'), + model_name="code", + name="_scope", + field=models.TextField(default="", verbose_name="Scopes"), ), migrations.AlterField( - model_name='code', - name='nonce', - field=models.CharField(blank=True, default='', max_length=255, verbose_name='Nonce'), + model_name="code", + name="nonce", + field=models.CharField(blank=True, default="", max_length=255, verbose_name="Nonce"), ), migrations.AlterField( - model_name='token', - name='_scope', - field=models.TextField(default='', verbose_name='Scopes'), + model_name="token", + name="_scope", + field=models.TextField(default="", verbose_name="Scopes"), ), migrations.AlterField( - model_name='userconsent', - name='_scope', - field=models.TextField(default='', verbose_name='Scopes'), + model_name="userconsent", + name="_scope", + field=models.TextField(default="", verbose_name="Scopes"), ), ] diff --git a/oidc_provider/migrations/0018_hybridflow_and_clientattrs.py b/oidc_provider/migrations/0018_hybridflow_and_clientattrs.py index 06328ddb..d09a21bf 100644 --- a/oidc_provider/migrations/0018_hybridflow_and_clientattrs.py +++ b/oidc_provider/migrations/0018_hybridflow_and_clientattrs.py @@ -2,62 +2,73 @@ # Generated by Django 1.9.7 on 2016-09-12 14:08 from __future__ import unicode_literals -from django.db import migrations, models +from django.db import migrations +from django.db import models class Migration(migrations.Migration): - dependencies = [ - ('oidc_provider', '0017_auto_20160811_1954'), + ("oidc_provider", "0017_auto_20160811_1954"), ] operations = [ migrations.AddField( - model_name='client', - name='contact_email', - field=models.CharField(blank=True, default='', max_length=255, verbose_name='Contact Email'), + model_name="client", + name="contact_email", + field=models.CharField( + blank=True, default="", max_length=255, verbose_name="Contact Email" + ), ), migrations.AddField( - model_name='client', - name='logo', + model_name="client", + name="logo", field=models.FileField( - blank=True, default='', upload_to='oidc_provider/clients', verbose_name='Logo Image'), + blank=True, default="", upload_to="oidc_provider/clients", verbose_name="Logo Image" + ), ), migrations.AddField( - model_name='client', - name='terms_url', + model_name="client", + name="terms_url", field=models.CharField( blank=True, - default='', - help_text='External reference to the privacy policy of the client.', + default="", + help_text="External reference to the privacy policy of the client.", max_length=255, - verbose_name='Terms URL'), + verbose_name="Terms URL", + ), ), migrations.AddField( - model_name='client', - name='website_url', - field=models.CharField(blank=True, default='', max_length=255, verbose_name='Website URL'), + model_name="client", + name="website_url", + field=models.CharField( + blank=True, default="", max_length=255, verbose_name="Website URL" + ), ), migrations.AlterField( - model_name='client', - name='jwt_alg', + model_name="client", + name="jwt_alg", field=models.CharField( - choices=[('HS256', 'HS256'), ('RS256', 'RS256')], - default='RS256', - help_text='Algorithm used to encode ID Tokens.', + choices=[("HS256", "HS256"), ("RS256", "RS256")], + default="RS256", + help_text="Algorithm used to encode ID Tokens.", max_length=10, - verbose_name='JWT Algorithm'), + verbose_name="JWT Algorithm", + ), ), migrations.AlterField( - model_name='client', - name='response_type', + model_name="client", + name="response_type", field=models.CharField( choices=[ - ('code', 'code (Authorization Code Flow)'), ('id_token', 'id_token (Implicit Flow)'), - ('id_token token', 'id_token token (Implicit Flow)'), ('code token', 'code token (Hybrid Flow)'), - ('code id_token', 'code id_token (Hybrid Flow)'), - ('code id_token token', 'code id_token token (Hybrid Flow)')], + ("code", "code (Authorization Code Flow)"), + ("id_token", "id_token (Implicit Flow)"), + ("id_token token", "id_token token (Implicit Flow)"), + ("code token", "code token (Hybrid Flow)"), + ("code id_token", "code id_token (Hybrid Flow)"), + ("code id_token token", "code id_token token (Hybrid Flow)"), + ], max_length=30, - verbose_name='Response Type'), + verbose_name="Response Type", + ), ), ] diff --git a/oidc_provider/migrations/0019_auto_20161005_1552.py b/oidc_provider/migrations/0019_auto_20161005_1552.py index f2afff92..4b7a9d9d 100644 --- a/oidc_provider/migrations/0019_auto_20161005_1552.py +++ b/oidc_provider/migrations/0019_auto_20161005_1552.py @@ -2,19 +2,19 @@ # Generated by Django 1.10.2 on 2016-10-05 15:52 from __future__ import unicode_literals -from django.db import migrations, models +from django.db import migrations +from django.db import models class Migration(migrations.Migration): - dependencies = [ - ('oidc_provider', '0018_hybridflow_and_clientattrs'), + ("oidc_provider", "0018_hybridflow_and_clientattrs"), ] operations = [ migrations.AlterField( - model_name='client', - name='client_secret', - field=models.CharField(blank=True, max_length=255, verbose_name='Client SECRET'), + model_name="client", + name="client_secret", + field=models.CharField(blank=True, max_length=255, verbose_name="Client SECRET"), ), ] diff --git a/oidc_provider/migrations/0020_client__post_logout_redirect_uris.py b/oidc_provider/migrations/0020_client__post_logout_redirect_uris.py index 158da245..06d06da7 100644 --- a/oidc_provider/migrations/0020_client__post_logout_redirect_uris.py +++ b/oidc_provider/migrations/0020_client__post_logout_redirect_uris.py @@ -2,23 +2,24 @@ # Generated by Django 1.9.7 on 2016-11-01 14:59 from __future__ import unicode_literals -from django.db import migrations, models +from django.db import migrations +from django.db import models class Migration(migrations.Migration): - dependencies = [ - ('oidc_provider', '0019_auto_20161005_1552'), + ("oidc_provider", "0019_auto_20161005_1552"), ] operations = [ migrations.AddField( - model_name='client', - name='_post_logout_redirect_uris', + model_name="client", + name="_post_logout_redirect_uris", field=models.TextField( blank=True, - default='', - help_text='Enter each URI on a new line.', - verbose_name='Post Logout Redirect URIs'), + default="", + help_text="Enter each URI on a new line.", + verbose_name="Post Logout Redirect URIs", + ), ), ] diff --git a/oidc_provider/migrations/0021_refresh_token_not_unique.py b/oidc_provider/migrations/0021_refresh_token_not_unique.py index 691ae8e3..0a518a19 100644 --- a/oidc_provider/migrations/0021_refresh_token_not_unique.py +++ b/oidc_provider/migrations/0021_refresh_token_not_unique.py @@ -2,20 +2,22 @@ # Generated by Django 1.10 on 2016-12-12 19:44 from __future__ import unicode_literals -from django.db import migrations, models +from django.db import migrations +from django.db import models class Migration(migrations.Migration): - dependencies = [ - ('oidc_provider', '0020_client__post_logout_redirect_uris'), + ("oidc_provider", "0020_client__post_logout_redirect_uris"), ] operations = [ migrations.AlterField( - model_name='token', - name='refresh_token', - field=models.CharField(default='', max_length=255, unique=True, verbose_name='Refresh Token'), + model_name="token", + name="refresh_token", + field=models.CharField( + default="", max_length=255, unique=True, verbose_name="Refresh Token" + ), preserve_default=False, ), ] diff --git a/oidc_provider/migrations/0022_auto_20170331_1626.py b/oidc_provider/migrations/0022_auto_20170331_1626.py index 78b7026a..5b7ce628 100644 --- a/oidc_provider/migrations/0022_auto_20170331_1626.py +++ b/oidc_provider/migrations/0022_auto_20170331_1626.py @@ -2,31 +2,33 @@ # Generated by Django 1.10.6 on 2017-03-31 16:26 from __future__ import unicode_literals -from django.db import migrations, models +from django.db import migrations +from django.db import models class Migration(migrations.Migration): - dependencies = [ - ('oidc_provider', '0021_refresh_token_not_unique'), + ("oidc_provider", "0021_refresh_token_not_unique"), ] operations = [ migrations.AddField( - model_name='client', - name='require_consent', + model_name="client", + name="require_consent", field=models.BooleanField( default=True, - help_text='If disabled, the Server will NEVER ask the user for consent.', - verbose_name='Require Consent?'), + help_text="If disabled, the Server will NEVER ask the user for consent.", + verbose_name="Require Consent?", + ), ), migrations.AddField( - model_name='client', - name='reuse_consent', + model_name="client", + name="reuse_consent", field=models.BooleanField( default=True, help_text="If enabled, the Server will save the user consent given to a specific client," - " so that user won't be prompted for the same authorization multiple times.", - verbose_name='Reuse Consent?'), + " so that user won't be prompted for the same authorization multiple times.", + verbose_name="Reuse Consent?", + ), ), ] diff --git a/oidc_provider/migrations/0023_client_owner.py b/oidc_provider/migrations/0023_client_owner.py index b6d214dd..8fcc38eb 100644 --- a/oidc_provider/migrations/0023_client_owner.py +++ b/oidc_provider/migrations/0023_client_owner.py @@ -2,22 +2,30 @@ # Generated by Django 1.11 on 2017-11-08 21:43 from __future__ import unicode_literals -from django.conf import settings -from django.db import migrations, models import django.db.models.deletion +from django.conf import settings +from django.db import migrations +from django.db import models class Migration(migrations.Migration): - dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('oidc_provider', '0022_auto_20170331_1626'), + ("oidc_provider", "0022_auto_20170331_1626"), ] operations = [ migrations.AddField( - model_name='client', - name='owner', - field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='oidc_clients_set', to=settings.AUTH_USER_MODEL, verbose_name='Owner'), + model_name="client", + name="owner", + field=models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="oidc_clients_set", + to=settings.AUTH_USER_MODEL, + verbose_name="Owner", + ), ), ] diff --git a/oidc_provider/migrations/0024_auto_20180327_1959.py b/oidc_provider/migrations/0024_auto_20180327_1959.py index 7171661d..aae29413 100644 --- a/oidc_provider/migrations/0024_auto_20180327_1959.py +++ b/oidc_provider/migrations/0024_auto_20180327_1959.py @@ -1,18 +1,22 @@ # Generated by Django 2.0.3 on 2018-03-27 19:59 -from django.db import migrations, models +from django.db import migrations +from django.db import models class Migration(migrations.Migration): - dependencies = [ - ('oidc_provider', '0023_client_owner'), + ("oidc_provider", "0023_client_owner"), ] operations = [ migrations.AlterField( - model_name='client', - name='reuse_consent', - field=models.BooleanField(default=True, help_text="If enabled, server will save the user consent given to a specific client, so that user won't be prompted for the same authorization multiple times.", verbose_name='Reuse Consent?'), + model_name="client", + name="reuse_consent", + field=models.BooleanField( + default=True, + help_text="If enabled, server will save the user consent given to a specific client, so that user won't be prompted for the same authorization multiple times.", + verbose_name="Reuse Consent?", + ), ), ] diff --git a/oidc_provider/migrations/0025_user_field_codetoken.py b/oidc_provider/migrations/0025_user_field_codetoken.py index d757fb0f..f866ec9a 100644 --- a/oidc_provider/migrations/0025_user_field_codetoken.py +++ b/oidc_provider/migrations/0025_user_field_codetoken.py @@ -1,25 +1,35 @@ # Generated by Django 2.0.3 on 2018-04-13 19:34 -from django.conf import settings -from django.db import migrations, models import django.db.models.deletion +from django.conf import settings +from django.db import migrations +from django.db import models class Migration(migrations.Migration): - dependencies = [ - ('oidc_provider', '0024_auto_20180327_1959'), + ("oidc_provider", "0024_auto_20180327_1959"), ] operations = [ migrations.AddField( - model_name='client', - name='_scope', - field=models.TextField(blank=True, default='', help_text='Specifies the authorized scope values for the client app.', verbose_name='Scopes'), + model_name="client", + name="_scope", + field=models.TextField( + blank=True, + default="", + help_text="Specifies the authorized scope values for the client app.", + verbose_name="Scopes", + ), ), migrations.AlterField( - model_name='token', - name='user', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User'), + model_name="token", + name="user", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + verbose_name="User", + ), ), ] diff --git a/oidc_provider/migrations/0026_client_multiple_response_types.py b/oidc_provider/migrations/0026_client_multiple_response_types.py index 067d2910..e412c69f 100644 --- a/oidc_provider/migrations/0026_client_multiple_response_types.py +++ b/oidc_provider/migrations/0026_client_multiple_response_types.py @@ -1,21 +1,22 @@ # Generated by Django 2.0.7 on 2018-08-15 20:44 -from django.db import migrations, models +from django.db import migrations +from django.db import models def migrate_response_type(apps, schema_editor): RESPONSE_TYPES = [ - ('code', 'code (Authorization Code Flow)'), - ('id_token', 'id_token (Implicit Flow)'), - ('id_token token', 'id_token token (Implicit Flow)'), - ('code token', 'code token (Hybrid Flow)'), - ('code id_token', 'code id_token (Hybrid Flow)'), - ('code id_token token', 'code id_token token (Hybrid Flow)'), + ("code", "code (Authorization Code Flow)"), + ("id_token", "id_token (Implicit Flow)"), + ("id_token token", "id_token token (Implicit Flow)"), + ("code token", "code token (Hybrid Flow)"), + ("code id_token", "code id_token (Hybrid Flow)"), + ("code id_token token", "code id_token token (Hybrid Flow)"), ] # ensure we get proper, versioned model with the deleted response_type field; # importing directly yields the latest without response_type - ResponseType = apps.get_model('oidc_provider', 'ResponseType') - Client = apps.get_model('oidc_provider', 'Client') + ResponseType = apps.get_model("oidc_provider", "ResponseType") + Client = apps.get_model("oidc_provider", "Client") db = schema_editor.connection.alias for value, description in RESPONSE_TYPES: ResponseType.objects.using(db).create(value=value, description=description) @@ -24,29 +25,48 @@ def migrate_response_type(apps, schema_editor): class Migration(migrations.Migration): - dependencies = [ - ('oidc_provider', '0025_user_field_codetoken'), + ("oidc_provider", "0025_user_field_codetoken"), ] operations = [ migrations.CreateModel( - name='ResponseType', + name="ResponseType", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('value', models.CharField(choices=[('code', 'code (Authorization Code Flow)'), ('id_token', 'id_token (Implicit Flow)'), ('id_token token', 'id_token token (Implicit Flow)'), ('code token', 'code token (Hybrid Flow)'), ('code id_token', 'code id_token (Hybrid Flow)'), ('code id_token token', 'code id_token token (Hybrid Flow)')], max_length=30, unique=True, verbose_name='Response Type Value')), - ('description', models.CharField(max_length=50)), + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "value", + models.CharField( + choices=[ + ("code", "code (Authorization Code Flow)"), + ("id_token", "id_token (Implicit Flow)"), + ("id_token token", "id_token token (Implicit Flow)"), + ("code token", "code token (Hybrid Flow)"), + ("code id_token", "code id_token (Hybrid Flow)"), + ("code id_token token", "code id_token token (Hybrid Flow)"), + ], + max_length=30, + unique=True, + verbose_name="Response Type Value", + ), + ), + ("description", models.CharField(max_length=50)), ], ), migrations.AddField( - model_name='client', - name='response_types', - field=models.ManyToManyField(to='oidc_provider.ResponseType'), + model_name="client", + name="response_types", + field=models.ManyToManyField(to="oidc_provider.ResponseType"), ), # omitting reverse migrate_response_type since removing response_type is irreversible (nonnull and no default) migrations.RunPython(migrate_response_type), migrations.RemoveField( - model_name='client', - name='response_type', + model_name="client", + name="response_type", ), ] diff --git a/oidc_provider/migrations/0027_alter_rsakey_options.py b/oidc_provider/migrations/0027_alter_rsakey_options.py index ddec150a..7d9a5fbd 100644 --- a/oidc_provider/migrations/0027_alter_rsakey_options.py +++ b/oidc_provider/migrations/0027_alter_rsakey_options.py @@ -4,14 +4,17 @@ class Migration(migrations.Migration): - dependencies = [ - ('oidc_provider', '0026_client_multiple_response_types'), + ("oidc_provider", "0026_client_multiple_response_types"), ] operations = [ migrations.AlterModelOptions( - name='rsakey', - options={'ordering': ['id'], 'verbose_name': 'RSA Key', 'verbose_name_plural': 'RSA Keys'}, + name="rsakey", + options={ + "ordering": ["id"], + "verbose_name": "RSA Key", + "verbose_name_plural": "RSA Keys", + }, ), ] diff --git a/oidc_provider/settings.py b/oidc_provider/settings.py index 08274042..5b55d82a 100644 --- a/oidc_provider/settings.py +++ b/oidc_provider/settings.py @@ -31,7 +31,7 @@ def OIDC_AFTER_USERLOGIN_HOOK(self): OPTIONAL. Provide a way to plug into the process after the user has logged in, typically to perform some business logic. """ - return 'oidc_provider.lib.utils.common.default_after_userlogin_hook' + return "oidc_provider.lib.utils.common.default_after_userlogin_hook" @property def OIDC_AFTER_END_SESSION_HOOK(self): @@ -39,14 +39,14 @@ def OIDC_AFTER_END_SESSION_HOOK(self): OPTIONAL. Provide a way to plug into the end session process just before calling Django's logout function, typically to perform some business logic. """ - return 'oidc_provider.lib.utils.common.default_after_end_session_hook' + return "oidc_provider.lib.utils.common.default_after_end_session_hook" @property def OIDC_CODE_EXPIRE(self): """ OPTIONAL. Code expiration time expressed in seconds. """ - return 60*10 + return 60 * 10 @property def OIDC_DISCOVERY_CACHE_ENABLE(self): @@ -60,7 +60,7 @@ def OIDC_DISCOVERY_CACHE_EXPIRE(self): """ OPTIONAL. Discovery endpoint cache expiration time expressed in seconds. """ - return 60*60*24 + return 60 * 60 * 24 @property def OIDC_EXTRA_SCOPE_CLAIMS(self): @@ -75,7 +75,7 @@ def OIDC_IDTOKEN_EXPIRE(self): """ OPTIONAL. Id token expiration time expressed in seconds. """ - return 60*10 + return 60 * 10 @property def OIDC_IDTOKEN_SUB_GENERATOR(self): @@ -84,7 +84,7 @@ def OIDC_IDTOKEN_SUB_GENERATOR(self): reassigned identifier within the Issuer for the End-User, which is intended to be consumed by the Client. """ - return 'oidc_provider.lib.utils.common.default_sub_generator' + return "oidc_provider.lib.utils.common.default_sub_generator" @property def OIDC_IDTOKEN_INCLUDE_CLAIMS(self): @@ -108,8 +108,9 @@ def OIDC_UNAUTHENTICATED_SESSION_MANAGEMENT_KEY(self): # Memoize generated value if not self._unauthenticated_session_management_key: - self._unauthenticated_session_management_key = ''.join( - random.choice(string.ascii_uppercase + string.digits) for _ in range(100)) + self._unauthenticated_session_management_key = "".join( + random.choice(string.ascii_uppercase + string.digits) for _ in range(100) + ) return self._unauthenticated_session_management_key @property @@ -117,7 +118,7 @@ def OIDC_SKIP_CONSENT_EXPIRE(self): """ OPTIONAL. User consent expiration after been granted. """ - return 30*3 + return 30 * 3 @property def OIDC_TOKEN_EXPIRE(self): @@ -125,7 +126,7 @@ def OIDC_TOKEN_EXPIRE(self): OPTIONAL. Token object expiration after been created. Expressed in seconds. """ - return 60*60 + return 60 * 60 @property def OIDC_USERINFO(self): @@ -133,7 +134,7 @@ def OIDC_USERINFO(self): OPTIONAL. A string with the location of your function. Used to populate standard claims with your user information. """ - return 'oidc_provider.lib.utils.common.default_userinfo' + return "oidc_provider.lib.utils.common.default_userinfo" @property def OIDC_IDTOKEN_PROCESSING_HOOK(self): @@ -141,7 +142,7 @@ def OIDC_IDTOKEN_PROCESSING_HOOK(self): OPTIONAL. A string with the location of your hook. Used to add extra dictionary values specific for your app into id_token. """ - return 'oidc_provider.lib.utils.common.default_idtoken_processing_hook' + return "oidc_provider.lib.utils.common.default_idtoken_processing_hook" @property def OIDC_INTROSPECTION_PROCESSING_HOOK(self): @@ -149,7 +150,7 @@ def OIDC_INTROSPECTION_PROCESSING_HOOK(self): OPTIONAL. A string with the location of your function. Used to update the response for a valid introspection token request. """ - return 'oidc_provider.lib.utils.common.default_introspection_processing_hook' + return "oidc_provider.lib.utils.common.default_introspection_processing_hook" @property def OIDC_INTROSPECTION_VALIDATE_AUDIENCE_SCOPE(self): @@ -177,10 +178,7 @@ def OIDC_GRANT_TYPE_PASSWORD_ENABLE(self): @property def OIDC_TEMPLATES(self): - return { - 'authorize': 'oidc_provider/authorize.html', - 'error': 'oidc_provider/error.html' - } + return {"authorize": "oidc_provider/authorize.html", "error": "oidc_provider/error.html"} @property def OIDC_INTROSPECTION_RESPONSE_SCOPE_ENABLE(self): @@ -198,12 +196,12 @@ def import_from_str(value): Attempt to import a class from a string representation. """ try: - parts = value.split('.') - module_path, class_name = '.'.join(parts[:-1]), parts[-1] + parts = value.split(".") + module_path, class_name = ".".join(parts[:-1]), parts[-1] module = importlib.import_module(module_path) return getattr(module, class_name) except ImportError as e: - msg = 'Could not import %s for settings. %s: %s.' % (value, e.__class__.__name__, e) + msg = "Could not import %s for settings. %s: %s." % (value, e.__class__.__name__, e) raise ImportError(msg) @@ -218,7 +216,7 @@ def get(name, import_str=False): value = getattr(settings, name) except AttributeError: if name in default_settings.required_attrs: - raise Exception('You must set ' + name + ' in your settings.') + raise Exception("You must set " + name + " in your settings.") if isinstance(default_value, dict) and value: default_value.update(value) diff --git a/oidc_provider/signals.py b/oidc_provider/signals.py index ba3d5e50..7efc56a0 100644 --- a/oidc_provider/signals.py +++ b/oidc_provider/signals.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- from django.dispatch import Signal - user_accept_consent = Signal() user_decline_consent = Signal() diff --git a/oidc_provider/tests/app/urls.py b/oidc_provider/tests/app/urls.py index a7f8c943..eb11d4a9 100644 --- a/oidc_provider/tests/app/urls.py +++ b/oidc_provider/tests/app/urls.py @@ -1,22 +1,26 @@ from django.contrib.auth import views as auth_views try: - from django.urls import include, re_path + from django.urls import include + from django.urls import re_path except ImportError: from django.conf.urls import include from django.conf.urls import url as re_path from django.contrib import admin from django.views.generic import TemplateView - urlpatterns = [ - re_path(r'^$', TemplateView.as_view(template_name='home.html'), name='home'), - re_path(r'^accounts/login/$', - auth_views.LoginView.as_view(template_name='accounts/login.html'), - name='login'), - re_path(r'^accounts/logout/$', - auth_views.LogoutView.as_view(template_name='accounts/logout.html'), - name='logout'), - re_path(r'^openid/', include('oidc_provider.urls', namespace='oidc_provider')), - re_path(r'^admin/', admin.site.urls), + re_path(r"^$", TemplateView.as_view(template_name="home.html"), name="home"), + re_path( + r"^accounts/login/$", + auth_views.LoginView.as_view(template_name="accounts/login.html"), + name="login", + ), + re_path( + r"^accounts/logout/$", + auth_views.LogoutView.as_view(template_name="accounts/logout.html"), + name="logout", + ), + re_path(r"^openid/", include("oidc_provider.urls", namespace="oidc_provider")), + re_path(r"^admin/", admin.site.urls), ] diff --git a/oidc_provider/tests/cases/test_claims.py b/oidc_provider/tests/cases/test_claims.py index 1610f773..b66bc4c8 100644 --- a/oidc_provider/tests/cases/test_claims.py +++ b/oidc_provider/tests/cases/test_claims.py @@ -1,87 +1,88 @@ from __future__ import unicode_literals from django.test import TestCase - from django.utils.translation import override as override_language from six import text_type -from oidc_provider.lib.claims import ScopeClaims, StandardScopeClaims, STANDARD_CLAIMS -from oidc_provider.tests.app.utils import create_fake_user, create_fake_client, create_fake_token +from oidc_provider.lib.claims import STANDARD_CLAIMS +from oidc_provider.lib.claims import ScopeClaims +from oidc_provider.lib.claims import StandardScopeClaims +from oidc_provider.tests.app.utils import create_fake_client +from oidc_provider.tests.app.utils import create_fake_token +from oidc_provider.tests.app.utils import create_fake_user class ClaimsTestCase(TestCase): - def setUp(self): self.user = create_fake_user() - self.scopes = ['openid', 'address', 'email', 'phone', 'profile', 'foo'] - self.client = create_fake_client('code') + self.scopes = ["openid", "address", "email", "phone", "profile", "foo"] + self.client = create_fake_client("code") self.token = create_fake_token(self.user, self.scopes, self.client) self.scopeClaims = ScopeClaims(self.token) def test_empty_standard_claims(self): - for v in [v for k, v in STANDARD_CLAIMS.items() if k != 'address']: - self.assertEqual(v, '') + for v in [v for k, v in STANDARD_CLAIMS.items() if k != "address"]: + self.assertEqual(v, "") - for v in STANDARD_CLAIMS['address'].values(): - self.assertEqual(v, '') + for v in STANDARD_CLAIMS["address"].values(): + self.assertEqual(v, "") def test_clean_dic(self): - """ assert that _clean_dic function returns a clean dictionnary - (no empty claims) """ + """assert that _clean_dic function returns a clean dictionnary + (no empty claims)""" dict_to_clean = { - 'phone_number_verified': '', - 'middle_name': '', - 'name': 'John Doe', - 'website': '', - 'profile': '', - 'family_name': 'Doe', - 'birthdate': '', - 'preferred_username': '', - 'picture': '', - 'zoneinfo': '', - 'locale': '', - 'gender': '', - 'updated_at': '', - 'address': {}, - 'given_name': 'John', - 'email_verified': '', - 'nickname': '', - 'email': u'johndoe@example.com', - 'phone_number': '', + "phone_number_verified": "", + "middle_name": "", + "name": "John Doe", + "website": "", + "profile": "", + "family_name": "Doe", + "birthdate": "", + "preferred_username": "", + "picture": "", + "zoneinfo": "", + "locale": "", + "gender": "", + "updated_at": "", + "address": {}, + "given_name": "John", + "email_verified": "", + "nickname": "", + "email": "johndoe@example.com", + "phone_number": "", } clean_dict = self.scopeClaims._clean_dic(dict_to_clean) self.assertEqual( clean_dict, { - 'family_name': 'Doe', - 'given_name': 'John', - 'name': 'John Doe', - 'email': u'johndoe@example.com' - } + "family_name": "Doe", + "given_name": "John", + "name": "John Doe", + "email": "johndoe@example.com", + }, ) def test_locale(self): - with override_language('fr'): - self.assertEqual(text_type(StandardScopeClaims.info_profile[0]), 'Profil de base') + with override_language("fr"): + self.assertEqual(text_type(StandardScopeClaims.info_profile[0]), "Profil de base") def test_scopeclaims_class_inheritance(self): # Generate example class that will be used for `OIDC_EXTRA_SCOPE_CLAIMS` setting. class CustomScopeClaims(ScopeClaims): - - info_foo = ('Title', 'Description') + info_foo = ("Title", "Description") def scope_foo(self): - dic = {'test': self.user.id} + dic = {"test": self.user.id} return dic - info_notadd = ('Title', 'Description') + info_notadd = ("Title", "Description") def scope_notadd(self): - dic = {'test': self.user.id} + dic = {"test": self.user.id} return dic claims = CustomScopeClaims(self.token) response = claims.create_response_dic() - self.assertTrue('test' in response.keys()) - self.assertFalse('notadd' in response.keys()) + self.assertTrue("test" in response.keys()) + self.assertFalse("notadd" in response.keys()) diff --git a/oidc_provider/tests/cases/test_commands.py b/oidc_provider/tests/cases/test_commands.py index 2f9248fe..4594e5f0 100644 --- a/oidc_provider/tests/cases/test_commands.py +++ b/oidc_provider/tests/cases/test_commands.py @@ -5,13 +5,12 @@ class CommandsTest(TestCase): - def test_creatersakey_output(self): out = StringIO() - call_command('creatersakey', stdout=out) - self.assertIn('RSA key successfully created', out.getvalue()) + call_command("creatersakey", stdout=out) + self.assertIn("RSA key successfully created", out.getvalue()) def test_makemigrations_output(self): out = StringIO() - call_command('makemigrations', 'oidc_provider', stdout=out) - self.assertIn('No changes detected in app', out.getvalue()) + call_command("makemigrations", "oidc_provider", stdout=out) + self.assertIn("No changes detected in app", out.getvalue()) diff --git a/oidc_provider/tests/cases/test_settings.py b/oidc_provider/tests/cases/test_settings.py index 1a8a0f7b..bf851a39 100644 --- a/oidc_provider/tests/cases/test_settings.py +++ b/oidc_provider/tests/cases/test_settings.py @@ -1,29 +1,26 @@ -from django.test import TestCase, override_settings +from django.test import TestCase +from django.test import override_settings from oidc_provider import settings -CUSTOM_TEMPLATES = { - 'authorize': 'custom/authorize.html', - 'error': 'custom/error.html' -} +CUSTOM_TEMPLATES = {"authorize": "custom/authorize.html", "error": "custom/error.html"} class SettingsTest(TestCase): - @override_settings(OIDC_TEMPLATES=CUSTOM_TEMPLATES) def test_override_templates(self): - self.assertEqual(settings.get('OIDC_TEMPLATES'), CUSTOM_TEMPLATES) + self.assertEqual(settings.get("OIDC_TEMPLATES"), CUSTOM_TEMPLATES) def test_unauthenticated_session_management_key_has_default(self): - key = settings.get('OIDC_UNAUTHENTICATED_SESSION_MANAGEMENT_KEY') - self.assertRegex(key, r'[a-zA-Z0-9]+') + key = settings.get("OIDC_UNAUTHENTICATED_SESSION_MANAGEMENT_KEY") + self.assertRegex(key, r"[a-zA-Z0-9]+") self.assertGreater(len(key), 50) def test_unauthenticated_session_management_key_has_constant_value(self): - key1 = settings.get('OIDC_UNAUTHENTICATED_SESSION_MANAGEMENT_KEY') - key2 = settings.get('OIDC_UNAUTHENTICATED_SESSION_MANAGEMENT_KEY') + key1 = settings.get("OIDC_UNAUTHENTICATED_SESSION_MANAGEMENT_KEY") + key2 = settings.get("OIDC_UNAUTHENTICATED_SESSION_MANAGEMENT_KEY") self.assertEqual(key1, key2) @override_settings(OIDC_INTROSPECTION_VALIDATE_AUDIENCE_SCOPE=False) def test_can_override_with_false_value(self): - self.assertFalse(settings.get('OIDC_INTROSPECTION_VALIDATE_AUDIENCE_SCOPE')) + self.assertFalse(settings.get("OIDC_INTROSPECTION_VALIDATE_AUDIENCE_SCOPE")) diff --git a/oidc_provider/tests/cases/test_userinfo_endpoint.py b/oidc_provider/tests/cases/test_userinfo_endpoint.py index 832d4354..4aa571dc 100644 --- a/oidc_provider/tests/cases/test_userinfo_endpoint.py +++ b/oidc_provider/tests/cases/test_userinfo_endpoint.py @@ -1,6 +1,6 @@ import json - from datetime import timedelta + try: from urllib.parse import urlencode except ImportError: @@ -14,24 +14,19 @@ from django.test import TestCase from django.utils import timezone -from oidc_provider.lib.utils.token import ( - create_id_token, - create_token, -) -from oidc_provider.tests.app.utils import ( - create_fake_user, - create_fake_client, - FAKE_NONCE, -) +from oidc_provider.lib.utils.token import create_id_token +from oidc_provider.lib.utils.token import create_token +from oidc_provider.tests.app.utils import FAKE_NONCE +from oidc_provider.tests.app.utils import create_fake_client +from oidc_provider.tests.app.utils import create_fake_user from oidc_provider.views import userinfo class UserInfoTestCase(TestCase): - def setUp(self): self.factory = RequestFactory() self.user = create_fake_user() - self.client = create_fake_client(response_type='code') + self.client = create_fake_client(response_type="code") def _create_token(self, extra_scope=None): """ @@ -39,12 +34,9 @@ def _create_token(self, extra_scope=None): """ if extra_scope is None: extra_scope = [] - scope = ['openid', 'email'] + extra_scope + scope = ["openid", "email"] + extra_scope - token = create_token( - user=self.user, - client=self.client, - scope=scope) + token = create_token(user=self.user, client=self.client, scope=scope) id_token_dic = create_id_token( token=token, @@ -59,17 +51,17 @@ def _create_token(self, extra_scope=None): return token - def _post_request(self, access_token, schema='Bearer'): + def _post_request(self, access_token, schema="Bearer"): """ Makes a request to the userinfo endpoint by sending the `post_data` parameters using the 'multipart/form-data' format. """ - url = reverse('oidc_provider:userinfo') + url = reverse("oidc_provider:userinfo") - request = self.factory.post(url, data={}, content_type='multipart/form-data') + request = self.factory.post(url, data={}, content_type="multipart/form-data") - request.META['HTTP_AUTHORIZATION'] = schema + ' ' + access_token + request.META["HTTP_AUTHORIZATION"] = schema + " " + access_token response = userinfo(request) @@ -91,7 +83,7 @@ def test_response_with_valid_token_lowercase_bearer(self): """ token = self._create_token() - response = self._post_request(token.access_token, schema='bearer') + response = self._post_request(token.access_token, schema="bearer") self.assertEqual(response.status_code, 200) self.assertEqual(bool(response.content), True) @@ -108,7 +100,7 @@ def test_response_with_expired_token(self): self.assertEqual(response.status_code, 401) try: - is_header_field_ok = 'invalid_token' in response['WWW-Authenticate'] + is_header_field_ok = "invalid_token" in response["WWW-Authenticate"] except KeyError: is_header_field_ok = False self.assertEqual(is_header_field_ok, True) @@ -116,7 +108,7 @@ def test_response_with_expired_token(self): def test_response_with_invalid_scope(self): token = self._create_token() - token.scope = ['otherone'] + token.scope = ["otherone"] token.save() response = self._post_request(token.access_token) @@ -124,7 +116,7 @@ def test_response_with_invalid_scope(self): self.assertEqual(response.status_code, 403) try: - is_header_field_ok = 'insufficient_scope' in response['WWW-Authenticate'] + is_header_field_ok = "insufficient_scope" in response["WWW-Authenticate"] except KeyError: is_header_field_ok = False self.assertEqual(is_header_field_ok, True) @@ -136,9 +128,15 @@ def test_accesstoken_query_string_parameter(self): """ token = self._create_token() - url = reverse('oidc_provider:userinfo') + '?' + urlencode({ - 'access_token': token.access_token, - }) + url = ( + reverse("oidc_provider:userinfo") + + "?" + + urlencode( + { + "access_token": token.access_token, + } + ) + ) request = self.factory.get(url) response = userinfo(request) @@ -147,20 +145,21 @@ def test_accesstoken_query_string_parameter(self): self.assertEqual(bool(response.content), True) def test_user_claims_in_response(self): - token = self._create_token(extra_scope=['profile']) + token = self._create_token(extra_scope=["profile"]) response = self._post_request(token.access_token) - response_dic = json.loads(response.content.decode('utf-8')) + response_dic = json.loads(response.content.decode("utf-8")) self.assertEqual(response.status_code, 200) self.assertEqual(bool(response.content), True) - self.assertIn('given_name', response_dic, msg='"given_name" claim should be in response.') - self.assertNotIn('profile', response_dic, msg='"profile" claim should not be in response.') + self.assertIn("given_name", response_dic, msg='"given_name" claim should be in response.') + self.assertNotIn("profile", response_dic, msg='"profile" claim should not be in response.') # Now adding `address` scope. - token = self._create_token(extra_scope=['profile', 'address']) + token = self._create_token(extra_scope=["profile", "address"]) response = self._post_request(token.access_token) - response_dic = json.loads(response.content.decode('utf-8')) + response_dic = json.loads(response.content.decode("utf-8")) - self.assertIn('address', response_dic, msg='"address" claim should be in response.') + self.assertIn("address", response_dic, msg='"address" claim should be in response.') self.assertIn( - 'country', response_dic['address'], msg='"country" claim should be in response.') + "country", response_dic["address"], msg='"country" claim should be in response.' + ) diff --git a/oidc_provider/tests/settings.py b/oidc_provider/tests/settings.py index ea61262f..c6973ee4 100644 --- a/oidc_provider/tests/settings.py +++ b/oidc_provider/tests/settings.py @@ -1,79 +1,79 @@ DEBUG = False -SECRET_KEY = 'this-should-be-top-secret' +SECRET_KEY = "this-should-be-top-secret" DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': ':memory:', + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", } } SITE_ID = 1 MIDDLEWARE_CLASSES = [ - 'django.middleware.common.CommonMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', + "django.middleware.common.CommonMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", ] MIDDLEWARE = [ - 'django.middleware.common.CommonMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', + "django.middleware.common.CommonMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", ] TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], }, }, ] INSTALLED_APPS = [ - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.sites', - 'django.contrib.messages', - 'django.contrib.admin', - 'oidc_provider', + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.sites", + "django.contrib.messages", + "django.contrib.admin", + "oidc_provider", ] -ROOT_URLCONF = 'oidc_provider.tests.app.urls' +ROOT_URLCONF = "oidc_provider.tests.app.urls" TEMPLATE_DIRS = [ - 'oidc_provider/tests/templates', + "oidc_provider/tests/templates", ] USE_TZ = True LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'handlers': { - 'console': { - 'class': 'logging.StreamHandler', + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "class": "logging.StreamHandler", }, }, - 'loggers': { - 'oidc_provider': { - 'handlers': ['console'], - 'level': 'DEBUG', + "loggers": { + "oidc_provider": { + "handlers": ["console"], + "level": "DEBUG", }, }, } # OIDC Provider settings. -SITE_URL = 'http://localhost:8000' -OIDC_USERINFO = 'oidc_provider.tests.app.utils.userinfo' +SITE_URL = "http://localhost:8000" +OIDC_USERINFO = "oidc_provider.tests.app.utils.userinfo" diff --git a/oidc_provider/urls.py b/oidc_provider/urls.py index cdebac8e..cd8f4fbd 100644 --- a/oidc_provider/urls.py +++ b/oidc_provider/urls.py @@ -1,10 +1,8 @@ from django.urls import re_path from django.views.decorators.csrf import csrf_exempt -from oidc_provider import ( - settings, - views, -) +from oidc_provider import settings +from oidc_provider import views app_name = "oidc_provider" urlpatterns = [ From e2a1a2c6c13111017aa929e3ada2eebf0adb0494 Mon Sep 17 00:00:00 2001 From: Juani Date: Sun, 24 Aug 2025 21:08:37 -0300 Subject: [PATCH 41/54] Add Ruff to output in problems tab. --- .vscode/settings.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 001feee6..7bcb17d7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,5 +6,10 @@ "source.organizeImports.ruff": "explicit" }, "editor.defaultFormatter": "charliermarsh.ruff" - } + }, + "ruff.enable": true, + "ruff.nativeServer": true, + "python.analysis.ignore": ["*"], + "python.analysis.autoImportCompletions": false, + "pylint.enabled": false } \ No newline at end of file From 348db3ca98ac4f5bb7e82a5500ebbd29fae6cd41 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Tue, 26 Aug 2025 11:39:26 -0300 Subject: [PATCH 42/54] Update changelog.rst --- docs/sections/changelog.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/sections/changelog.rst b/docs/sections/changelog.rst index 4f9325ca..ac30944a 100644 --- a/docs/sections/changelog.rst +++ b/docs/sections/changelog.rst @@ -9,6 +9,7 @@ Unreleased ========== * Added: Translation to Russian. +* Changed: Ruff as a fast Python linter and code formatter. 0.8.4 ===== From c30d341ed2ba63081fb75cd14738d349ce2735a7 Mon Sep 17 00:00:00 2001 From: Stefan Foulis Date: Tue, 19 Aug 2025 20:08:52 +0200 Subject: [PATCH 43/54] Run tests in matrix on github --- .github/workflows/main.yml | 49 ++++++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 4ff2934b..433e79f8 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,13 +1,16 @@ -name: Django Tests CI - +name: "CI" on: push: branches: ["master", "develop"] pull_request: - branches: ["develop"] + +concurrency: + group: check-${{ github.ref }} + cancel-in-progress: true jobs: formatting: + name: "Check Code Formatting" runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -17,6 +20,7 @@ jobs: - run: "ruff format --check --diff" linting: + name: "Check Code Linting" runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -25,24 +29,33 @@ jobs: args: "--version" - run: "ruff check --diff" - tests: + test_matrix_prep: + name: "Prepare Test Matrix" + runs-on: ubuntu-latest + outputs: + matrix: "${{ steps.set-matrix.outputs.matrix }}" + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + - run: uv tool install tox + - id: set-matrix + run: | + matrix=$(tox -l | jq -Rc 'select(test("^py\\d+.*django\\d+")) | capture("^py(?\\d+).*django(?\\d+)") | {"python": (.python | tostring | .[0:1] + "." + .[1:]), "django": (.django | tostring | .[0:1] + "." + .[1:])}' | jq -sc '{include: .}') + echo "matrix=$matrix" >> $GITHUB_OUTPUT + + test: + name: "Test Django ${{ matrix.django }} | Python ${{ matrix.python }}" + needs: test_matrix_prep runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.test_matrix_prep.outputs.matrix) }} steps: - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + - run: uv tool install tox - uses: actions/setup-python@v4 with: - python-version: | - 3.8 - 3.9 - 3.10 - 3.11 - 3.12 - 3.13 - - name: Install tox - run: | - python -m pip install --upgrade pip - pip install tox + python-version: ${{ matrix.python }} - name: Run tox - # we skip the 'ruff' env here, because we are running it in a separate - # parallel job already - run: tox --skip-env ruff + run: tox run -e py$(echo "${{ matrix.python }}" | tr -d '.')-django$(echo "${{ matrix.django }}" | tr -d '.') From 3ebaec4be9937746c6f0a0883157be77c25c0783 Mon Sep 17 00:00:00 2001 From: Juani Date: Sun, 31 Aug 2025 20:53:03 -0300 Subject: [PATCH 44/54] Fixed client_id sanitization to prevent DB errors --- docs/sections/changelog.rst | 1 + oidc_provider/admin.py | 6 +- oidc_provider/lib/endpoints/authorize.py | 6 +- oidc_provider/lib/endpoints/introspection.py | 4 +- oidc_provider/lib/endpoints/token.py | 4 +- oidc_provider/lib/utils/sanitization.py | 31 ++++++ oidc_provider/tests/cases/test_admin.py | 98 +++++++++++++++++++ .../tests/cases/test_authorize_endpoint.py | 20 ++++ oidc_provider/tests/cases/test_utils.py | 60 ++++++++++++ 9 files changed, 226 insertions(+), 4 deletions(-) create mode 100644 oidc_provider/lib/utils/sanitization.py create mode 100644 oidc_provider/tests/cases/test_admin.py diff --git a/docs/sections/changelog.rst b/docs/sections/changelog.rst index ac30944a..9f412a8c 100644 --- a/docs/sections/changelog.rst +++ b/docs/sections/changelog.rst @@ -10,6 +10,7 @@ Unreleased * Added: Translation to Russian. * Changed: Ruff as a fast Python linter and code formatter. +* Fixed: client_id sanitization to prevent database errors. 0.8.4 ===== diff --git a/oidc_provider/admin.py b/oidc_provider/admin.py index e060e092..f5da4dfd 100644 --- a/oidc_provider/admin.py +++ b/oidc_provider/admin.py @@ -6,6 +6,7 @@ from django.forms import ModelForm from django.utils.translation import gettext_lazy as _ +from oidc_provider.lib.utils.sanitization import sanitize_client_id from oidc_provider.models import Client from oidc_provider.models import Code from oidc_provider.models import RSAKey @@ -23,12 +24,15 @@ def __init__(self, *args, **kwargs): self.fields["client_id"].widget.attrs["disabled"] = "true" self.fields["client_secret"].required = False self.fields["client_secret"].widget.attrs["disabled"] = "true" + self.fields["jwt_alg"].required = False def clean_client_id(self): instance = getattr(self, "instance", None) if instance and instance.pk: - return instance.client_id + # Sanitize existing client_id to remove any problematic characters + return sanitize_client_id(instance.client_id) else: + # Generate new client_id (digits only) return str(randint(1, 999999)).zfill(6) def clean_client_secret(self): diff --git a/oidc_provider/lib/endpoints/authorize.py b/oidc_provider/lib/endpoints/authorize.py index 90a7b212..7019754b 100644 --- a/oidc_provider/lib/endpoints/authorize.py +++ b/oidc_provider/lib/endpoints/authorize.py @@ -28,6 +28,7 @@ from oidc_provider.lib.errors import ClientIdError from oidc_provider.lib.errors import RedirectUriError from oidc_provider.lib.utils.common import get_browser_state_or_default +from oidc_provider.lib.utils.sanitization import sanitize_client_id from oidc_provider.lib.utils.token import create_code from oidc_provider.lib.utils.token import create_id_token from oidc_provider.lib.utils.token import create_token @@ -72,7 +73,10 @@ def _extract_params(self): # and POST request. query_dict = self.request.POST if self.request.method == "POST" else self.request.GET - self.params["client_id"] = query_dict.get("client_id", "") + # Sanitize client_id to remove control characters that cause PostgreSQL errors + client_id = query_dict.get("client_id", "") + self.params["client_id"] = sanitize_client_id(client_id) + self.params["redirect_uri"] = query_dict.get("redirect_uri", "") self.params["response_type"] = query_dict.get("response_type", "") self.params["scope"] = query_dict.get("scope", "").split() diff --git a/oidc_provider/lib/endpoints/introspection.py b/oidc_provider/lib/endpoints/introspection.py index 2ff023cb..b582af83 100644 --- a/oidc_provider/lib/endpoints/introspection.py +++ b/oidc_provider/lib/endpoints/introspection.py @@ -6,6 +6,7 @@ from oidc_provider.lib.errors import TokenIntrospectionError from oidc_provider.lib.utils.common import run_processing_hook from oidc_provider.lib.utils.oauth2 import extract_client_auth +from oidc_provider.lib.utils.sanitization import sanitize_client_id from oidc_provider.models import Client from oidc_provider.models import Token @@ -27,7 +28,8 @@ def _extract_params(self): # Introspection only supports POST requests self.params["token"] = self.request.POST.get("token") client_id, client_secret = extract_client_auth(self.request) - self.params["client_id"] = client_id + # Sanitize client_id to remove control characters that cause PostgreSQL errors + self.params["client_id"] = sanitize_client_id(client_id) self.params["client_secret"] = client_secret def validate_params(self): diff --git a/oidc_provider/lib/endpoints/token.py b/oidc_provider/lib/endpoints/token.py index 39cc871f..89131758 100644 --- a/oidc_provider/lib/endpoints/token.py +++ b/oidc_provider/lib/endpoints/token.py @@ -11,6 +11,7 @@ from oidc_provider.lib.errors import TokenError from oidc_provider.lib.errors import UserAuthError from oidc_provider.lib.utils.oauth2 import extract_client_auth +from oidc_provider.lib.utils.sanitization import sanitize_client_id from oidc_provider.lib.utils.token import create_id_token from oidc_provider.lib.utils.token import create_token from oidc_provider.lib.utils.token import encode_id_token @@ -31,7 +32,8 @@ def __init__(self, request): def _extract_params(self): client_id, client_secret = extract_client_auth(self.request) - self.params["client_id"] = client_id + # Sanitize client_id to remove control characters that cause PostgreSQL errors + self.params["client_id"] = sanitize_client_id(client_id) self.params["client_secret"] = client_secret self.params["redirect_uri"] = self.request.POST.get("redirect_uri", "") self.params["grant_type"] = self.request.POST.get("grant_type", "") diff --git a/oidc_provider/lib/utils/sanitization.py b/oidc_provider/lib/utils/sanitization.py new file mode 100644 index 00000000..fde787be --- /dev/null +++ b/oidc_provider/lib/utils/sanitization.py @@ -0,0 +1,31 @@ +import re + + +def sanitize_client_id(client_id): + """ + Sanitize client_id according to OAuth 2.0 RFC 6749 specification. + + Removes control characters that can cause database errors while preserving + all valid visible ASCII characters (VCHAR: 0x21-0x7E) as defined by the + OAuth 2.0 specification. + + Args: + client_id (str): The client_id parameter from the request + + Returns: + str: Sanitized client_id with control characters removed + + Examples: + >>> sanitize_client_id("Hello\\x00World") + 'HelloWorld' + >>> sanitize_client_id("valid-client-123") + 'valid-client-123' + >>> sanitize_client_id("") + '' + >>> sanitize_client_id(None) + '' + """ + if not client_id: + return "" + + return re.sub(r"[^\x21-\x7E]", "", client_id) diff --git a/oidc_provider/tests/cases/test_admin.py b/oidc_provider/tests/cases/test_admin.py new file mode 100644 index 00000000..98d76c29 --- /dev/null +++ b/oidc_provider/tests/cases/test_admin.py @@ -0,0 +1,98 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase + +from oidc_provider.admin import ClientForm +from oidc_provider.models import Client +from oidc_provider.models import ResponseType +from oidc_provider.tests.app.utils import create_fake_user + +User = get_user_model() + + +class ClientFormTest(TestCase): + """ + Test cases for ClientForm in admin. + """ + + def setUp(self): + self.user = create_fake_user() + self.code_response_type, _ = ResponseType.objects.get_or_create( + value="code", defaults={"description": "code (Authorization Code Flow)"} + ) + + def test_creates_client_without_client_id_generates_random_one(self): + """Test that creating a client without client_id generates a random 6-digit one.""" + form_data = { + "name": "Test Client", + "owner": self.user.pk, + "client_type": "public", + "response_types": [self.code_response_type.pk], + "_redirect_uris": "http://example.com/callback", + } + + form = ClientForm(data=form_data) + self.assertTrue(form.is_valid(), f"Form errors: {form.errors}") + + # The form should generate a client_id + client_id = form.clean_client_id() + self.assertIsNotNone(client_id) + self.assertEqual(len(client_id), 6) + self.assertTrue(client_id.isdigit()) + self.assertTrue(1 <= int(client_id) <= 999999) + + def test_creates_client_with_custom_client_id_preserves_it(self): + """Test that providing a custom client_id preserves it for new clients.""" + # Create and save a client first + client = Client.objects.create( + name="Existing Client", + owner=self.user, + client_type="public", + client_id="custom-client-123", + ) + client.response_types.add(self.code_response_type) + + form_data = { + "name": "Existing Client Updated", + "owner": self.user.pk, + "client_type": "public", + "response_types": [self.code_response_type.pk], + "_redirect_uris": "http://example.com/callback", + "client_id": "custom-client-123", + } + + # Test updating existing client + form = ClientForm(data=form_data, instance=client) + self.assertTrue(form.is_valid(), f"Form errors: {form.errors}") + + # Should return the sanitized version of existing client_id + client_id = form.clean_client_id() + self.assertEqual(client_id, "custom-client-123") + + def test_sanitizes_existing_client_id_with_control_characters(self): + """Test that existing client_id with control characters gets sanitized.""" + # Create a client with problematic client_id + client = Client.objects.create( + name="Problematic Client", + owner=self.user, + client_type="public", + client_id="normalclient", # Start with normal client_id + ) + client.response_types.add(self.code_response_type) + + # Manually set problematic client_id to test sanitization + client.client_id = "client\x00\x01test" # Contains null byte and control char + + form_data = { + "name": "Problematic Client", + "owner": self.user.pk, + "client_type": "public", + "response_types": [self.code_response_type.pk], + "_redirect_uris": "http://example.com/callback", + } + + form = ClientForm(data=form_data, instance=client) + self.assertTrue(form.is_valid(), f"Form errors: {form.errors}") + + # Should return sanitized client_id + client_id = form.clean_client_id() + self.assertEqual(client_id, "clienttest") # Control characters removed diff --git a/oidc_provider/tests/cases/test_authorize_endpoint.py b/oidc_provider/tests/cases/test_authorize_endpoint.py index 5846a423..c9f4745c 100644 --- a/oidc_provider/tests/cases/test_authorize_endpoint.py +++ b/oidc_provider/tests/cases/test_authorize_endpoint.py @@ -583,6 +583,26 @@ def test_strip_prompt_login(self): self.assertIn("none", strip_prompt_login(path3)) self.assertNotIn("login", strip_prompt_login(path3)) + def test_client_id_with_null_char_are_rejected(self): + """ + Test that client_id parameters containing unexpected characters and + are properly sanitized and to not cause database errors. + """ + data = { + "client_id": "Hello\0World", + "response_type": next(self.client_code.response_type_values()), + "redirect_uri": self.client_code.default_redirect_uri, + "scope": "openid email", + "state": self.state, + "prompt": "none", + } + + response = self._auth_request("get", data) + + self.assertEqual(response.status_code, 200) + + self.assertIn("Client ID Error", response.content.decode("utf-8")) + class AuthorizationImplicitFlowTestCase(TestCase, AuthorizeEndpointMixin): """ diff --git a/oidc_provider/tests/cases/test_utils.py b/oidc_provider/tests/cases/test_utils.py index 119e4ba4..788e633d 100644 --- a/oidc_provider/tests/cases/test_utils.py +++ b/oidc_provider/tests/cases/test_utils.py @@ -11,6 +11,7 @@ from oidc_provider.lib.utils.common import get_browser_state_or_default from oidc_provider.lib.utils.common import get_issuer +from oidc_provider.lib.utils.sanitization import sanitize_client_id from oidc_provider.lib.utils.token import create_id_token from oidc_provider.lib.utils.token import create_token from oidc_provider.tests.app.utils import create_fake_client @@ -155,3 +156,62 @@ def test_get_browser_state_uses_session_key_to_calculate_browser_state_if_availa request.session = Mock(session_key="my_session_key") state = get_browser_state_or_default(request) self.assertEqual(state, sha224("my_session_key".encode("utf-8")).hexdigest()) + + +class SanitizationTest(TestCase): + """ + Test cases for sanitization utils. + """ + + def test_sanitize_client_id_removes_null_bytes(self): + """Test that null bytes are removed from client_id.""" + client_id = "Hello\x00World" + result = sanitize_client_id(client_id) + self.assertEqual(result, "HelloWorld") + + def test_sanitize_client_id_removes_control_characters(self): + """Test that various control characters are removed.""" + client_id = "client\x01\x02\x03\x1f\x7fid" + result = sanitize_client_id(client_id) + self.assertEqual(result, "clientid") + + def test_sanitize_client_id_preserves_valid_characters(self): + """Test that valid visible ASCII characters are preserved.""" + client_id = "valid-client_123.abc!@#$%^&*()+={}[]|\\:;\"'<>?,./~`" + result = sanitize_client_id(client_id) + self.assertEqual(result, client_id) # Should remain unchanged + + def test_sanitize_client_id_handles_empty_string(self): + """Test that empty string returns empty string.""" + result = sanitize_client_id("") + self.assertEqual(result, "") + + def test_sanitize_client_id_handles_none(self): + """Test that None returns empty string.""" + result = sanitize_client_id(None) + self.assertEqual(result, "") + + def test_sanitize_client_id_removes_whitespace_characters(self): + """Test that whitespace characters are removed (not part of VCHAR).""" + client_id = "client\t\n\r id" + result = sanitize_client_id(client_id) + self.assertEqual(result, "clientid") + + def test_sanitize_client_id_preserves_printable_ascii(self): + """Test preservation of all printable ASCII characters (0x21-0x7E).""" + # All VCHAR characters as per RFC 6749 + vchar_string = "".join(chr(i) for i in range(0x21, 0x7F)) + result = sanitize_client_id(vchar_string) + self.assertEqual(result, vchar_string) + + def test_sanitize_client_id_removes_unicode_characters(self): + """Test that Unicode characters outside ASCII range are removed.""" + client_id = "client-ñáéíóú-测试-🔥" + result = sanitize_client_id(client_id) + self.assertEqual(result, "client---") + + def test_sanitize_client_id_mixed_valid_invalid(self): + """Test mixed valid and invalid characters.""" + client_id = "valid\x00client\x01-\x7f123\tabc" + result = sanitize_client_id(client_id) + self.assertEqual(result, "validclient-123abc") From 2bc44e45199e659ace8b57c136f26a18cecdff2a Mon Sep 17 00:00:00 2001 From: Juani Date: Sun, 31 Aug 2025 20:56:17 -0300 Subject: [PATCH 45/54] Fixed client_id sanitization to prevent DB errors --- oidc_provider/lib/endpoints/authorize.py | 5 +---- oidc_provider/lib/endpoints/introspection.py | 1 - oidc_provider/lib/endpoints/token.py | 1 - 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/oidc_provider/lib/endpoints/authorize.py b/oidc_provider/lib/endpoints/authorize.py index 7019754b..74565ee1 100644 --- a/oidc_provider/lib/endpoints/authorize.py +++ b/oidc_provider/lib/endpoints/authorize.py @@ -73,10 +73,7 @@ def _extract_params(self): # and POST request. query_dict = self.request.POST if self.request.method == "POST" else self.request.GET - # Sanitize client_id to remove control characters that cause PostgreSQL errors - client_id = query_dict.get("client_id", "") - self.params["client_id"] = sanitize_client_id(client_id) - + self.params["client_id"] = sanitize_client_id(query_dict.get("client_id", "")) self.params["redirect_uri"] = query_dict.get("redirect_uri", "") self.params["response_type"] = query_dict.get("response_type", "") self.params["scope"] = query_dict.get("scope", "").split() diff --git a/oidc_provider/lib/endpoints/introspection.py b/oidc_provider/lib/endpoints/introspection.py index b582af83..f7845b28 100644 --- a/oidc_provider/lib/endpoints/introspection.py +++ b/oidc_provider/lib/endpoints/introspection.py @@ -28,7 +28,6 @@ def _extract_params(self): # Introspection only supports POST requests self.params["token"] = self.request.POST.get("token") client_id, client_secret = extract_client_auth(self.request) - # Sanitize client_id to remove control characters that cause PostgreSQL errors self.params["client_id"] = sanitize_client_id(client_id) self.params["client_secret"] = client_secret diff --git a/oidc_provider/lib/endpoints/token.py b/oidc_provider/lib/endpoints/token.py index 89131758..f804a6ca 100644 --- a/oidc_provider/lib/endpoints/token.py +++ b/oidc_provider/lib/endpoints/token.py @@ -32,7 +32,6 @@ def __init__(self, request): def _extract_params(self): client_id, client_secret = extract_client_auth(self.request) - # Sanitize client_id to remove control characters that cause PostgreSQL errors self.params["client_id"] = sanitize_client_id(client_id) self.params["client_secret"] = client_secret self.params["redirect_uri"] = self.request.POST.get("redirect_uri", "") From 9297e24e86045c00a54ef3ca515132bcd25edc07 Mon Sep 17 00:00:00 2001 From: Stefan Foulis Date: Wed, 3 Sep 2025 12:02:05 +0200 Subject: [PATCH 46/54] Add Django 5.2 to our test matrix * remove non-LTS versions (4.0, 4.1, 5.0, 5.1) * github test matrix: explicitly fail if the tested python version is not available --- .github/workflows/main.yml | 2 +- tox.ini | 19 ++++++++----------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 433e79f8..aa1301bc 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -58,4 +58,4 @@ jobs: with: python-version: ${{ matrix.python }} - name: Run tox - run: tox run -e py$(echo "${{ matrix.python }}" | tr -d '.')-django$(echo "${{ matrix.django }}" | tr -d '.') + run: tox run --skip-missing-interpreters=false -e py$(echo "${{ matrix.python }}" | tr -d '.')-django$(echo "${{ matrix.django }}" | tr -d '.') diff --git a/tox.ini b/tox.ini index 76feec13..e76ad56a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,12 +1,12 @@ [tox] envlist= docs, - py38-django{32,40,41,42}, - py39-django{32,40,41,42}, - py310-django{32,40,41,42,50,51}, - py311-django{41,42,50,51}, - py312-django{41,42,50,51}, - py313-django{41,42,50,51}, + py38-django{32,42}, + py39-django{32,42}, + py310-django{32,42,52}, + py311-django{42,52}, + py312-django{42,52}, + py313-django{42,52}, ruff [testenv] @@ -14,11 +14,8 @@ changedir= oidc_provider deps = django32: django>=3.2,<3.3 - django40: django>=4.0,<4.1 - django41: django>=4.1,<4.2 django42: django>=4.2,<4.3 - django50: django>=5.0,<5.1 - django51: django>=5.1,<5.2 + django52: django>=5.2,<5.3 freezegun psycopg2-binary pytest @@ -50,4 +47,4 @@ commands = [pytest] DJANGO_SETTINGS_MODULE = oidc_provider.tests.settings -python_files = test_*.py \ No newline at end of file +python_files = test_*.py From 971a7cdb71c4f812c1b3c409e06bd7ab4e1efb0e Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Wed, 3 Sep 2025 19:55:40 -0300 Subject: [PATCH 47/54] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 39b12ec7..71a81f13 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Django OpenID Connect Provider [![Python Versions](https://img.shields.io/pypi/pyversions/django-oidc-provider.svg)](https://pypi.python.org/pypi/django-oidc-provider) -[![Django Versions](https://img.shields.io/badge/Django-3.2%20%7C%204.2%20%7C%205.1-green)](https://pypi.python.org/pypi/django-oidc-provider) +[![Django Versions](https://img.shields.io/badge/Django-3.2%20%7C%204.2%20%7C%205.2-green)](https://pypi.python.org/pypi/django-oidc-provider) [![PyPI Versions](https://img.shields.io/pypi/v/django-oidc-provider.svg)](https://pypi.python.org/pypi/django-oidc-provider) [![Documentation Status](https://readthedocs.org/projects/django-oidc-provider/badge/?version=master)](http://django-oidc-provider.readthedocs.io/) From 5d4b479f559274ac98ad0d88a0aba320977fd176 Mon Sep 17 00:00:00 2001 From: Stefan Foulis Date: Mon, 22 Sep 2025 01:53:43 +0200 Subject: [PATCH 48/54] Use PyJWT+cryptography instead of jwkest+Cryptodrome (#446) - Switches id_token signing and verification to use PyJWT with cryptography - Removes jwkest and Cryptodome dependencies - `future` dependency previously required by jwkest is no longer needed (it had unfixed security vulnerabilities and seems unmaintained) - adds caching of RSA keys to avoid repeated expensive key loading operations --- oidc_provider/lib/utils/token.py | 90 ++++- .../management/commands/creatersakey.py | 19 +- .../tests/cases/test_authorize_endpoint.py | 6 +- .../tests/cases/test_token_endpoint.py | 379 ++++++++++++++++-- oidc_provider/views.py | 16 +- setup.py | 6 +- 6 files changed, 456 insertions(+), 60 deletions(-) diff --git a/oidc_provider/lib/utils/token.py b/oidc_provider/lib/utils/token.py index 9083b36e..7b8618f4 100644 --- a/oidc_provider/lib/utils/token.py +++ b/oidc_provider/lib/utils/token.py @@ -2,13 +2,10 @@ import uuid from datetime import timedelta -from Cryptodome.PublicKey.RSA import importKey +import jwt +from cryptography.hazmat.primitives import serialization from django.utils import dateformat from django.utils import timezone -from jwkest.jwk import RSAKey as jwk_RSAKey -from jwkest.jwk import SYMKey -from jwkest.jws import JWS -from jwkest.jwt import JWT from oidc_provider import settings from oidc_provider.lib.claims import StandardScopeClaims @@ -18,6 +15,10 @@ from oidc_provider.models import RSAKey from oidc_provider.models import Token +# Cache for loaded RSA keys to avoid repeated PEM parsing +# Cache is automatically cleaned of stale entries (keys no longer in DB) +_rsa_key_cache = {} + def create_id_token(token, user, aud, nonce="", at_hash="", request=None, scope=None): """ @@ -72,28 +73,56 @@ def create_id_token(token, user, aud, nonce="", at_hash="", request=None, scope= def encode_id_token(payload, client): """ Represent the ID Token as a JSON Web Token (JWT). - Return a hash. + Returns a dict. """ keys = get_client_alg_keys(client) - _jws = JWS(payload, alg=client.jwt_alg) - return _jws.sign_compact(keys) + # Use the first key for encoding + # TODO: make key selection more explicit + key_info = keys[0] + + headers = {} + if "kid" in key_info: + headers["kid"] = key_info["kid"] + + return jwt.encode(payload, key_info["key"], algorithm=key_info["algorithm"], headers=headers) def decode_id_token(token, client): """ Represent the ID Token as a JSON Web Token (JWT). - Return a hash. + Returns a dict. """ - keys = get_client_alg_keys(client) - return JWS().verify_compact(token, keys=keys) + # Try decoding with each available key + for key in get_client_alg_keys(client): + try: + return jwt.decode( + jwt=token, + # HS256 uses the same key for signing and verifying + key=key["key"] if key["algorithm"] == "HS256" else key["public_key"], + algorithms=[key["algorithm"]], + options={ + "verify_signature": True, + "verify_aud": False, # Disable audience validation for compatibility + "verify_exp": False, # Disable expiration validation for compatibility + "verify_iat": False, # Disable issued at validation for compatibility + "verify_nbf": False, # Disable not before validation for compatibility + }, + ) + except jwt.InvalidTokenError: + continue + + # If we get here, none of the keys worked + raise jwt.InvalidTokenError("Token could not be decoded with any available key") def client_id_from_id_token(id_token): """ Extracts the client id from a JSON Web Token (JWT). + Does NOT verify the token signature or expiration. Returns a string or None. """ - payload = JWT().unpack(id_token).payload() + # Decode without verification to get the payload + payload = jwt.decode(id_token, options={"verify_signature": False}) aud = payload.get("aud", None) if aud is None: return None @@ -150,16 +179,47 @@ def create_code( def get_client_alg_keys(client): """ Takes a client and returns the set of keys associated with it. - Returns a list of keys. + Returns a list of keys compatible with PyJWT. """ if client.jwt_alg == "RS256": keys = [] + current_kids = set() + for rsakey in RSAKey.objects.all(): - keys.append(jwk_RSAKey(key=importKey(rsakey.key), kid=rsakey.kid)) + cache_key = f"rsa_key_{rsakey.kid}" + current_kids.add(cache_key) + + if cache_key not in _rsa_key_cache: + # Load the RSA private key using cryptography (expensive operation) + private_key = serialization.load_pem_private_key( + rsakey.key.encode("utf-8"), + password=None, + ) + # Also cache the public key to avoid repeated .public_key() calls + public_key = private_key.public_key() + _rsa_key_cache[cache_key] = {"private_key": private_key, "public_key": public_key} + + key_pair = _rsa_key_cache[cache_key] + keys.append( + { + "key": key_pair["private_key"], + "public_key": key_pair["public_key"], + "kid": rsakey.kid, + "algorithm": "RS256", + } + ) + + # Clean up stale cache entries (keys that no longer exist in DB) + stale_keys = set(_rsa_key_cache.keys()) - current_kids + for stale_key in stale_keys: + del _rsa_key_cache[stale_key] + if not keys: raise Exception("You must add at least one RSA Key.") elif client.jwt_alg == "HS256": - keys = [SYMKey(key=client.client_secret, alg=client.jwt_alg)] + # NOTE: HS256 does not have any expensive key parsing, so we don't need the + # same key caching as RS256. + keys = [{"key": client.client_secret, "algorithm": "HS256"}] else: raise Exception("Unsupported key algorithm.") diff --git a/oidc_provider/management/commands/creatersakey.py b/oidc_provider/management/commands/creatersakey.py index 3057f851..1b5b0c8f 100644 --- a/oidc_provider/management/commands/creatersakey.py +++ b/oidc_provider/management/commands/creatersakey.py @@ -1,4 +1,5 @@ -from Cryptodome.PublicKey import RSA +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa from django.core.management.base import BaseCommand from oidc_provider.models import RSAKey @@ -9,8 +10,20 @@ class Command(BaseCommand): def handle(self, *args, **options): try: - key = RSA.generate(2048) - rsakey = RSAKey(key=key.exportKey("PEM").decode("utf8")) + # Generate a new RSA private key with 2048 bits + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + ) + + # Serialize the private key to PEM format + key_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode("utf-8") + + rsakey = RSAKey(key=key_pem) rsakey.save() self.stdout.write("RSA key successfully created with kid: {0}".format(rsakey.kid)) except Exception as e: diff --git a/oidc_provider/tests/cases/test_authorize_endpoint.py b/oidc_provider/tests/cases/test_authorize_endpoint.py index c9f4745c..1cfe5c5e 100644 --- a/oidc_provider/tests/cases/test_authorize_endpoint.py +++ b/oidc_provider/tests/cases/test_authorize_endpoint.py @@ -22,12 +22,12 @@ from django.urls import reverse except ImportError: from django.core.urlresolvers import reverse +import jwt from django.contrib.auth.models import AnonymousUser from django.core.management import call_command from django.test import RequestFactory from django.test import TestCase from django.test import override_settings -from jwkest.jwt import JWT from oidc_provider import settings from oidc_provider.lib.endpoints.authorize import AuthorizeEndpoint @@ -724,7 +724,7 @@ def test_idtoken_token_at_hash(self): # obtain `id_token` portion of Location components = urlsplit(response["Location"]) fragment = parse_qs(components[4]) - id_token = JWT().unpack(fragment["id_token"][0].encode("utf-8")).payload() + id_token = jwt.decode(fragment["id_token"][0], options={"verify_signature": False}) self.assertIn("at_hash", id_token) @@ -750,7 +750,7 @@ def test_idtoken_at_hash(self): # obtain `id_token` portion of Location components = urlsplit(response["Location"]) fragment = parse_qs(components[4]) - id_token = JWT().unpack(fragment["id_token"][0].encode("utf-8")).payload() + id_token = jwt.decode(fragment["id_token"][0], options={"verify_signature": False}) self.assertNotIn("at_hash", id_token) diff --git a/oidc_provider/tests/cases/test_token_endpoint.py b/oidc_provider/tests/cases/test_token_endpoint.py index ee60a0c7..c736c3fd 100644 --- a/oidc_provider/tests/cases/test_token_endpoint.py +++ b/oidc_provider/tests/cases/test_token_endpoint.py @@ -14,6 +14,12 @@ except ImportError: from django.core.urlresolvers import reverse +import base64 + +import jwt +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers from django.core.management import call_command from django.db import DatabaseError from django.http import JsonResponse @@ -21,13 +27,15 @@ from django.test import TestCase from django.test import override_settings from django.views.decorators.http import require_http_methods -from jwkest.jwk import KEYS -from jwkest.jws import JWS -from jwkest.jwt import JWT +import oidc_provider.lib.utils from oidc_provider.lib.endpoints.introspection import INTROSPECTION_SCOPE from oidc_provider.lib.utils.oauth2 import protected_resource_view from oidc_provider.lib.utils.token import create_code +from oidc_provider.lib.utils.token import decode_id_token +from oidc_provider.lib.utils.token import encode_id_token +from oidc_provider.lib.utils.token import get_client_alg_keys +from oidc_provider.models import RSAKey from oidc_provider.models import Token from oidc_provider.tests.app.utils import FAKE_CODE_CHALLENGE from oidc_provider.tests.app.utils import FAKE_CODE_VERIFIER @@ -147,14 +155,11 @@ def _create_code(self, scope=None): def _get_keys(self): """ - Get public key from discovery. + Get RSA keys for JWT operations. + Returns the actual RSA private key that can be used with PyJWT directly. """ - request = self.factory.get(reverse("oidc_provider:jwks")) - response = JwksView.as_view()(request) - jwks_dic = json.loads(response.content.decode("utf-8")) - SIGKEYS = KEYS() - SIGKEYS.load_dict(jwks_dic) - return SIGKEYS + keys = get_client_alg_keys(self.client) + return keys[0]["key"] if keys else None def _get_userinfo(self, access_token): url = reverse("oidc_provider:userinfo") @@ -247,7 +252,7 @@ def check_password_grant(self, scope): ) response_dict = json.loads(response.content.decode("utf-8")) - id_token = JWS().verify_compact(response_dict["id_token"].encode("utf-8"), self._get_keys()) + id_token = decode_id_token(response_dict["id_token"], self.client) token = Token.objects.get(user=self.user) self.assertEqual(response_dict["access_token"], token.access_token) @@ -286,7 +291,6 @@ def test_authorization_code(self): using the algorithm specified in the alg Header Parameter of the JOSE Header. """ - SIGKEYS = self._get_keys() code = self._create_code() post_data = self._auth_code_post_data(code=code.code) @@ -294,7 +298,7 @@ def test_authorization_code(self): response = self._post_request(post_data) response_dic = json.loads(response.content.decode("utf-8")) - id_token = JWS().verify_compact(response_dic["id_token"].encode("utf-8"), SIGKEYS) + id_token = decode_id_token(response_dic["id_token"], self.client) token = Token.objects.get(user=self.user) self.assertEqual(response_dic["access_token"], token.access_token) @@ -328,7 +332,6 @@ def test_scope_is_ignored_for_auth_code(self): Scope is ignored for token respones to auth code grant type. This comes down to that the scopes requested in authorize are returned. """ - SIGKEYS = self._get_keys() for code_scope in [["openid"], ["openid", "email"], ["openid", "profile"]]: code = self._create_code(code_scope) @@ -339,7 +342,7 @@ def test_scope_is_ignored_for_auth_code(self): self.assertEqual(response.status_code, 200) - id_token = JWS().verify_compact(response_dic["id_token"].encode("utf-8"), SIGKEYS) + id_token = decode_id_token(response_dic["id_token"], self.client) if "email" in code_scope: self.assertIn("email", id_token) @@ -382,8 +385,6 @@ def test_refresh_token_narrowed_scope(self): @override_settings(OIDC_IDTOKEN_INCLUDE_CLAIMS=True) def do_refresh_token_check(self, scope=None): - SIGKEYS = self._get_keys() - # Retrieve refresh token code = self._create_code() self.assertEqual(code.scope, TokenTestCase.SCOPE_LIST) @@ -394,7 +395,7 @@ def do_refresh_token_check(self, scope=None): response = self._post_request(post_data) response_dic1 = json.loads(response.content.decode("utf-8")) - id_token1 = JWS().verify_compact(response_dic1["id_token"].encode("utf-8"), SIGKEYS) + id_token1 = decode_id_token(response_dic1["id_token"], self.client) # Use refresh token to obtain new token post_data = self._refresh_token_post_data(response_dic1["refresh_token"], scope) @@ -410,7 +411,7 @@ def do_refresh_token_check(self, scope=None): self.assertEqual(response_dic2["error"], "invalid_scope") return # No more checks - id_token2 = JWS().verify_compact(response_dic2["id_token"].encode("utf-8"), SIGKEYS) + id_token2 = decode_id_token(response_dic2["id_token"], self.client) if scope and "email" not in scope: # narrowed scope The auth # The auth code request had email in scope, so it should be @@ -584,7 +585,7 @@ def test_access_token_contains_nonce(self): response = self._post_request(post_data) response_dic = json.loads(response.content.decode("utf-8")) - id_token = JWT().unpack(response_dic["id_token"].encode("utf-8")).payload() + id_token = jwt.decode(response_dic["id_token"], options={"verify_signature": False}) self.assertEqual(id_token.get("nonce"), FAKE_NONCE) @@ -595,7 +596,7 @@ def test_access_token_contains_nonce(self): response = self._post_request(post_data) response_dic = json.loads(response.content.decode("utf-8")) - id_token = JWT().unpack(response_dic["id_token"].encode("utf-8")).payload() + id_token = jwt.decode(response_dic["id_token"], options={"verify_signature": False}) self.assertEqual(id_token.get("nonce"), None) @@ -610,7 +611,7 @@ def test_id_token_contains_at_hash(self): response = self._post_request(post_data) response_dic = json.loads(response.content.decode("utf-8")) - id_token = JWT().unpack(response_dic["id_token"].encode("utf-8")).payload() + id_token = jwt.decode(response_dic["id_token"], options={"verify_signature": False}) self.assertTrue(id_token.get("at_hash")) @@ -620,9 +621,6 @@ def test_idtoken_sign_validation(self): using the algorithm specified in the alg Header Parameter of the JOSE Header. """ - SIGKEYS = self._get_keys() - RSAKEYS = [k for k in SIGKEYS if k.kty == "RSA"] - code = self._create_code() post_data = self._auth_code_post_data(code=code.code) @@ -630,7 +628,14 @@ def test_idtoken_sign_validation(self): response = self._post_request(post_data) response_dic = json.loads(response.content.decode("utf-8")) - JWS().verify_compact(response_dic["id_token"].encode("utf-8"), RSAKEYS) + # This will raise an exception if verification fails + decode_id_token(response_dic["id_token"], self.client) + + def test_idtoken_sign_validation_fail(self): + bad_id_token = jwt.encode({"some": "payload"}, "wrong_key", algorithm="HS256") + + with self.assertRaises(expected_exception=jwt.InvalidTokenError): + decode_id_token(bad_id_token, self.client) @override_settings( OIDC_IDTOKEN_SUB_GENERATOR="oidc_provider.tests.app.utils.fake_sub_generator" @@ -646,7 +651,7 @@ def test_custom_sub_generator(self): response = self._post_request(post_data) response_dic = json.loads(response.content.decode("utf-8")) - id_token = JWT().unpack(response_dic["id_token"].encode("utf-8")).payload() + id_token = jwt.decode(response_dic["id_token"], options={"verify_signature": False}) self.assertEqual(id_token.get("sub"), self.user.email) @@ -664,7 +669,7 @@ def test_additional_idtoken_processing_hook(self): response = self._post_request(post_data) response_dic = json.loads(response.content.decode("utf-8")) - id_token = JWT().unpack(response_dic["id_token"].encode("utf-8")).payload() + id_token = jwt.decode(response_dic["id_token"], options={"verify_signature": False}) self.assertEqual(id_token.get("test_idtoken_processing_hook"), FAKE_RANDOM_STRING) self.assertEqual(id_token.get("test_idtoken_processing_hook_user_email"), self.user.email) @@ -685,7 +690,7 @@ def test_additional_idtoken_processing_hook_one_element_in_list(self): response = self._post_request(post_data) response_dic = json.loads(response.content.decode("utf-8")) - id_token = JWT().unpack(response_dic["id_token"].encode("utf-8")).payload() + id_token = jwt.decode(response_dic["id_token"], options={"verify_signature": False}) self.assertEqual(id_token.get("test_idtoken_processing_hook"), FAKE_RANDOM_STRING) self.assertEqual(id_token.get("test_idtoken_processing_hook_user_email"), self.user.email) @@ -707,7 +712,7 @@ def test_additional_idtoken_processing_hook_two_elements_in_list(self): response = self._post_request(post_data) response_dic = json.loads(response.content.decode("utf-8")) - id_token = JWT().unpack(response_dic["id_token"].encode("utf-8")).payload() + id_token = jwt.decode(response_dic["id_token"], options={"verify_signature": False}) self.assertEqual(id_token.get("test_idtoken_processing_hook"), FAKE_RANDOM_STRING) self.assertEqual(id_token.get("test_idtoken_processing_hook_user_email"), self.user.email) @@ -732,7 +737,7 @@ def test_additional_idtoken_processing_hook_two_elements_in_tuple(self): response = self._post_request(post_data) response_dic = json.loads(response.content.decode("utf-8")) - id_token = JWT().unpack(response_dic["id_token"].encode("utf-8")).payload() + id_token = jwt.decode(response_dic["id_token"], options={"verify_signature": False}) self.assertEqual(id_token.get("test_idtoken_processing_hook"), FAKE_RANDOM_STRING) self.assertEqual(id_token.get("test_idtoken_processing_hook_user_email"), self.user.email) @@ -775,7 +780,7 @@ def _request_id_token_with_scope(self, scope): response = self._post_request(post_data) response_dic = json.loads(response.content.decode("utf-8")) - id_token = JWT().unpack(response_dic["id_token"].encode("utf-8")).payload() + id_token = jwt.decode(response_dic["id_token"], options={"verify_signature": False}) return id_token def test_pkce_parameters(self): @@ -974,3 +979,313 @@ def test_requested_scope(self): response_dict = json.loads(response.content.decode("utf-8")) self.assertEqual(200, response.status_code) self.assertEqual("email openid", response_dict["scope"]) + + +class JwksTestCase(TestCase): + """ + Test cases for the JSON Web Key Set (JWKS) endpoint. + This tests the discovery mechanism and key format validation + that was previously covered implicitly in token tests. + """ + + def setUp(self): + call_command("creatersakey") + self.factory = RequestFactory() + self.user = create_fake_user() + self.client = create_fake_client(response_type="code", is_public=True) + + def test_jwks_endpoint_returns_valid_json(self): + """Test that the JWKS endpoint returns valid JSON.""" + request = self.factory.get(reverse("oidc_provider:jwks")) + response = JwksView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "application/json") + + # Should be able to parse as JSON + jwks_data = json.loads(response.content.decode("utf-8")) + self.assertIsInstance(jwks_data, dict) + + def test_jwks_contains_required_fields(self): + """Test that JWKS response contains the required JWK fields.""" + request = self.factory.get(reverse("oidc_provider:jwks")) + response = JwksView.as_view()(request) + jwks_data = json.loads(response.content.decode("utf-8")) + + # Should have 'keys' array + self.assertIn("keys", jwks_data) + self.assertIsInstance(jwks_data["keys"], list) + self.assertGreater(len(jwks_data["keys"]), 0) + + # Each key should have required JWK fields + for key in jwks_data["keys"]: + self.assertIn("kty", key) # Key type + self.assertIn("use", key) # Key use + self.assertIn("kid", key) # Key ID + self.assertIn("n", key) # RSA modulus + self.assertIn("e", key) # RSA exponent + self.assertIn("alg", key) # Algorithm + + # Should be RSA key for signing + self.assertEqual(key["kty"], "RSA") + self.assertEqual(key["use"], "sig") + + def test_jwks_keys_work_with_pyjwt(self): + """Test that keys from JWKS endpoint work with PyJWT for verification.""" + # Get JWKS + request = self.factory.get(reverse("oidc_provider:jwks")) + response = JwksView.as_view()(request) + jwks_data = json.loads(response.content.decode("utf-8")) + + # Get the first key + jwk = jwks_data["keys"][0] + + # Convert JWK to RSA public key + n = int.from_bytes(base64.urlsafe_b64decode(jwk["n"] + "=="), byteorder="big") + e = int.from_bytes(base64.urlsafe_b64decode(jwk["e"] + "=="), byteorder="big") + + public_key = RSAPublicNumbers(e, n).public_key() + + # Create a test token using our encode function + test_payload = { + "iss": "test", + "sub": "123", + "aud": self.client.client_id, + "exp": int(time.time()) + 3600, + "iat": int(time.time()), + } + + # Encode with our function + test_token = encode_id_token(test_payload, self.client) + + # Should be able to verify with the public key from JWKS + decoded = jwt.decode( + test_token, + public_key, + algorithms=["RS256"], + options={ + "verify_aud": False, + "verify_exp": False, + "verify_iat": False, + "verify_nbf": False, + }, + ) + + self.assertEqual(decoded["sub"], "123") + self.assertEqual(decoded["aud"], self.client.client_id) + + def test_jwks_integration_with_token_validation(self): + """Test that JWKS keys can be used to validate actual ID tokens.""" + # Get keys using both methods + jwks_request = self.factory.get(reverse("oidc_provider:jwks")) + jwks_response = JwksView.as_view()(jwks_request) + jwks_data = json.loads(jwks_response.content.decode("utf-8")) + + client_keys = get_client_alg_keys(self.client) + + # Should have keys from both methods + self.assertGreater(len(jwks_data["keys"]), 0) + self.assertGreater(len(client_keys), 0) + + # The kid should match between JWKS and client keys + jwks_kids = {key["kid"] for key in jwks_data["keys"]} + client_kids = {key["kid"] for key in client_keys} + self.assertEqual(jwks_kids, client_kids) + + +class RSAKeyCachingTestCase(TestCase): + """ + Test cases for RSA key caching functionality to ensure: + 1. Keys are cached for performance + 2. Cache is cleaned up when keys are removed + 3. No memory leaks occur + """ + + def setUp(self): + self.token_utils = oidc_provider.lib.utils.token + + call_command("creatersakey") + call_command("creatersakey") # Create additional test data + + # Start with clean cache + self.token_utils._rsa_key_cache.clear() + + # Create test client using the correct pattern + self.factory = RequestFactory() + self.user = create_fake_user() + self.client = create_fake_client(response_type="code") + # Ensure it uses RS256 (default, but let's be explicit) + self.client.jwt_alg = "RS256" + self.client.save() + + def test_rsa_key_caching_performance(self): + """Test that RSA key caching provides performance benefits.""" + # Clear cache to start fresh + self.token_utils._rsa_key_cache.clear() + + # Ensure cache is empty + self.assertEqual(len(self.token_utils._rsa_key_cache), 0) + + # First call should populate cache (slower) + start_time = time.time() + keys1 = self.token_utils.get_client_alg_keys(self.client) + first_call_time = time.time() - start_time + + # Cache should now have entries + self.assertGreater(len(self.token_utils._rsa_key_cache), 0) + self.assertGreater(len(keys1), 0) + + # Second call should use cache (much faster) + start_time = time.time() + keys2 = self.token_utils.get_client_alg_keys(self.client) + second_call_time = time.time() - start_time + + # Results should be identical + self.assertEqual(len(keys1), len(keys2)) + self.assertEqual(keys1[0]["kid"], keys2[0]["kid"]) + self.assertEqual(keys1[0]["algorithm"], keys2[0]["algorithm"]) + + # Second call should be significantly faster (cache hit) + # Note: This is a rough performance test, actual speedup is ~1000x + self.assertLess(second_call_time, first_call_time * 0.5) + + def test_rsa_key_cache_cleanup_on_key_deletion(self): + """Test that cache is cleaned up when RSA keys are deleted from DB.""" + # Load keys into cache + keys_before = self.token_utils.get_client_alg_keys(self.client) + initial_cache_size = len(self.token_utils._rsa_key_cache) + initial_key_count = len(keys_before) + + self.assertGreater(initial_cache_size, 0) + self.assertGreater(initial_key_count, 0) + + # Manually add a fake cache entry to simulate a deleted key + fake_cache_key = "rsa_key_fake_deleted_key" + self.token_utils._rsa_key_cache[fake_cache_key] = { + "private_key": "fake_private_key", + "public_key": "fake_public_key", + } + + # Cache should now have the fake entry + self.assertEqual(len(self.token_utils._rsa_key_cache), initial_cache_size + 1) + self.assertIn(fake_cache_key, self.token_utils._rsa_key_cache) + + # Call get_client_alg_keys again - should clean up the fake entry + keys_after = self.token_utils.get_client_alg_keys(self.client) + + # Cache should be cleaned up + self.assertEqual(len(self.token_utils._rsa_key_cache), initial_cache_size) + self.assertNotIn(fake_cache_key, self.token_utils._rsa_key_cache) + + # Key results should be unchanged + self.assertEqual(len(keys_after), initial_key_count) + + def test_rsa_key_cache_cleanup_on_all_keys_deleted(self): + """Test that cache is completely cleaned when all RSA keys are deleted.""" + # Load keys into cache + self.token_utils.get_client_alg_keys(self.client) + self.assertGreater(len(self.token_utils._rsa_key_cache), 0) + + # Delete all RSA keys from database + RSAKey.objects.all().delete() + + # Calling get_client_alg_keys should raise exception and clean cache + with self.assertRaises(Exception) as context: + self.token_utils.get_client_alg_keys(self.client) + + self.assertIn("You must add at least one RSA Key", str(context.exception)) + + # Cache should be completely empty + self.assertEqual(len(self.token_utils._rsa_key_cache), 0) + + def test_rsa_key_cache_with_multiple_keys(self): + """Test caching behavior with multiple RSA keys.""" + # Create additional RSA keys + call_command("creatersakey") + call_command("creatersakey") + + # Should now have multiple keys + all_keys = RSAKey.objects.all() + self.assertGreater(len(all_keys), 1) + + # Load keys into cache + client_keys = self.token_utils.get_client_alg_keys(self.client) + + # Cache should have entries for all keys + self.assertEqual(len(self.token_utils._rsa_key_cache), len(all_keys)) + self.assertEqual(len(client_keys), len(all_keys)) + + # All keys should be properly structured + for key_info in client_keys: + self.assertIn("key", key_info) # private key + self.assertIn("public_key", key_info) # public key + self.assertIn("kid", key_info) + self.assertIn("algorithm", key_info) + self.assertEqual(key_info["algorithm"], "RS256") + + def test_rsa_key_cache_clear_function(self): + """Test the manual cache clear function.""" + # Load keys into cache + self.token_utils.get_client_alg_keys(self.client) + + self.assertGreater(len(self.token_utils._rsa_key_cache), 0) + + # Clear cache manually + self.token_utils._rsa_key_cache.clear() + + # Cache should be empty + self.assertEqual(len(self.token_utils._rsa_key_cache), 0) + + # Should be able to load keys again + keys = self.token_utils.get_client_alg_keys(self.client) + self.assertGreater(len(keys), 0) + self.assertGreater(len(self.token_utils._rsa_key_cache), 0) + + def test_rsa_key_cache_contains_correct_key_types(self): + """Test that cached keys contain the correct cryptography key objects.""" + # Load keys into cache + client_keys = self.token_utils.get_client_alg_keys(self.client) + + # Check that cache contains proper key objects + for cache_key, key_pair in self.token_utils._rsa_key_cache.items(): + self.assertIn("private_key", key_pair) + self.assertIn("public_key", key_pair) + + # Should be actual cryptography key objects + self.assertIsInstance(key_pair["private_key"], RSAPrivateKey) + self.assertIsInstance(key_pair["public_key"], RSAPublicKey) + + # Check that client keys reference the same objects + for key_info in client_keys: + cache_key = f"rsa_key_{key_info['kid']}" + cached_pair = self.token_utils._rsa_key_cache[cache_key] + + # Should be the exact same objects (not copies) + self.assertIs(key_info["key"], cached_pair["private_key"]) + self.assertIs(key_info["public_key"], cached_pair["public_key"]) + + def test_hs256_no_caching(self): + """Test that HS256 clients don't use RSA key caching.""" + # Create HS256 client + hs256_client = create_fake_client(response_type="code") + hs256_client.jwt_alg = "HS256" + hs256_client.save() + + # Clear cache + self.token_utils._rsa_key_cache.clear() + + # Get keys for HS256 client + hs256_keys = self.token_utils.get_client_alg_keys(hs256_client) + + # Should have keys but no cache entries (HS256 doesn't use caching) + self.assertEqual(len(hs256_keys), 1) + self.assertEqual(hs256_keys[0]["algorithm"], "HS256") + + self.assertEqual(len(self.token_utils._rsa_key_cache), 0) # No RSA caching for HS256 + + # Get keys for RS256 client + rs256_keys = self.token_utils.get_client_alg_keys(self.client) + + # Now should have cache entries for RS256 + self.assertEqual(rs256_keys[0]["algorithm"], "RS256") + self.assertGreater(len(self.token_utils._rsa_key_cache), 0) diff --git a/oidc_provider/views.py b/oidc_provider/views.py index a102ab6f..d9d539bc 100644 --- a/oidc_provider/views.py +++ b/oidc_provider/views.py @@ -13,7 +13,8 @@ from urllib.parse import urlsplit from urllib.parse import urlunsplit -from Cryptodome.PublicKey import RSA +import jwt.utils +from cryptography.hazmat.primitives import serialization from django.contrib.auth.views import redirect_to_login try: @@ -34,7 +35,6 @@ from django.views.decorators.http import require_http_methods from django.views.generic import TemplateView from django.views.generic import View -from jwkest import long_to_base64 from oidc_provider import settings from oidc_provider import signals @@ -347,15 +347,21 @@ def get(self, request, *args, **kwargs): dic = dict(keys=[]) for rsakey in RSAKey.objects.all(): - public_key = RSA.importKey(rsakey.key).publickey() + # Load the private key and extract the public key components + private_key = serialization.load_pem_private_key( + rsakey.key.encode("utf-8"), password=None + ) + public_key = private_key.public_key() + public_numbers = public_key.public_numbers() + dic["keys"].append( { "kty": "RSA", "alg": "RS256", "use": "sig", "kid": rsakey.kid, - "n": long_to_base64(public_key.n), - "e": long_to_base64(public_key.e), + "n": jwt.utils.to_base64url_uint(public_numbers.n).decode("ascii"), + "e": jwt.utils.to_base64url_uint(public_numbers.e).decode("ascii"), } ) diff --git a/setup.py b/setup.py index 59de70e0..2531404f 100644 --- a/setup.py +++ b/setup.py @@ -42,9 +42,11 @@ ], test_suite="runtests.runtests", tests_require=[ - "pyjwkest>=1.3.0", + "PyJWT>=2.8.0", + "cryptography>=3.4.0", ], install_requires=[ - "pyjwkest>=1.3.0", + "PyJWT>=2.8.0", + "cryptography>=3.4.0", ], ) From 1db06d11da9e6d8c274653fa510061c79e4bb712 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Fiorentino Date: Sun, 21 Sep 2025 20:55:00 -0300 Subject: [PATCH 49/54] Update changelog.rst --- docs/sections/changelog.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/sections/changelog.rst b/docs/sections/changelog.rst index 9f412a8c..21ccb6c4 100644 --- a/docs/sections/changelog.rst +++ b/docs/sections/changelog.rst @@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file. Unreleased ========== +* Changed: Use PyJWT+cryptography instead of jwkest+Cryptodrome. * Added: Translation to Russian. * Changed: Ruff as a fast Python linter and code formatter. * Fixed: client_id sanitization to prevent database errors. From f8dbf990975bdca2263bcaf0492c2af6dde65ed1 Mon Sep 17 00:00:00 2001 From: Juani Date: Tue, 23 Sep 2025 15:14:01 -0300 Subject: [PATCH 50/54] Bump version 0.9.0 --- docs/sections/changelog.rst | 5 +++++ oidc_provider/version.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/sections/changelog.rst b/docs/sections/changelog.rst index 21ccb6c4..e0eff7b8 100644 --- a/docs/sections/changelog.rst +++ b/docs/sections/changelog.rst @@ -8,6 +8,11 @@ All notable changes to this project will be documented in this file. Unreleased ========== +0.9.0 +===== + +*2025-09-23* + * Changed: Use PyJWT+cryptography instead of jwkest+Cryptodrome. * Added: Translation to Russian. * Changed: Ruff as a fast Python linter and code formatter. diff --git a/oidc_provider/version.py b/oidc_provider/version.py index fa3ddd8c..3e2f46a3 100644 --- a/oidc_provider/version.py +++ b/oidc_provider/version.py @@ -1 +1 @@ -__version__ = "0.8.4" +__version__ = "0.9.0" From bb6b326ee0aad7cd5b3c8bc79a4dbce28f079851 Mon Sep 17 00:00:00 2001 From: Matt Cohoon Date: Tue, 30 Sep 2025 13:56:43 -0500 Subject: [PATCH 51/54] Black format everything. --- docs/conf.py | 61 +- example/app/settings.py | 2 +- example/app/urls.py | 16 +- example/app/wsgi.py | 2 +- example/manage.py | 4 +- oidc_provider/admin.py | 91 ++- oidc_provider/apps.py | 4 +- oidc_provider/lib/claims.py | 161 ++-- oidc_provider/lib/endpoints/authorize.py | 90 ++- oidc_provider/lib/endpoints/introspection.py | 87 ++- oidc_provider/lib/endpoints/token.py | 13 +- oidc_provider/lib/errors.py | 170 ++--- oidc_provider/lib/utils/authorize.py | 12 +- oidc_provider/lib/utils/common.py | 61 +- oidc_provider/lib/utils/oauth2.py | 47 +- oidc_provider/lib/utils/token.py | 20 +- .../management/commands/creatersakey.py | 10 +- oidc_provider/middleware.py | 6 +- oidc_provider/migrations/0001_initial.py | 207 ++++-- oidc_provider/migrations/0002_userconsent.py | 34 +- oidc_provider/migrations/0003_code_nonce.py | 8 +- .../migrations/0004_remove_userinfo.py | 8 +- .../migrations/0005_token_refresh_token.py | 6 +- .../migrations/0006_unique_user_client.py | 6 +- .../migrations/0007_auto_20160111_1844.py | 37 +- oidc_provider/migrations/0008_rsakey.py | 16 +- .../migrations/0009_auto_20160202_1945.py | 12 +- .../migrations/0010_code_is_authentication.py | 6 +- .../migrations/0011_client_client_type.py | 17 +- .../migrations/0012_auto_20160405_2041.py | 8 +- .../migrations/0013_auto_20160407_1912.py | 10 +- .../migrations/0014_client_jwt_alg.py | 13 +- .../migrations/0015_change_client_code.py | 87 ++- .../0016_userconsent_and_verbosenames.py | 233 +++--- .../migrations/0017_auto_20160811_1954.py | 80 +- .../0018_hybridflow_and_clientattrs.py | 70 +- .../migrations/0019_auto_20161005_1552.py | 10 +- .../0020_client__post_logout_redirect_uris.py | 13 +- .../0021_refresh_token_not_unique.py | 10 +- .../migrations/0022_auto_20170331_1626.py | 20 +- oidc_provider/migrations/0023_client_owner.py | 16 +- .../migrations/0024_auto_20180327_1959.py | 12 +- .../migrations/0025_user_field_codetoken.py | 24 +- .../0026_client_multiple_response_types.py | 66 +- .../migrations/0027_alter_rsakey_options.py | 10 +- oidc_provider/models.py | 232 +++--- oidc_provider/settings.py | 50 +- oidc_provider/tests/app/urls.py | 22 +- oidc_provider/tests/app/utils.py | 4 +- .../tests/cases/test_authorize_endpoint.py | 690 ++++++++++-------- oidc_provider/tests/cases/test_claims.py | 88 +-- oidc_provider/tests/cases/test_commands.py | 8 +- .../tests/cases/test_end_session_endpoint.py | 28 +- .../cases/test_introspection_endpoint.py | 81 +- oidc_provider/tests/cases/test_middleware.py | 38 +- .../cases/test_provider_info_endpoint.py | 17 +- oidc_provider/tests/cases/test_settings.py | 17 +- .../tests/cases/test_token_endpoint.py | 649 +++++++++------- .../tests/cases/test_userinfo_endpoint.py | 63 +- oidc_provider/tests/cases/test_utils.py | 12 +- oidc_provider/tests/settings.py | 78 +- oidc_provider/urls.py | 10 +- oidc_provider/views.py | 94 ++- setup.py | 53 +- 64 files changed, 2415 insertions(+), 1715 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index f17fbd1e..234ea1fb 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -31,7 +31,7 @@ extensions = [] # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: @@ -41,28 +41,28 @@ # source_encoding = 'utf-8-sig' # The master toctree document. -master_doc = 'index' +master_doc = "index" # General information about the project. -project = u'django-oidc-provider' -copyright = u'2025, Juan Ignacio Fiorentino' -author = u'Juan Ignacio Fiorentino' +project = "django-oidc-provider" +copyright = "2025, Juan Ignacio Fiorentino" +author = "Juan Ignacio Fiorentino" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. -version = u'0.8' +version = "0.8" # The full version, including alpha/beta/rc tags. -release = u'0.8' +release = "0.8" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = 'en' +language = "en" # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: @@ -72,7 +72,7 @@ # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. -exclude_patterns = ['_build'] +exclude_patterns = ["_build"] # The reST default role (used for this markup: `text`) to use for all # documents. @@ -90,7 +90,7 @@ # show_authors = False # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = "sphinx" # A list of ignored prefixes for module index sorting. # modindex_common_prefix = [] @@ -106,7 +106,7 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'sphinx_rtd_theme' +html_theme = "sphinx_rtd_theme" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the @@ -135,7 +135,7 @@ # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = ["_static"] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied @@ -198,20 +198,17 @@ # html_search_scorer = 'scorer.js' # Output file base name for HTML help builder. -htmlhelp_basename = 'django-oidc-providerdoc' +htmlhelp_basename = "django-oidc-providerdoc" # -- Options for LaTeX output --------------------------------------------- latex_elements = { # The paper size ('letterpaper' or 'a4paper'). # 'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). # 'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. # 'preamble': '', - # Latex figure (float) alignment # 'figure_align': 'htbp', } @@ -220,8 +217,13 @@ # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, 'django-oidc-provider.tex', u'django-oidc-provider Documentation', - u'Juan Ignacio Fiorentino', 'manual'), + ( + master_doc, + "django-oidc-provider.tex", + "django-oidc-provider Documentation", + "Juan Ignacio Fiorentino", + "manual", + ), ] # The name of an image file (relative to this directory) to place at the top of @@ -250,8 +252,13 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - (master_doc, 'django-oidc-provider', u'django-oidc-provider Documentation', - [author], 1) + ( + master_doc, + "django-oidc-provider", + "django-oidc-provider Documentation", + [author], + 1, + ) ] # If true, show URL addresses after external links. @@ -264,9 +271,15 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - (master_doc, 'django-oidc-provider', u'django-oidc-provider Documentation', - author, 'django-oidc-provider', 'One line description of project.', - 'Miscellaneous'), + ( + master_doc, + "django-oidc-provider", + "django-oidc-provider Documentation", + author, + "django-oidc-provider", + "One line description of project.", + "Miscellaneous", + ), ] # Documents to append as an appendix to all manuals. @@ -328,7 +341,7 @@ # epub_post_files = [] # A list of files that should not be packed into the epub file. -epub_exclude_files = ['search.html'] +epub_exclude_files = ["search.html"] # The depth of the table of contents in toc.ncx. # epub_tocdepth = 3 diff --git a/example/app/settings.py b/example/app/settings.py index 23df2801..519d93b1 100644 --- a/example/app/settings.py +++ b/example/app/settings.py @@ -3,7 +3,7 @@ BASE_DIR = os.path.dirname(os.path.dirname(__file__)) -DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" SECRET_KEY = "c14d549c574e4d8cf162404ef0b04598" diff --git a/example/app/urls.py b/example/app/urls.py index 93e8b895..0926aceb 100644 --- a/example/app/urls.py +++ b/example/app/urls.py @@ -5,9 +5,17 @@ urlpatterns = [ - re_path(r"^$", TemplateView.as_view(template_name='home.html'), name='home'), - re_path(r"^accounts/login/$", auth_views.LoginView.as_view(template_name='login.html'), name='login'), # noqa - re_path(r"^accounts/logout/$", auth_views.LogoutView.as_view(next_page='/'), name='logout'), - re_path(r"^", include('oidc_provider.urls', namespace='oidc_provider')), + re_path(r"^$", TemplateView.as_view(template_name="home.html"), name="home"), + re_path( + r"^accounts/login/$", + auth_views.LoginView.as_view(template_name="login.html"), + name="login", + ), # noqa + re_path( + r"^accounts/logout/$", + auth_views.LogoutView.as_view(next_page="/"), + name="logout", + ), + re_path(r"^", include("oidc_provider.urls", namespace="oidc_provider")), re_path(r"^admin/", admin.site.urls), ] diff --git a/example/app/wsgi.py b/example/app/wsgi.py index 7c75d281..f66d49a7 100644 --- a/example/app/wsgi.py +++ b/example/app/wsgi.py @@ -3,6 +3,6 @@ from django.core.wsgi import get_wsgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings") application = get_wsgi_application() diff --git a/example/manage.py b/example/manage.py index 7adfe491..72238252 100755 --- a/example/manage.py +++ b/example/manage.py @@ -2,8 +2,8 @@ import os import sys -if __name__ == '__main__': - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings") from django.core.management import execute_from_command_line diff --git a/oidc_provider/admin.py b/oidc_provider/admin.py index 86a90fc7..cb62feeb 100644 --- a/oidc_provider/admin.py +++ b/oidc_provider/admin.py @@ -17,30 +17,34 @@ class Meta: def __init__(self, *args, **kwargs): super(ClientForm, self).__init__(*args, **kwargs) - self.fields['client_id'].required = False - self.fields['client_id'].widget.attrs['disabled'] = 'true' - self.fields['client_secret'].required = False - self.fields['client_secret'].widget.attrs['disabled'] = 'true' + self.fields["client_id"].required = False + self.fields["client_id"].widget.attrs["disabled"] = "true" + self.fields["client_secret"].required = False + self.fields["client_secret"].widget.attrs["disabled"] = "true" def clean_client_id(self): - instance = getattr(self, 'instance', None) + instance = getattr(self, "instance", None) if instance and instance.pk: return instance.client_id else: return str(randint(1, 999999)).zfill(6) def clean_client_secret(self): - instance = getattr(self, 'instance', None) + instance = getattr(self, "instance", None) - secret = '' + secret = "" if instance and instance.pk: - if (self.cleaned_data['client_type'] == 'confidential') and not instance.client_secret: + if ( + self.cleaned_data["client_type"] == "confidential" + ) and not instance.client_secret: secret = sha224(uuid4().hex.encode()).hexdigest() - elif (self.cleaned_data['client_type'] == 'confidential') and instance.client_secret: + elif ( + self.cleaned_data["client_type"] == "confidential" + ) and instance.client_secret: secret = instance.client_secret else: - if (self.cleaned_data['client_type'] == 'confidential'): + if self.cleaned_data["client_type"] == "confidential": secret = sha224(uuid4().hex.encode()).hexdigest() return secret @@ -50,32 +54,57 @@ def clean_client_secret(self): class ClientAdmin(admin.ModelAdmin): fieldsets = [ - [_(u''), { - 'fields': ( - 'name', 'owner', 'client_type', 'response_types', '_redirect_uris', 'jwt_alg', - 'require_consent', 'reuse_consent'), - }], - [_(u'Credentials'), { - 'fields': ('client_id', 'client_secret', '_scope'), - }], - [_(u'Information'), { - 'fields': ('contact_email', 'website_url', 'terms_url', 'logo', 'date_created'), - }], - [_(u'Session Management'), { - 'fields': ('_post_logout_redirect_uris',), - }], + [ + _(""), + { + "fields": ( + "name", + "owner", + "client_type", + "response_types", + "_redirect_uris", + "jwt_alg", + "require_consent", + "reuse_consent", + ), + }, + ], + [ + _("Credentials"), + { + "fields": ("client_id", "client_secret", "_scope"), + }, + ], + [ + _("Information"), + { + "fields": ( + "contact_email", + "website_url", + "terms_url", + "logo", + "date_created", + ), + }, + ], + [ + _("Session Management"), + { + "fields": ("_post_logout_redirect_uris",), + }, + ], ] form = ClientForm - list_display = ['name', 'client_id', 'response_type_descriptions', 'date_created'] - readonly_fields = ['date_created'] - search_fields = ['name'] - raw_id_fields = ['owner'] + list_display = ["name", "client_id", "response_type_descriptions", "date_created"] + readonly_fields = ["date_created"] + search_fields = ["name"] + raw_id_fields = ["owner"] @admin.register(Code) class CodeAdmin(admin.ModelAdmin): - raw_id_fields = ['user'] + raw_id_fields = ["user"] def has_add_permission(self, request): return False @@ -84,7 +113,7 @@ def has_add_permission(self, request): @admin.register(Token) class TokenAdmin(admin.ModelAdmin): - raw_id_fields = ['user'] + raw_id_fields = ["user"] def has_add_permission(self, request): return False @@ -93,4 +122,4 @@ def has_add_permission(self, request): @admin.register(RSAKey) class RSAKeyAdmin(admin.ModelAdmin): - readonly_fields = ['kid'] + readonly_fields = ["kid"] diff --git a/oidc_provider/apps.py b/oidc_provider/apps.py index 9e7ba201..336c27e6 100644 --- a/oidc_provider/apps.py +++ b/oidc_provider/apps.py @@ -3,5 +3,5 @@ class OIDCProviderConfig(AppConfig): - name = 'oidc_provider' - verbose_name = u'OpenID Connect Provider' + name = "oidc_provider" + verbose_name = "OpenID Connect Provider" diff --git a/oidc_provider/lib/claims.py b/oidc_provider/lib/claims.py index c641e5b5..a60509fc 100644 --- a/oidc_provider/lib/claims.py +++ b/oidc_provider/lib/claims.py @@ -6,31 +6,31 @@ STANDARD_CLAIMS = { - 'name': '', - 'given_name': '', - 'family_name': '', - 'middle_name': '', - 'nickname': '', - 'preferred_username': '', - 'profile': '', - 'picture': '', - 'website': '', - 'gender': '', - 'birthdate': '', - 'zoneinfo': '', - 'locale': '', - 'updated_at': '', - 'email': '', - 'email_verified': '', - 'phone_number': '', - 'phone_number_verified': '', - 'address': { - 'formatted': '', - 'street_address': '', - 'locality': '', - 'region': '', - 'postal_code': '', - 'country': '', + "name": "", + "given_name": "", + "family_name": "", + "middle_name": "", + "nickname": "", + "preferred_username": "", + "profile": "", + "picture": "", + "website": "", + "gender": "", + "birthdate": "", + "zoneinfo": "", + "locale": "", + "updated_at": "", + "email": "", + "email_verified": "", + "phone_number": "", + "phone_number_verified": "", + "address": { + "formatted": "", + "street_address": "", + "locality": "", + "region": "", + "postal_code": "", + "country": "", }, } @@ -40,7 +40,9 @@ class ScopeClaims(object): def __init__(self, token): self.user = token.user claims = copy.deepcopy(STANDARD_CLAIMS) - self.userinfo = settings.get('OIDC_USERINFO', import_str=True)(claims, self.user) + self.userinfo = settings.get("OIDC_USERINFO", import_str=True)( + claims, self.user + ) self.scopes = token.scope self.client = token.client @@ -55,7 +57,7 @@ def create_response_dic(self): for scope in self.scopes: if scope in self._scopes_registered(): - dic.update(getattr(self, 'scope_' + scope)()) + dic.update(getattr(self, "scope_" + scope)()) dic = self._clean_dic(dic) @@ -69,8 +71,8 @@ def _scopes_registered(self): scopes = [] for name in dir(self.__class__): - if name.startswith('scope_'): - scope = name.split('scope_')[1] + if name.startswith("scope_"): + scope = name.split("scope_")[1] scopes.append(scope) return scopes @@ -82,7 +84,7 @@ def _clean_dic(self, dic): aux_dic = dic.copy() for key, value in iter(dic.items()): - if value is None or value == '': + if value is None or value == "": del aux_dic[key] elif type(value) is dict: cleaned_dict = self._clean_dic(value) @@ -99,15 +101,17 @@ def get_scopes_info(cls, scopes=None): scopes_info = [] for name in dir(cls): - if name.startswith('info_'): - scope_name = name.split('info_')[1] + if name.startswith("info_"): + scope_name = name.split("info_")[1] if scope_name in scopes: touple_info = getattr(cls, name) - scopes_info.append({ - 'scope': scope_name, - 'name': touple_info[0], - 'description': touple_info[1], - }) + scopes_info.append( + { + "scope": scope_name, + "name": touple_info[0], + "description": touple_info[1], + } + ) return scopes_info @@ -119,73 +123,84 @@ class StandardScopeClaims(ScopeClaims): """ info_profile = ( - _(u'Basic profile'), - _(u'Access to your basic information. Includes names, gender, birthdate ' - 'and other information.'), + _("Basic profile"), + _( + "Access to your basic information. Includes names, gender, birthdate " + "and other information." + ), ) def scope_profile(self): dic = { - 'name': self.userinfo.get('name'), - 'given_name': (self.userinfo.get('given_name') or - getattr(self.user, 'first_name', None)), - 'family_name': (self.userinfo.get('family_name') or - getattr(self.user, 'last_name', None)), - 'middle_name': self.userinfo.get('middle_name'), - 'nickname': self.userinfo.get('nickname') or getattr(self.user, 'username', None), - 'preferred_username': self.userinfo.get('preferred_username'), - 'profile': self.userinfo.get('profile'), - 'picture': self.userinfo.get('picture'), - 'website': self.userinfo.get('website'), - 'gender': self.userinfo.get('gender'), - 'birthdate': self.userinfo.get('birthdate'), - 'zoneinfo': self.userinfo.get('zoneinfo'), - 'locale': self.userinfo.get('locale'), - 'updated_at': self.userinfo.get('updated_at'), + "name": self.userinfo.get("name"), + "given_name": ( + self.userinfo.get("given_name") + or getattr(self.user, "first_name", None) + ), + "family_name": ( + self.userinfo.get("family_name") + or getattr(self.user, "last_name", None) + ), + "middle_name": self.userinfo.get("middle_name"), + "nickname": self.userinfo.get("nickname") + or getattr(self.user, "username", None), + "preferred_username": self.userinfo.get("preferred_username"), + "profile": self.userinfo.get("profile"), + "picture": self.userinfo.get("picture"), + "website": self.userinfo.get("website"), + "gender": self.userinfo.get("gender"), + "birthdate": self.userinfo.get("birthdate"), + "zoneinfo": self.userinfo.get("zoneinfo"), + "locale": self.userinfo.get("locale"), + "updated_at": self.userinfo.get("updated_at"), } return dic info_email = ( - _(u'Email'), - _(u'Access to your email address.'), + _("Email"), + _("Access to your email address."), ) def scope_email(self): dic = { - 'email': self.userinfo.get('email') or getattr(self.user, 'email', None), - 'email_verified': self.userinfo.get('email_verified'), + "email": self.userinfo.get("email") or getattr(self.user, "email", None), + "email_verified": self.userinfo.get("email_verified"), } return dic info_phone = ( - _(u'Phone number'), - _(u'Access to your phone number.'), + _("Phone number"), + _("Access to your phone number."), ) def scope_phone(self): dic = { - 'phone_number': self.userinfo.get('phone_number'), - 'phone_number_verified': self.userinfo.get('phone_number_verified'), + "phone_number": self.userinfo.get("phone_number"), + "phone_number_verified": self.userinfo.get("phone_number_verified"), } return dic info_address = ( - _(u'Address information'), - _(u'Access to your address. Includes country, locality, street and other information.'), + _("Address information"), + _( + "Access to your address. Includes country, locality, street and other information." + ), ) def scope_address(self): dic = { - 'address': { - 'formatted': self.userinfo.get('address', {}).get('formatted'), - 'street_address': self.userinfo.get('address', {}).get('street_address'), - 'locality': self.userinfo.get('address', {}).get('locality'), - 'region': self.userinfo.get('address', {}).get('region'), - 'postal_code': self.userinfo.get('address', {}).get('postal_code'), - 'country': self.userinfo.get('address', {}).get('country'), + "address": { + "formatted": self.userinfo.get("address", {}).get("formatted"), + "street_address": self.userinfo.get("address", {}).get( + "street_address" + ), + "locality": self.userinfo.get("address", {}).get("locality"), + "region": self.userinfo.get("address", {}).get("region"), + "postal_code": self.userinfo.get("address", {}).get("postal_code"), + "country": self.userinfo.get("address", {}).get("country"), } } diff --git a/oidc_provider/lib/endpoints/authorize.py b/oidc_provider/lib/endpoints/authorize.py index a4f6d0a7..ad1aab03 100644 --- a/oidc_provider/lib/endpoints/authorize.py +++ b/oidc_provider/lib/endpoints/authorize.py @@ -47,7 +47,11 @@ def __init__(self, request): self.grant_type = "authorization_code" elif self.params["response_type"] in ["id_token", "id_token token", "token"]: self.grant_type = "implicit" - elif self.params["response_type"] in ["code token", "code id_token", "code id_token token"]: + elif self.params["response_type"] in [ + "code token", + "code id_token", + "code id_token token", + ]: self.grant_type = "hybrid" else: self.grant_type = None @@ -64,7 +68,9 @@ def _extract_params(self): """ # Because in this endpoint we handle both GET # and POST request. - query_dict = self.request.POST if self.request.method == "POST" else self.request.GET + query_dict = ( + self.request.POST if self.request.method == "POST" else self.request.GET + ) self.params["client_id"] = query_dict.get("client_id", "") self.params["redirect_uri"] = query_dict.get("redirect_uri", "") @@ -78,14 +84,20 @@ def _extract_params(self): ) self.params["code_challenge"] = query_dict.get("code_challenge", "") - self.params["code_challenge_method"] = query_dict.get("code_challenge_method", "") + self.params["code_challenge_method"] = query_dict.get( + "code_challenge_method", "" + ) def validate_params(self): # Client validation. try: - self.client = self.client_class.objects.get(client_id=self.params["client_id"]) + self.client = self.client_class.objects.get( + client_id=self.params["client_id"] + ) except Client.DoesNotExist: - logger.debug("[Authorize] Invalid client identifier: %s", self.params["client_id"]) + logger.debug( + "[Authorize] Invalid client identifier: %s", self.params["client_id"] + ) raise ClientIdError() # Redirect URI validation. @@ -93,14 +105,20 @@ def validate_params(self): logger.debug("[Authorize] Missing redirect uri.") raise RedirectUriError() if self.params["redirect_uri"] not in self.client.redirect_uris: - logger.debug("[Authorize] Invalid redirect uri: %s", self.params["redirect_uri"]) + logger.debug( + "[Authorize] Invalid redirect uri: %s", self.params["redirect_uri"] + ) raise RedirectUriError() # Grant type validation. if not self.grant_type: - logger.debug("[Authorize] Invalid response type: %s", self.params["response_type"]) + logger.debug( + "[Authorize] Invalid response type: %s", self.params["response_type"] + ) raise AuthorizeError( - self.params["redirect_uri"], "unsupported_response_type", self.grant_type + self.params["redirect_uri"], + "unsupported_response_type", + self.grant_type, ) if not self.is_authentication and ( @@ -108,18 +126,28 @@ def validate_params(self): or self.params["response_type"] in ["id_token", "id_token token"] ): logger.debug("[Authorize] Missing openid scope.") - raise AuthorizeError(self.params["redirect_uri"], "invalid_scope", self.grant_type) + raise AuthorizeError( + self.params["redirect_uri"], "invalid_scope", self.grant_type + ) # Nonce parameter validation. - if self.is_authentication and self.grant_type == "implicit" and not self.params["nonce"]: - raise AuthorizeError(self.params["redirect_uri"], "invalid_request", self.grant_type) + if ( + self.is_authentication + and self.grant_type == "implicit" + and not self.params["nonce"] + ): + raise AuthorizeError( + self.params["redirect_uri"], "invalid_request", self.grant_type + ) # Response type parameter validation. if ( self.is_authentication and self.params["response_type"] not in self.client.response_type_values() ): - raise AuthorizeError(self.params["redirect_uri"], "invalid_request", self.grant_type) + raise AuthorizeError( + self.params["redirect_uri"], "invalid_request", self.grant_type + ) # PKCE validation of the transformation method. if self.params["code_challenge"]: @@ -161,7 +189,9 @@ def create_response_uri(self): code.save() if self.grant_type == "authorization_code": query_params["code"] = code.code - query_params["state"] = self.params["state"] if self.params["state"] else "" + query_params["state"] = ( + self.params["state"] if self.params["state"] else "" + ) elif self.grant_type in ["implicit", "hybrid"]: token = self.create_token() @@ -193,9 +223,7 @@ def create_response_uri(self): ) id_token_dic = create_id_token_hook(**kwargs) - encode_id_token = settings.import_hook( - "OIDC_IDTOKEN_ENCODE_HOOK" - ) + encode_id_token = settings.import_hook("OIDC_IDTOKEN_ENCODE_HOOK") # Check if response_type must include id_token in the response. if self.params["response_type"] in [ @@ -204,7 +232,9 @@ def create_response_uri(self): "code id_token", "code id_token token", ]: - query_fragment["id_token"] = encode_id_token(id_token_dic, self.client) + query_fragment["id_token"] = encode_id_token( + id_token_dic, self.client + ) else: id_token_dic = {} @@ -220,7 +250,9 @@ def create_response_uri(self): query_fragment["expires_in"] = settings.get("OIDC_TOKEN_EXPIRE") - query_fragment["state"] = self.params["state"] if self.params["state"] else "" + query_fragment["state"] = ( + self.params["state"] if self.params["state"] else "" + ) if settings.get("OIDC_SESSION_MANAGEMENT_ENABLE"): # Generate client origin URI from the redirect_uri param. @@ -249,8 +281,12 @@ def create_response_uri(self): query_fragment["session_state"] = session_state except Exception as error: - logger.exception("[Authorize] Error when trying to create response uri: %s", error) - raise AuthorizeError(self.params["redirect_uri"], "server_error", self.grant_type) + logger.exception( + "[Authorize] Error when trying to create response uri: %s", error + ) + raise AuthorizeError( + self.params["redirect_uri"], "server_error", self.grant_type + ) uri = uri._replace( query=urlencode(query_params, doseq=True), @@ -266,7 +302,9 @@ def set_client_user_consent(self): Return None. """ date_given = timezone.now() - expires_at = date_given + timedelta(days=settings.get("OIDC_SKIP_CONSENT_EXPIRE")) + expires_at = date_given + timedelta( + days=settings.get("OIDC_SKIP_CONSENT_EXPIRE") + ) uc, created = UserConsent.objects.get_or_create( user=self.request.user, @@ -294,7 +332,9 @@ def client_has_user_consent(self): value = False try: uc = UserConsent.objects.get(user=self.request.user, client=self.client) - if (set(self.params["scope"]).issubset(uc.scope)) and not (uc.has_expired()): + if (set(self.params["scope"]).issubset(uc.scope)) and not ( + uc.has_expired() + ): value = True except UserConsent.DoesNotExist: pass @@ -314,9 +354,9 @@ def get_scopes_information(self): """ scopes = StandardScopeClaims.get_scopes_info(self.params["scope"]) if settings.get("OIDC_EXTRA_SCOPE_CLAIMS"): - scopes_extra = settings.get("OIDC_EXTRA_SCOPE_CLAIMS", import_str=True).get_scopes_info( - self.params["scope"] - ) + scopes_extra = settings.get( + "OIDC_EXTRA_SCOPE_CLAIMS", import_str=True + ).get_scopes_info(self.params["scope"]) for index_extra, scope_extra in enumerate(scopes_extra): for index, scope in enumerate(scopes[:]): if scope_extra["scope"] == scope["scope"]: diff --git a/oidc_provider/lib/endpoints/introspection.py b/oidc_provider/lib/endpoints/introspection.py index c1e8a8e6..b0aacbd1 100644 --- a/oidc_provider/lib/endpoints/introspection.py +++ b/oidc_provider/lib/endpoints/introspection.py @@ -10,7 +10,7 @@ logger = logging.getLogger(__name__) -INTROSPECTION_SCOPE = 'token_introspection' +INTROSPECTION_SCOPE = "token_introspection" class TokenIntrospectionEndpoint(object): @@ -25,72 +25,87 @@ def __init__(self, request): def _extract_params(self): # Introspection only supports POST requests - self.params['token'] = self.request.POST.get('token') + self.params["token"] = self.request.POST.get("token") client_id, client_secret = extract_client_auth(self.request) - self.params['client_id'] = client_id - self.params['client_secret'] = client_secret + self.params["client_id"] = client_id + self.params["client_secret"] = client_secret def validate_params(self): - if not (self.params['client_id'] and self.params['client_secret']): - logger.debug('[Introspection] No client credentials provided') + if not (self.params["client_id"] and self.params["client_secret"]): + logger.debug("[Introspection] No client credentials provided") raise TokenIntrospectionError() - if not self.params['token']: - logger.debug('[Introspection] No token provided') + if not self.params["token"]: + logger.debug("[Introspection] No token provided") raise TokenIntrospectionError() try: - self.token = Token.objects.get(access_token=self.params['token']) + self.token = Token.objects.get(access_token=self.params["token"]) except Token.DoesNotExist: - logger.debug('[Introspection] Token does not exist: %s', self.params['token']) + logger.debug( + "[Introspection] Token does not exist: %s", self.params["token"] + ) raise TokenIntrospectionError() if self.token.has_expired(): - logger.debug('[Introspection] Token is not valid: %s', self.params['token']) + logger.debug("[Introspection] Token is not valid: %s", self.params["token"]) raise TokenIntrospectionError() try: self.client = Client.objects.get( - client_id=self.params['client_id'], - client_secret=self.params['client_secret']) + client_id=self.params["client_id"], + client_secret=self.params["client_secret"], + ) except Client.DoesNotExist: - logger.debug('[Introspection] No valid client for id: %s', - self.params['client_id']) + logger.debug( + "[Introspection] No valid client for id: %s", self.params["client_id"] + ) raise TokenIntrospectionError() if INTROSPECTION_SCOPE not in self.client.scope: - logger.debug('[Introspection] Client %s does not have introspection scope', - self.params['client_id']) + logger.debug( + "[Introspection] Client %s does not have introspection scope", + self.params["client_id"], + ) raise TokenIntrospectionError() self.id_token = self.token.id_token - if settings.get('OIDC_INTROSPECTION_VALIDATE_AUDIENCE_SCOPE'): + if settings.get("OIDC_INTROSPECTION_VALIDATE_AUDIENCE_SCOPE"): if not self.token.id_token: - logger.debug('[Introspection] Token not an authentication token: %s', - self.params['token']) + logger.debug( + "[Introspection] Token not an authentication token: %s", + self.params["token"], + ) raise TokenIntrospectionError() - audience = self.token.id_token.get('aud') + audience = self.token.id_token.get("aud") if not audience: - logger.debug('[Introspection] No audience found for token: %s', - self.params['token']) + logger.debug( + "[Introspection] No audience found for token: %s", + self.params["token"], + ) raise TokenIntrospectionError() if audience not in self.client.scope: - logger.debug('[Introspection] Client %s does not audience scope %s', - self.params['client_id'], audience) + logger.debug( + "[Introspection] Client %s does not audience scope %s", + self.params["client_id"], + audience, + ) raise TokenIntrospectionError() def create_response_dic(self): response_dic = {} if self.id_token: - for k in ('aud', 'sub', 'exp', 'iat', 'iss'): + for k in ("aud", "sub", "exp", "iat", "iss"): response_dic[k] = self.id_token[k] - response_dic['active'] = True - response_dic['client_id'] = self.token.client.client_id - if settings.get('OIDC_INTROSPECTION_RESPONSE_SCOPE_ENABLE'): - response_dic['scope'] = ' '.join(self.token.scope) - response_dic = run_processing_hook(response_dic, - 'OIDC_INTROSPECTION_PROCESSING_HOOK', - client=self.client, - id_token=self.id_token) + response_dic["active"] = True + response_dic["client_id"] = self.token.client.client_id + if settings.get("OIDC_INTROSPECTION_RESPONSE_SCOPE_ENABLE"): + response_dic["scope"] = " ".join(self.token.scope) + response_dic = run_processing_hook( + response_dic, + "OIDC_INTROSPECTION_PROCESSING_HOOK", + client=self.client, + id_token=self.id_token, + ) return response_dic @@ -100,7 +115,7 @@ def response(cls, dic, status=200): Create and return a response object. """ response = JsonResponse(dic, status=status) - response['Cache-Control'] = 'no-store' - response['Pragma'] = 'no-cache' + response["Cache-Control"] = "no-store" + response["Pragma"] = "no-cache" return response diff --git a/oidc_provider/lib/endpoints/token.py b/oidc_provider/lib/endpoints/token.py index fdfca25d..81a814bd 100644 --- a/oidc_provider/lib/endpoints/token.py +++ b/oidc_provider/lib/endpoints/token.py @@ -27,7 +27,7 @@ def __init__(self, request): self._extract_params() def _encode_id_token(self, *args): - return settings.import_hook('OIDC_IDTOKEN_ENCODE_HOOK')(*args) + return settings.import_hook("OIDC_IDTOKEN_ENCODE_HOOK")(*args) def _extract_params(self): client_id, client_secret = extract_client_auth(self.request) @@ -120,7 +120,9 @@ def validate_params(self): if self.code.code_challenge_method == "S256": new_code_challenge = ( urlsafe_b64encode( - hashlib.sha256(self.params["code_verifier"].encode("ascii")).digest() + hashlib.sha256( + self.params["code_verifier"].encode("ascii") + ).digest() ) .decode("utf-8") .replace("=", "") @@ -147,7 +149,9 @@ def validate_params(self): auth_args = () user = authenticate( - *auth_args, username=self.params["username"], password=self.params["password"] + *auth_args, + username=self.params["username"], + password=self.params["password"] ) if not user: @@ -167,7 +171,8 @@ def validate_params(self): except Token.DoesNotExist: logger.info( - "[Token] Refresh token does not exist: %s", self.params["refresh_token"] + "[Token] Refresh token does not exist: %s", + self.params["refresh_token"], ) raise TokenError("invalid_grant") elif self.params["grant_type"] == "client_credentials": diff --git a/oidc_provider/lib/errors.py b/oidc_provider/lib/errors.py index 318fb969..5ebf07d7 100644 --- a/oidc_provider/lib/errors.py +++ b/oidc_provider/lib/errors.py @@ -6,15 +6,17 @@ class RedirectUriError(Exception): - error = 'Redirect URI Error' - description = 'The request fails due to a missing, invalid, or mismatching' \ - ' redirection URI (redirect_uri).' + error = "Redirect URI Error" + description = ( + "The request fails due to a missing, invalid, or mismatching" + " redirection URI (redirect_uri)." + ) class ClientIdError(Exception): - error = 'Client ID Error' - description = 'The client identifier (client_id) is missing or invalid.' + error = "Client ID Error" + description = "The client identifier (client_id) is missing or invalid." class UserAuthError(Exception): @@ -22,13 +24,14 @@ class UserAuthError(Exception): Specific to the Resource Owner Password Credentials flow when the Resource Owners credentials are not valid. """ - error = 'access_denied' - description = 'The resource owner or authorization server denied the request.' + + error = "access_denied" + description = "The resource owner or authorization server denied the request." def create_dict(self): return { - 'error': self.error, - 'error_description': self.description, + "error": self.error, + "error_description": self.description, } @@ -38,6 +41,7 @@ class TokenIntrospectionError(Exception): to an "active: false" response, as per the spec. See https://tools.ietf.org/html/rfc7662 """ + pass @@ -46,56 +50,39 @@ class AuthorizeError(Exception): _errors = { # Oauth2 errors. # https://tools.ietf.org/html/rfc6749#section-4.1.2.1 - 'invalid_request': 'The request is otherwise malformed', - - 'unauthorized_client': 'The client is not authorized to request an ' - 'authorization code using this method', - - 'access_denied': 'The resource owner or authorization server denied ' - 'the request', - - 'unsupported_response_type': 'The authorization server does not ' - 'support obtaining an authorization code ' - 'using this method', - - 'invalid_scope': 'The requested scope is invalid, unknown, or ' - 'malformed', - - 'server_error': 'The authorization server encountered an error', - - 'temporarily_unavailable': 'The authorization server is currently ' - 'unable to handle the request due to a ' - 'temporary overloading or maintenance of ' - 'the server', - + "invalid_request": "The request is otherwise malformed", + "unauthorized_client": "The client is not authorized to request an " + "authorization code using this method", + "access_denied": "The resource owner or authorization server denied " + "the request", + "unsupported_response_type": "The authorization server does not " + "support obtaining an authorization code " + "using this method", + "invalid_scope": "The requested scope is invalid, unknown, or " "malformed", + "server_error": "The authorization server encountered an error", + "temporarily_unavailable": "The authorization server is currently " + "unable to handle the request due to a " + "temporary overloading or maintenance of " + "the server", # OpenID errors. # http://openid.net/specs/openid-connect-core-1_0.html#AuthError - 'interaction_required': 'The Authorization Server requires End-User ' - 'interaction of some form to proceed', - - 'login_required': 'The Authorization Server requires End-User ' - 'authentication', - - 'account_selection_required': 'The End-User is required to select a ' - 'session at the Authorization Server', - - 'consent_required': 'The Authorization Server requires End-User' - 'consent', - - 'invalid_request_uri': 'The request_uri in the Authorization Request ' - 'returns an error or contains invalid data', - - 'invalid_request_object': 'The request parameter contains an invalid ' - 'Request Object', - - 'request_not_supported': 'The provider does not support use of the ' - 'request parameter', - - 'request_uri_not_supported': 'The provider does not support use of the ' - 'request_uri parameter', - - 'registration_not_supported': 'The provider does not support use of ' - 'the registration parameter', + "interaction_required": "The Authorization Server requires End-User " + "interaction of some form to proceed", + "login_required": "The Authorization Server requires End-User " + "authentication", + "account_selection_required": "The End-User is required to select a " + "session at the Authorization Server", + "consent_required": "The Authorization Server requires End-User" "consent", + "invalid_request_uri": "The request_uri in the Authorization Request " + "returns an error or contains invalid data", + "invalid_request_object": "The request parameter contains an invalid " + "Request Object", + "request_not_supported": "The provider does not support use of the " + "request parameter", + "request_uri_not_supported": "The provider does not support use of the " + "request_uri parameter", + "registration_not_supported": "The provider does not support use of " + "the registration parameter", } def __init__(self, redirect_uri, error, grant_type): @@ -109,16 +96,14 @@ def create_uri(self, redirect_uri, state): # See: # http://openid.net/specs/openid-connect-core-1_0.html#ImplicitAuthError - hash_or_question = '#' if self.grant_type == 'implicit' else '?' + hash_or_question = "#" if self.grant_type == "implicit" else "?" - uri = '{0}{1}error={2}&error_description={3}'.format( - redirect_uri, - hash_or_question, - self.error, - description) + uri = "{0}{1}error={2}&error_description={3}".format( + redirect_uri, hash_or_question, self.error, description + ) # Add state if present. - uri = uri + ('&state={0}'.format(state) if state else '') + uri = uri + ("&state={0}".format(state) if state else "") return uri @@ -130,25 +115,20 @@ class TokenError(Exception): """ _errors = { - 'invalid_request': 'The request is otherwise malformed', - - 'invalid_client': 'Client authentication failed (e.g., unknown client, ' - 'no client authentication included, or unsupported ' - 'authentication method)', - - 'invalid_grant': 'The provided authorization grant or refresh token is ' - 'invalid, expired, revoked, does not match the ' - 'redirection URI used in the authorization request, ' - 'or was issued to another client', - - 'unauthorized_client': 'The authenticated client is not authorized to ' - 'use this authorization grant type', - - 'unsupported_grant_type': 'The authorization grant type is not ' - 'supported by the authorization server', - - 'invalid_scope': 'The requested scope is invalid, unknown, malformed, ' - 'or exceeds the scope granted by the resource owner', + "invalid_request": "The request is otherwise malformed", + "invalid_client": "Client authentication failed (e.g., unknown client, " + "no client authentication included, or unsupported " + "authentication method)", + "invalid_grant": "The provided authorization grant or refresh token is " + "invalid, expired, revoked, does not match the " + "redirection URI used in the authorization request, " + "or was issued to another client", + "unauthorized_client": "The authenticated client is not authorized to " + "use this authorization grant type", + "unsupported_grant_type": "The authorization grant type is not " + "supported by the authorization server", + "invalid_scope": "The requested scope is invalid, unknown, malformed, " + "or exceeds the scope granted by the resource owner", } def __init__(self, error): @@ -157,8 +137,8 @@ def __init__(self, error): def create_dict(self): dic = { - 'error': self.error, - 'error_description': self.description, + "error": self.error, + "error_description": self.description, } return dic @@ -171,21 +151,21 @@ class BearerTokenError(Exception): """ _errors = { - 'invalid_request': ( - 'The request is otherwise malformed', 400 - ), - 'invalid_token': ( - 'The access token provided is expired, revoked, malformed, ' - 'or invalid for other reasons', 401 + "invalid_request": ("The request is otherwise malformed", 400), + "invalid_token": ( + "The access token provided is expired, revoked, malformed, " + "or invalid for other reasons", + 401, ), - 'insufficient_scope': ( - 'The request requires higher privileges than provided by ' - 'the access token', 403 + "insufficient_scope": ( + "The request requires higher privileges than provided by " + "the access token", + 403, ), } def __init__(self, code): self.code = code - error_tuple = self._errors.get(code, ('', '')) + error_tuple = self._errors.get(code, ("", "")) self.description = error_tuple[0] self.status = error_tuple[1] diff --git a/oidc_provider/lib/utils/authorize.py b/oidc_provider/lib/utils/authorize.py index 006c9cc0..a87ff224 100644 --- a/oidc_provider/lib/utils/authorize.py +++ b/oidc_provider/lib/utils/authorize.py @@ -11,11 +11,11 @@ def strip_prompt_login(path): """ uri = urlsplit(path) query_params = parse_qs(uri.query) - prompt_list = query_params.get('prompt', '')[0].split() - if 'login' in prompt_list: - prompt_list.remove('login') - query_params['prompt'] = ' '.join(prompt_list) - if not query_params['prompt']: - del query_params['prompt'] + prompt_list = query_params.get("prompt", "")[0].split() + if "login" in prompt_list: + prompt_list.remove("login") + query_params["prompt"] = " ".join(prompt_list) + if not query_params["prompt"]: + del query_params["prompt"] uri = uri._replace(query=urlencode(query_params, doseq=True)) return urlunsplit(uri) diff --git a/oidc_provider/lib/utils/common.py b/oidc_provider/lib/utils/common.py index 8d4623ec..35e393c6 100644 --- a/oidc_provider/lib/utils/common.py +++ b/oidc_provider/lib/utils/common.py @@ -17,8 +17,8 @@ def redirect(uri): """ Custom Response object for redirecting to a Non-HTTP url scheme. """ - response = HttpResponse('', status=302) - response['Location'] = uri + response = HttpResponse("", status=302) + response["Location"] = uri return response @@ -31,15 +31,17 @@ def get_site_url(site_url=None, request=None): 2. valid `SITE_URL` in settings 3. construct from `request` object """ - site_url = site_url or settings.get('SITE_URL') + site_url = site_url or settings.get("SITE_URL") if site_url: return site_url elif request: - return '{}://{}'.format(request.scheme, request.get_host()) + return "{}://{}".format(request.scheme, request.get_host()) else: - raise Exception('Either pass `site_url`, ' - 'or set `SITE_URL` in settings, ' - 'or pass `request` object.') + raise Exception( + "Either pass `site_url`, " + "or set `SITE_URL` in settings, " + "or pass `request` object." + ) def get_issuer(site_url=None, request=None): @@ -48,8 +50,9 @@ def get_issuer(site_url=None, request=None): appended. """ site_url = get_site_url(site_url=site_url, request=request) - path = reverse('oidc_provider:provider-info') \ - .split('/.well-known/openid-configuration')[0] + path = reverse("oidc_provider:provider-info").split( + "/.well-known/openid-configuration" + )[0] issuer = site_url + path return str(issuer) @@ -78,8 +81,13 @@ def default_after_userlogin_hook(request, user, client): def default_after_end_session_hook( - request, id_token=None, post_logout_redirect_uri=None, - state=None, client=None, next_page=None): + request, + id_token=None, + post_logout_redirect_uri=None, + state=None, + client=None, + next_page=None, +): """ Default function for setting OIDC_AFTER_END_SESSION_HOOK. @@ -108,8 +116,7 @@ def default_after_end_session_hook( return None -def default_idtoken_processing_hook( - id_token, user, token, request, **kwargs): +def default_idtoken_processing_hook(id_token, user, token, request, **kwargs): """ Hook to perform some additional actions to `id_token` dictionary just before serialization. @@ -146,9 +153,10 @@ def get_browser_state_or_default(request): """ Determine value to use as session state. """ - key = (request.session.session_key or - settings.get('OIDC_UNAUTHENTICATED_SESSION_MANAGEMENT_KEY')) - return sha224(key.encode('utf-8')).hexdigest() + key = request.session.session_key or settings.get( + "OIDC_UNAUTHENTICATED_SESSION_MANAGEMENT_KEY" + ) + return sha224(key.encode("utf-8")).hexdigest() def run_processing_hook(subject, hook_settings_name, **kwargs): @@ -168,19 +176,20 @@ def cors_allow_any(request, response): Add headers to permit CORS requests from any origin, with or without credentials, with any headers. """ - origin = request.META.get('HTTP_ORIGIN') + origin = request.META.get("HTTP_ORIGIN") if not origin: return response # From the CORS spec: The string "*" cannot be used for a resource that supports credentials. - response['Access-Control-Allow-Origin'] = origin - patch_vary_headers(response, ['Origin']) - response['Access-Control-Allow-Credentials'] = 'true' - - if request.method == 'OPTIONS': - if 'HTTP_ACCESS_CONTROL_REQUEST_HEADERS' in request.META: - response['Access-Control-Allow-Headers'] \ - = request.META['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'] - response['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS' + response["Access-Control-Allow-Origin"] = origin + patch_vary_headers(response, ["Origin"]) + response["Access-Control-Allow-Credentials"] = "true" + + if request.method == "OPTIONS": + if "HTTP_ACCESS_CONTROL_REQUEST_HEADERS" in request.META: + response["Access-Control-Allow-Headers"] = request.META[ + "HTTP_ACCESS_CONTROL_REQUEST_HEADERS" + ] + response["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS" return response diff --git a/oidc_provider/lib/utils/oauth2.py b/oidc_provider/lib/utils/oauth2.py index a3fe7a09..9db75a3c 100644 --- a/oidc_provider/lib/utils/oauth2.py +++ b/oidc_provider/lib/utils/oauth2.py @@ -19,12 +19,12 @@ def extract_access_token(request): Return a string. """ - auth_header = request.META.get('HTTP_AUTHORIZATION', '') + auth_header = request.META.get("HTTP_AUTHORIZATION", "") - if re.compile(r'^[Bb]earer\s{1}.+$').match(auth_header): + if re.compile(r"^[Bb]earer\s{1}.+$").match(auth_header): access_token = auth_header.split()[1] else: - access_token = request.GET.get('access_token', '') + access_token = request.GET.get("access_token", "") return access_token @@ -37,18 +37,18 @@ def extract_client_auth(request): Return a tuple `(client_id, client_secret)`. """ - auth_header = request.META.get('HTTP_AUTHORIZATION', '') + auth_header = request.META.get("HTTP_AUTHORIZATION", "") - if re.compile(r'^Basic\s{1}.+$').match(auth_header): + if re.compile(r"^Basic\s{1}.+$").match(auth_header): b64_user_pass = auth_header.split()[1] try: - user_pass = b64decode(b64_user_pass).decode('utf-8').split(':') + user_pass = b64decode(b64_user_pass).decode("utf-8").split(":") client_id, client_secret = tuple(user_pass) except Exception: - client_id = client_secret = '' + client_id = client_secret = "" else: - client_id = request.POST.get('client_id', '') - client_secret = request.POST.get('client_secret', '') + client_id = request.POST.get("client_id", "") + client_secret = request.POST.get("client_secret", "") return (client_id, client_secret) @@ -63,30 +63,33 @@ def protected_resource_view(scopes=None): scopes = [] def wrapper(view): - def view_wrapper(request, *args, **kwargs): + def view_wrapper(request, *args, **kwargs): access_token = extract_access_token(request) try: try: - kwargs['token'] = Token.objects.get(access_token=access_token) + kwargs["token"] = Token.objects.get(access_token=access_token) except Token.DoesNotExist: - logger.debug('[UserInfo] Token does not exist: %s', access_token) - raise BearerTokenError('invalid_token') + logger.debug("[UserInfo] Token does not exist: %s", access_token) + raise BearerTokenError("invalid_token") - if kwargs['token'].has_expired(): - logger.debug('[UserInfo] Token has expired: %s', access_token) - raise BearerTokenError('invalid_token') + if kwargs["token"].has_expired(): + logger.debug("[UserInfo] Token has expired: %s", access_token) + raise BearerTokenError("invalid_token") - if not set(scopes).issubset(set(kwargs['token'].scope)): - logger.debug('[UserInfo] Missing openid scope.') - raise BearerTokenError('insufficient_scope') + if not set(scopes).issubset(set(kwargs["token"].scope)): + logger.debug("[UserInfo] Missing openid scope.") + raise BearerTokenError("insufficient_scope") except BearerTokenError as error: response = HttpResponse(status=error.status) - response['WWW-Authenticate'] = 'error="{0}", error_description="{1}"'.format( - error.code, error.description) + response["WWW-Authenticate"] = ( + 'error="{0}", error_description="{1}"'.format( + error.code, error.description + ) + ) return response - return view(request, *args, **kwargs) + return view(request, *args, **kwargs) return view_wrapper diff --git a/oidc_provider/lib/utils/token.py b/oidc_provider/lib/utils/token.py index 403440ad..71317dfb 100644 --- a/oidc_provider/lib/utils/token.py +++ b/oidc_provider/lib/utils/token.py @@ -59,7 +59,9 @@ def create_id_token(token, user, aud, nonce="", at_hash="", request=None, scope= dic.update(standard_claims.create_response_dic()) if settings.get("OIDC_EXTRA_SCOPE_CLAIMS"): - extra_claims = settings.get("OIDC_EXTRA_SCOPE_CLAIMS", import_str=True)(token) + extra_claims = settings.get("OIDC_EXTRA_SCOPE_CLAIMS", import_str=True)( + token + ) dic.update(extra_claims.create_response_dic()) dic = run_processing_hook( @@ -116,14 +118,22 @@ def create_token(user, client, scope, id_token_dic=None): token.id_token = id_token_dic token.refresh_token = uuid.uuid4().hex - token.expires_at = timezone.now() + timedelta(seconds=settings.get("OIDC_TOKEN_EXPIRE")) + token.expires_at = timezone.now() + timedelta( + seconds=settings.get("OIDC_TOKEN_EXPIRE") + ) token.scope = scope return token def create_code( - user, client, scope, nonce, is_authentication, code_challenge=None, code_challenge_method=None + user, + client, + scope, + nonce, + is_authentication, + code_challenge=None, + code_challenge_method=None, ): """ Create and populate a Code object. @@ -139,7 +149,9 @@ def create_code( code.code_challenge = code_challenge code.code_challenge_method = code_challenge_method - code.expires_at = timezone.now() + timedelta(seconds=settings.get("OIDC_CODE_EXPIRE")) + code.expires_at = timezone.now() + timedelta( + seconds=settings.get("OIDC_CODE_EXPIRE") + ) code.scope = scope code.nonce = nonce code.is_authentication = is_authentication diff --git a/oidc_provider/management/commands/creatersakey.py b/oidc_provider/management/commands/creatersakey.py index 9d609c56..7704d109 100644 --- a/oidc_provider/management/commands/creatersakey.py +++ b/oidc_provider/management/commands/creatersakey.py @@ -4,13 +4,15 @@ class Command(BaseCommand): - help = 'Randomly generate a new RSA key for the OpenID server' + help = "Randomly generate a new RSA key for the OpenID server" def handle(self, *args, **options): try: key = RSA.generate(2048) - rsakey = RSAKey(key=key.exportKey('PEM').decode('utf8')) + rsakey = RSAKey(key=key.exportKey("PEM").decode("utf8")) rsakey.save() - self.stdout.write(u'RSA key successfully created with kid: {0}'.format(rsakey.kid)) + self.stdout.write( + "RSA key successfully created with kid: {0}".format(rsakey.kid) + ) except Exception as e: - self.stdout.write('Something goes wrong: {0}'.format(e)) + self.stdout.write("Something goes wrong: {0}".format(e)) diff --git a/oidc_provider/middleware.py b/oidc_provider/middleware.py index 3516bc44..f4b3a2c8 100644 --- a/oidc_provider/middleware.py +++ b/oidc_provider/middleware.py @@ -16,6 +16,8 @@ class SessionManagementMiddleware(MiddlewareMixin): """ def process_response(self, request, response): - if settings.get('OIDC_SESSION_MANAGEMENT_ENABLE'): - response.set_cookie('op_browser_state', get_browser_state_or_default(request)) + if settings.get("OIDC_SESSION_MANAGEMENT_ENABLE"): + response.set_cookie( + "op_browser_state", get_browser_state_or_default(request) + ) return response diff --git a/oidc_provider/migrations/0001_initial.py b/oidc_provider/migrations/0001_initial.py index 2af079ab..62f0d4f0 100644 --- a/oidc_provider/migrations/0001_initial.py +++ b/oidc_provider/migrations/0001_initial.py @@ -8,96 +8,187 @@ class Migration(migrations.Migration): dependencies = [ - ('auth', '0001_initial'), + ("auth", "0001_initial"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name='Client', + name="Client", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('name', models.CharField(default=b'', max_length=100)), - ('client_id', models.CharField(unique=True, max_length=255)), - ('client_secret', models.CharField(unique=True, max_length=255)), - ('response_type', models.CharField(max_length=30, choices=[ - (b'code', b'code (Authorization Code Flow)'), (b'id_token', b'id_token (Implicit Flow)'), - (b'id_token token', b'id_token token (Implicit Flow)')])), - ('_redirect_uris', models.TextField(default=b'')), + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ("name", models.CharField(default=b"", max_length=100)), + ("client_id", models.CharField(unique=True, max_length=255)), + ("client_secret", models.CharField(unique=True, max_length=255)), + ( + "response_type", + models.CharField( + max_length=30, + choices=[ + (b"code", b"code (Authorization Code Flow)"), + (b"id_token", b"id_token (Implicit Flow)"), + (b"id_token token", b"id_token token (Implicit Flow)"), + ], + ), + ), + ("_redirect_uris", models.TextField(default=b"")), ], - options={ - }, + options={}, bases=(models.Model,), ), migrations.CreateModel( - name='Code', + name="Code", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('expires_at', models.DateTimeField()), - ('_scope', models.TextField(default=b'')), - ('code', models.CharField(unique=True, max_length=255)), - ('client', models.ForeignKey(to='oidc_provider.Client', on_delete=models.CASCADE)), + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ("expires_at", models.DateTimeField()), + ("_scope", models.TextField(default=b"")), + ("code", models.CharField(unique=True, max_length=255)), + ( + "client", + models.ForeignKey( + to="oidc_provider.Client", on_delete=models.CASCADE + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, bases=(models.Model,), ), migrations.CreateModel( - name='Token', + name="Token", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('expires_at', models.DateTimeField()), - ('_scope', models.TextField(default=b'')), - ('access_token', models.CharField(unique=True, max_length=255)), - ('_id_token', models.TextField()), - ('client', models.ForeignKey(to='oidc_provider.Client', on_delete=models.CASCADE)), + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ("expires_at", models.DateTimeField()), + ("_scope", models.TextField(default=b"")), + ("access_token", models.CharField(unique=True, max_length=255)), + ("_id_token", models.TextField()), + ( + "client", + models.ForeignKey( + to="oidc_provider.Client", on_delete=models.CASCADE + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, bases=(models.Model,), ), migrations.CreateModel( - name='UserInfo', + name="UserInfo", fields=[ - ('user', models.OneToOneField(primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), - ('given_name', models.CharField(max_length=255, null=True, blank=True)), - ('family_name', models.CharField(max_length=255, null=True, blank=True)), - ('middle_name', models.CharField(max_length=255, null=True, blank=True)), - ('nickname', models.CharField(max_length=255, null=True, blank=True)), - ('gender', models.CharField(max_length=100, null=True, choices=[(b'F', b'Female'), (b'M', b'Male')])), - ('birthdate', models.DateField(null=True)), - ('zoneinfo', models.CharField(default=b'', max_length=100, null=True, blank=True)), - ('preferred_username', models.CharField(max_length=255, null=True, blank=True)), - ('profile', models.URLField(default=b'', null=True, blank=True)), - ('picture', models.URLField(default=b'', null=True, blank=True)), - ('website', models.URLField(default=b'', null=True, blank=True)), - ('email_verified', models.NullBooleanField(default=False)), - ('locale', models.CharField(max_length=100, null=True, blank=True)), - ('phone_number', models.CharField(max_length=255, null=True, blank=True)), - ('phone_number_verified', models.NullBooleanField(default=False)), - ('address_street_address', models.CharField(max_length=255, null=True, blank=True)), - ('address_locality', models.CharField(max_length=255, null=True, blank=True)), - ('address_region', models.CharField(max_length=255, null=True, blank=True)), - ('address_postal_code', models.CharField(max_length=255, null=True, blank=True)), - ('address_country', models.CharField(max_length=255, null=True, blank=True)), - ('updated_at', models.DateTimeField(auto_now=True, null=True)), + ( + "user", + models.OneToOneField( + primary_key=True, + serialize=False, + to=settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + ), + ), + ("given_name", models.CharField(max_length=255, null=True, blank=True)), + ( + "family_name", + models.CharField(max_length=255, null=True, blank=True), + ), + ( + "middle_name", + models.CharField(max_length=255, null=True, blank=True), + ), + ("nickname", models.CharField(max_length=255, null=True, blank=True)), + ( + "gender", + models.CharField( + max_length=100, + null=True, + choices=[(b"F", b"Female"), (b"M", b"Male")], + ), + ), + ("birthdate", models.DateField(null=True)), + ( + "zoneinfo", + models.CharField( + default=b"", max_length=100, null=True, blank=True + ), + ), + ( + "preferred_username", + models.CharField(max_length=255, null=True, blank=True), + ), + ("profile", models.URLField(default=b"", null=True, blank=True)), + ("picture", models.URLField(default=b"", null=True, blank=True)), + ("website", models.URLField(default=b"", null=True, blank=True)), + ("email_verified", models.NullBooleanField(default=False)), + ("locale", models.CharField(max_length=100, null=True, blank=True)), + ( + "phone_number", + models.CharField(max_length=255, null=True, blank=True), + ), + ("phone_number_verified", models.NullBooleanField(default=False)), + ( + "address_street_address", + models.CharField(max_length=255, null=True, blank=True), + ), + ( + "address_locality", + models.CharField(max_length=255, null=True, blank=True), + ), + ( + "address_region", + models.CharField(max_length=255, null=True, blank=True), + ), + ( + "address_postal_code", + models.CharField(max_length=255, null=True, blank=True), + ), + ( + "address_country", + models.CharField(max_length=255, null=True, blank=True), + ), + ("updated_at", models.DateTimeField(auto_now=True, null=True)), ], - options={ - }, + options={}, bases=(models.Model,), ), migrations.AddField( - model_name='token', - name='user', - field=models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE), + model_name="token", + name="user", + field=models.ForeignKey( + to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE + ), preserve_default=True, ), migrations.AddField( - model_name='code', - name='user', - field=models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE), + model_name="code", + name="user", + field=models.ForeignKey( + to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE + ), preserve_default=True, ), ] diff --git a/oidc_provider/migrations/0002_userconsent.py b/oidc_provider/migrations/0002_userconsent.py index d2a0f12b..8d153deb 100644 --- a/oidc_provider/migrations/0002_userconsent.py +++ b/oidc_provider/migrations/0002_userconsent.py @@ -9,21 +9,39 @@ class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('oidc_provider', '0001_initial'), + ("oidc_provider", "0001_initial"), ] operations = [ migrations.CreateModel( - name='UserConsent', + name="UserConsent", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('expires_at', models.DateTimeField()), - ('_scope', models.TextField(default=b'')), - ('client', models.ForeignKey(to='oidc_provider.Client', on_delete=models.CASCADE)), - ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ("expires_at", models.DateTimeField()), + ("_scope", models.TextField(default=b"")), + ( + "client", + models.ForeignKey( + to="oidc_provider.Client", on_delete=models.CASCADE + ), + ), + ( + "user", + models.ForeignKey( + to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE + ), + ), ], options={ - 'abstract': False, + "abstract": False, }, bases=(models.Model,), ), diff --git a/oidc_provider/migrations/0003_code_nonce.py b/oidc_provider/migrations/0003_code_nonce.py index 0d496157..5509c58d 100644 --- a/oidc_provider/migrations/0003_code_nonce.py +++ b/oidc_provider/migrations/0003_code_nonce.py @@ -7,13 +7,13 @@ class Migration(migrations.Migration): dependencies = [ - ('oidc_provider', '0002_userconsent'), + ("oidc_provider", "0002_userconsent"), ] operations = [ migrations.AddField( - model_name='code', - name='nonce', - field=models.CharField(default=b'', max_length=255, blank=True), + model_name="code", + name="nonce", + field=models.CharField(default=b"", max_length=255, blank=True), ), ] diff --git a/oidc_provider/migrations/0004_remove_userinfo.py b/oidc_provider/migrations/0004_remove_userinfo.py index d4208e00..e072e2c9 100644 --- a/oidc_provider/migrations/0004_remove_userinfo.py +++ b/oidc_provider/migrations/0004_remove_userinfo.py @@ -7,15 +7,15 @@ class Migration(migrations.Migration): dependencies = [ - ('oidc_provider', '0003_code_nonce'), + ("oidc_provider", "0003_code_nonce"), ] operations = [ migrations.RemoveField( - model_name='userinfo', - name='user', + model_name="userinfo", + name="user", ), migrations.DeleteModel( - name='UserInfo', + name="UserInfo", ), ] diff --git a/oidc_provider/migrations/0005_token_refresh_token.py b/oidc_provider/migrations/0005_token_refresh_token.py index e571318d..536ec65c 100644 --- a/oidc_provider/migrations/0005_token_refresh_token.py +++ b/oidc_provider/migrations/0005_token_refresh_token.py @@ -7,13 +7,13 @@ class Migration(migrations.Migration): dependencies = [ - ('oidc_provider', '0004_remove_userinfo'), + ("oidc_provider", "0004_remove_userinfo"), ] operations = [ migrations.AddField( - model_name='token', - name='refresh_token', + model_name="token", + name="refresh_token", field=models.CharField(max_length=255, unique=True, null=True), preserve_default=True, ), diff --git a/oidc_provider/migrations/0006_unique_user_client.py b/oidc_provider/migrations/0006_unique_user_client.py index 1ce586eb..572a0e00 100644 --- a/oidc_provider/migrations/0006_unique_user_client.py +++ b/oidc_provider/migrations/0006_unique_user_client.py @@ -7,12 +7,12 @@ class Migration(migrations.Migration): dependencies = [ - ('oidc_provider', '0005_token_refresh_token'), + ("oidc_provider", "0005_token_refresh_token"), ] operations = [ migrations.AlterUniqueTogether( - name='userconsent', - unique_together=set([('user', 'client')]), + name="userconsent", + unique_together=set([("user", "client")]), ), ] diff --git a/oidc_provider/migrations/0007_auto_20160111_1844.py b/oidc_provider/migrations/0007_auto_20160111_1844.py index 1be05def..ea71d08a 100644 --- a/oidc_provider/migrations/0007_auto_20160111_1844.py +++ b/oidc_provider/migrations/0007_auto_20160111_1844.py @@ -11,32 +11,43 @@ class Migration(migrations.Migration): dependencies = [ - ('oidc_provider', '0006_unique_user_client'), + ("oidc_provider", "0006_unique_user_client"), ] operations = [ migrations.AlterModelOptions( - name='client', - options={'verbose_name': 'Client', 'verbose_name_plural': 'Clients'}, + name="client", + options={"verbose_name": "Client", "verbose_name_plural": "Clients"}, ), migrations.AlterModelOptions( - name='code', - options={'verbose_name': 'Authorization Code', 'verbose_name_plural': 'Authorization Codes'}, + name="code", + options={ + "verbose_name": "Authorization Code", + "verbose_name_plural": "Authorization Codes", + }, ), migrations.AlterModelOptions( - name='token', - options={'verbose_name': 'Token', 'verbose_name_plural': 'Tokens'}, + name="token", + options={"verbose_name": "Token", "verbose_name_plural": "Tokens"}, ), migrations.AddField( - model_name='client', - name='date_created', + model_name="client", + name="date_created", field=models.DateField( - auto_now_add=True, default=datetime.datetime(2016, 1, 11, 18, 44, 32, 192477, tzinfo=timezone.utc)), + auto_now_add=True, + default=datetime.datetime( + 2016, 1, 11, 18, 44, 32, 192477, tzinfo=timezone.utc + ), + ), preserve_default=False, ), migrations.AlterField( - model_name='client', - name='_redirect_uris', - field=models.TextField(default=b'', help_text='Enter each URI on a new line.', verbose_name='Redirect URI'), + model_name="client", + name="_redirect_uris", + field=models.TextField( + default=b"", + help_text="Enter each URI on a new line.", + verbose_name="Redirect URI", + ), ), ] diff --git a/oidc_provider/migrations/0008_rsakey.py b/oidc_provider/migrations/0008_rsakey.py index 6c76d6d1..eb57074f 100644 --- a/oidc_provider/migrations/0008_rsakey.py +++ b/oidc_provider/migrations/0008_rsakey.py @@ -8,15 +8,23 @@ class Migration(migrations.Migration): dependencies = [ - ('oidc_provider', '0007_auto_20160111_1844'), + ("oidc_provider", "0007_auto_20160111_1844"), ] operations = [ migrations.CreateModel( - name='RSAKey', + name="RSAKey", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('key', models.TextField()), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("key", models.TextField()), ], ), ] diff --git a/oidc_provider/migrations/0009_auto_20160202_1945.py b/oidc_provider/migrations/0009_auto_20160202_1945.py index c4b986df..c4dd9d0d 100644 --- a/oidc_provider/migrations/0009_auto_20160202_1945.py +++ b/oidc_provider/migrations/0009_auto_20160202_1945.py @@ -8,17 +8,17 @@ class Migration(migrations.Migration): dependencies = [ - ('oidc_provider', '0008_rsakey'), + ("oidc_provider", "0008_rsakey"), ] operations = [ migrations.AlterModelOptions( - name='rsakey', - options={'verbose_name': 'RSA Key', 'verbose_name_plural': 'RSA Keys'}, + name="rsakey", + options={"verbose_name": "RSA Key", "verbose_name_plural": "RSA Keys"}, ), migrations.AlterField( - model_name='rsakey', - name='key', - field=models.TextField(help_text='Paste your private RSA Key here.'), + model_name="rsakey", + name="key", + field=models.TextField(help_text="Paste your private RSA Key here."), ), ] diff --git a/oidc_provider/migrations/0010_code_is_authentication.py b/oidc_provider/migrations/0010_code_is_authentication.py index 0ecf8628..e9024825 100644 --- a/oidc_provider/migrations/0010_code_is_authentication.py +++ b/oidc_provider/migrations/0010_code_is_authentication.py @@ -8,13 +8,13 @@ class Migration(migrations.Migration): dependencies = [ - ('oidc_provider', '0009_auto_20160202_1945'), + ("oidc_provider", "0009_auto_20160202_1945"), ] operations = [ migrations.AddField( - model_name='code', - name='is_authentication', + model_name="code", + name="is_authentication", field=models.BooleanField(default=False), ), ] diff --git a/oidc_provider/migrations/0011_client_client_type.py b/oidc_provider/migrations/0011_client_client_type.py index 563096fa..729f3ca7 100644 --- a/oidc_provider/migrations/0011_client_client_type.py +++ b/oidc_provider/migrations/0011_client_client_type.py @@ -8,18 +8,19 @@ class Migration(migrations.Migration): dependencies = [ - ('oidc_provider', '0010_code_is_authentication'), + ("oidc_provider", "0010_code_is_authentication"), ] operations = [ migrations.AddField( - model_name='client', - name='client_type', + model_name="client", + name="client_type", field=models.CharField( - choices=[(b'confidential', b'Confidential'), (b'public', b'Public')], - default=b'confidential', - help_text='Confidential clients are capable of maintaining the confidentiality of their ' - 'credentials. Public clients are incapable.', - max_length=30), + choices=[(b"confidential", b"Confidential"), (b"public", b"Public")], + default=b"confidential", + help_text="Confidential clients are capable of maintaining the confidentiality of their " + "credentials. Public clients are incapable.", + max_length=30, + ), ), ] diff --git a/oidc_provider/migrations/0012_auto_20160405_2041.py b/oidc_provider/migrations/0012_auto_20160405_2041.py index c04b6130..d52889bb 100644 --- a/oidc_provider/migrations/0012_auto_20160405_2041.py +++ b/oidc_provider/migrations/0012_auto_20160405_2041.py @@ -8,13 +8,13 @@ class Migration(migrations.Migration): dependencies = [ - ('oidc_provider', '0011_client_client_type'), + ("oidc_provider", "0011_client_client_type"), ] operations = [ migrations.AlterField( - model_name='client', - name='client_secret', - field=models.CharField(blank=True, default=b'', max_length=255), + model_name="client", + name="client_secret", + field=models.CharField(blank=True, default=b"", max_length=255), ), ] diff --git a/oidc_provider/migrations/0013_auto_20160407_1912.py b/oidc_provider/migrations/0013_auto_20160407_1912.py index 19cb4448..21ba484b 100644 --- a/oidc_provider/migrations/0013_auto_20160407_1912.py +++ b/oidc_provider/migrations/0013_auto_20160407_1912.py @@ -8,18 +8,18 @@ class Migration(migrations.Migration): dependencies = [ - ('oidc_provider', '0012_auto_20160405_2041'), + ("oidc_provider", "0012_auto_20160405_2041"), ] operations = [ migrations.AddField( - model_name='code', - name='code_challenge', + model_name="code", + name="code_challenge", field=models.CharField(max_length=255, null=True), ), migrations.AddField( - model_name='code', - name='code_challenge_method', + model_name="code", + name="code_challenge_method", field=models.CharField(max_length=255, null=True), ), ] diff --git a/oidc_provider/migrations/0014_client_jwt_alg.py b/oidc_provider/migrations/0014_client_jwt_alg.py index 18a34c2a..04aacc1f 100644 --- a/oidc_provider/migrations/0014_client_jwt_alg.py +++ b/oidc_provider/migrations/0014_client_jwt_alg.py @@ -8,17 +8,18 @@ class Migration(migrations.Migration): dependencies = [ - ('oidc_provider', '0013_auto_20160407_1912'), + ("oidc_provider", "0013_auto_20160407_1912"), ] operations = [ migrations.AddField( - model_name='client', - name='jwt_alg', + model_name="client", + name="jwt_alg", field=models.CharField( - choices=[(b'HS256', b'HS256'), (b'RS256', b'RS256')], - default=b'RS256', + choices=[(b"HS256", b"HS256"), (b"RS256", b"RS256")], + default=b"RS256", max_length=10, - verbose_name='JWT Algorithm'), + verbose_name="JWT Algorithm", + ), ), ] diff --git a/oidc_provider/migrations/0015_change_client_code.py b/oidc_provider/migrations/0015_change_client_code.py index a4f67e1b..6da62e3d 100644 --- a/oidc_provider/migrations/0015_change_client_code.py +++ b/oidc_provider/migrations/0015_change_client_code.py @@ -8,71 +8,80 @@ class Migration(migrations.Migration): dependencies = [ - ('oidc_provider', '0014_client_jwt_alg'), + ("oidc_provider", "0014_client_jwt_alg"), ] operations = [ migrations.AlterField( - model_name='client', - name='_redirect_uris', - field=models.TextField(default='', help_text='Enter each URI on a new line.', verbose_name='Redirect URI'), + model_name="client", + name="_redirect_uris", + field=models.TextField( + default="", + help_text="Enter each URI on a new line.", + verbose_name="Redirect URI", + ), ), migrations.AlterField( - model_name='client', - name='client_secret', - field=models.CharField(blank=True, default='', max_length=255), + model_name="client", + name="client_secret", + field=models.CharField(blank=True, default="", max_length=255), ), migrations.AlterField( - model_name='client', - name='client_type', + model_name="client", + name="client_type", field=models.CharField( - choices=[('confidential', 'Confidential'), ('public', 'Public')], - default='confidential', - help_text='Confidential clients are capable of maintaining the confidentiality of their' - ' credentials. Public clients are incapable.', - max_length=30), + choices=[("confidential", "Confidential"), ("public", "Public")], + default="confidential", + help_text="Confidential clients are capable of maintaining the confidentiality of their" + " credentials. Public clients are incapable.", + max_length=30, + ), ), migrations.AlterField( - model_name='client', - name='jwt_alg', + model_name="client", + name="jwt_alg", field=models.CharField( - choices=[('HS256', 'HS256'), ('RS256', 'RS256')], - default='RS256', + choices=[("HS256", "HS256"), ("RS256", "RS256")], + default="RS256", max_length=10, - verbose_name='JWT Algorithm'), + verbose_name="JWT Algorithm", + ), ), migrations.AlterField( - model_name='client', - name='name', - field=models.CharField(default='', max_length=100), + model_name="client", + name="name", + field=models.CharField(default="", max_length=100), ), migrations.AlterField( - model_name='client', - name='response_type', + model_name="client", + name="response_type", field=models.CharField( choices=[ - ('code', 'code (Authorization Code Flow)'), ('id_token', 'id_token (Implicit Flow)'), - ('id_token token', 'id_token token (Implicit Flow)')], - max_length=30), + ("code", "code (Authorization Code Flow)"), + ("id_token", "id_token (Implicit Flow)"), + ("id_token token", "id_token token (Implicit Flow)"), + ], + max_length=30, + ), ), migrations.AlterField( - model_name='code', - name='_scope', - field=models.TextField(default=''), + model_name="code", + name="_scope", + field=models.TextField(default=""), ), migrations.AlterField( - model_name='code', - name='nonce', - field=models.CharField(blank=True, default='', max_length=255), + model_name="code", + name="nonce", + field=models.CharField(blank=True, default="", max_length=255), ), migrations.AlterField( - model_name='token', - name='_scope', - field=models.TextField(default=''), + model_name="token", + name="_scope", + field=models.TextField(default=""), ), migrations.AlterField( - model_name='userconsent', - name='_scope', - field=models.TextField(default=''), + model_name="userconsent", + name="_scope", + field=models.TextField(default=""), ), ] diff --git a/oidc_provider/migrations/0016_userconsent_and_verbosenames.py b/oidc_provider/migrations/0016_userconsent_and_verbosenames.py index fc562414..cdedb40a 100644 --- a/oidc_provider/migrations/0016_userconsent_and_verbosenames.py +++ b/oidc_provider/migrations/0016_userconsent_and_verbosenames.py @@ -13,173 +13,218 @@ class Migration(migrations.Migration): dependencies = [ - ('oidc_provider', '0015_change_client_code'), + ("oidc_provider", "0015_change_client_code"), ] operations = [ migrations.AddField( - model_name='userconsent', - name='date_given', + model_name="userconsent", + name="date_given", field=models.DateTimeField( - default=datetime.datetime(2016, 6, 10, 17, 53, 48, 889808, tzinfo=datetime.timezone.utc), verbose_name='Date Given'), + default=datetime.datetime( + 2016, 6, 10, 17, 53, 48, 889808, tzinfo=datetime.timezone.utc + ), + verbose_name="Date Given", + ), preserve_default=False, ), migrations.AlterField( - model_name='client', - name='_redirect_uris', + model_name="client", + name="_redirect_uris", field=models.TextField( - default=b'', help_text='Enter each URI on a new line.', verbose_name='Redirect URIs'), + default=b"", + help_text="Enter each URI on a new line.", + verbose_name="Redirect URIs", + ), ), migrations.AlterField( - model_name='client', - name='client_id', - field=models.CharField(max_length=255, unique=True, verbose_name='Client ID'), + model_name="client", + name="client_id", + field=models.CharField( + max_length=255, unique=True, verbose_name="Client ID" + ), ), migrations.AlterField( - model_name='client', - name='client_secret', - field=models.CharField(blank=True, default=b'', max_length=255, verbose_name='Client SECRET'), + model_name="client", + name="client_secret", + field=models.CharField( + blank=True, default=b"", max_length=255, verbose_name="Client SECRET" + ), ), migrations.AlterField( - model_name='client', - name='client_type', + model_name="client", + name="client_type", field=models.CharField( - choices=[(b'confidential', b'Confidential'), (b'public', b'Public')], - default=b'confidential', - help_text='Confidential clients are capable of maintaining the confidentiality of their ' - 'credentials. Public clients are incapable.', + choices=[(b"confidential", b"Confidential"), (b"public", b"Public")], + default=b"confidential", + help_text="Confidential clients are capable of maintaining the confidentiality of their " + "credentials. Public clients are incapable.", max_length=30, - verbose_name='Client Type'), + verbose_name="Client Type", + ), ), migrations.AlterField( - model_name='client', - name='date_created', - field=models.DateField(auto_now_add=True, verbose_name='Date Created'), + model_name="client", + name="date_created", + field=models.DateField(auto_now_add=True, verbose_name="Date Created"), ), migrations.AlterField( - model_name='client', - name='name', - field=models.CharField(default=b'', max_length=100, verbose_name='Name'), + model_name="client", + name="name", + field=models.CharField(default=b"", max_length=100, verbose_name="Name"), ), migrations.AlterField( - model_name='client', - name='response_type', + model_name="client", + name="response_type", field=models.CharField( choices=[ - (b'code', b'code (Authorization Code Flow)'), (b'id_token', b'id_token (Implicit Flow)'), - (b'id_token token', b'id_token token (Implicit Flow)')], + (b"code", b"code (Authorization Code Flow)"), + (b"id_token", b"id_token (Implicit Flow)"), + (b"id_token token", b"id_token token (Implicit Flow)"), + ], max_length=30, - verbose_name='Response Type'), + verbose_name="Response Type", + ), ), migrations.AlterField( - model_name='code', - name='_scope', - field=models.TextField(default=b'', verbose_name='Scopes'), + model_name="code", + name="_scope", + field=models.TextField(default=b"", verbose_name="Scopes"), ), migrations.AlterField( - model_name='code', - name='client', + model_name="code", + name="client", field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, to='oidc_provider.Client', verbose_name='Client'), + on_delete=django.db.models.deletion.CASCADE, + to="oidc_provider.Client", + verbose_name="Client", + ), ), migrations.AlterField( - model_name='code', - name='code', - field=models.CharField(max_length=255, unique=True, verbose_name='Code'), + model_name="code", + name="code", + field=models.CharField(max_length=255, unique=True, verbose_name="Code"), ), migrations.AlterField( - model_name='code', - name='code_challenge', - field=models.CharField(max_length=255, null=True, verbose_name='Code Challenge'), + model_name="code", + name="code_challenge", + field=models.CharField( + max_length=255, null=True, verbose_name="Code Challenge" + ), ), migrations.AlterField( - model_name='code', - name='code_challenge_method', - field=models.CharField(max_length=255, null=True, verbose_name='Code Challenge Method'), + model_name="code", + name="code_challenge_method", + field=models.CharField( + max_length=255, null=True, verbose_name="Code Challenge Method" + ), ), migrations.AlterField( - model_name='code', - name='expires_at', - field=models.DateTimeField(verbose_name='Expiration Date'), + model_name="code", + name="expires_at", + field=models.DateTimeField(verbose_name="Expiration Date"), ), migrations.AlterField( - model_name='code', - name='is_authentication', - field=models.BooleanField(default=False, verbose_name='Is Authentication?'), + model_name="code", + name="is_authentication", + field=models.BooleanField(default=False, verbose_name="Is Authentication?"), ), migrations.AlterField( - model_name='code', - name='nonce', - field=models.CharField(blank=True, default=b'', max_length=255, verbose_name='Nonce'), + model_name="code", + name="nonce", + field=models.CharField( + blank=True, default=b"", max_length=255, verbose_name="Nonce" + ), ), migrations.AlterField( - model_name='code', - name='user', + model_name="code", + name="user", field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User'), + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + verbose_name="User", + ), ), migrations.AlterField( - model_name='rsakey', - name='key', - field=models.TextField(help_text='Paste your private RSA Key here.', verbose_name='Key'), + model_name="rsakey", + name="key", + field=models.TextField( + help_text="Paste your private RSA Key here.", verbose_name="Key" + ), ), migrations.AlterField( - model_name='token', - name='_id_token', - field=models.TextField(verbose_name='ID Token'), + model_name="token", + name="_id_token", + field=models.TextField(verbose_name="ID Token"), ), migrations.AlterField( - model_name='token', - name='_scope', - field=models.TextField(default=b'', verbose_name='Scopes'), + model_name="token", + name="_scope", + field=models.TextField(default=b"", verbose_name="Scopes"), ), migrations.AlterField( - model_name='token', - name='access_token', - field=models.CharField(max_length=255, unique=True, verbose_name='Access Token'), + model_name="token", + name="access_token", + field=models.CharField( + max_length=255, unique=True, verbose_name="Access Token" + ), ), migrations.AlterField( - model_name='token', - name='client', + model_name="token", + name="client", field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, to='oidc_provider.Client', verbose_name='Client'), + on_delete=django.db.models.deletion.CASCADE, + to="oidc_provider.Client", + verbose_name="Client", + ), ), migrations.AlterField( - model_name='token', - name='expires_at', - field=models.DateTimeField(verbose_name='Expiration Date'), + model_name="token", + name="expires_at", + field=models.DateTimeField(verbose_name="Expiration Date"), ), migrations.AlterField( - model_name='token', - name='refresh_token', - field=models.CharField(max_length=255, null=True, unique=True, verbose_name='Refresh Token'), + model_name="token", + name="refresh_token", + field=models.CharField( + max_length=255, null=True, unique=True, verbose_name="Refresh Token" + ), ), migrations.AlterField( - model_name='token', - name='user', + model_name="token", + name="user", field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User'), + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + verbose_name="User", + ), ), migrations.AlterField( - model_name='userconsent', - name='_scope', - field=models.TextField(default=b'', verbose_name='Scopes'), + model_name="userconsent", + name="_scope", + field=models.TextField(default=b"", verbose_name="Scopes"), ), migrations.AlterField( - model_name='userconsent', - name='client', + model_name="userconsent", + name="client", field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, to='oidc_provider.Client', verbose_name='Client'), + on_delete=django.db.models.deletion.CASCADE, + to="oidc_provider.Client", + verbose_name="Client", + ), ), migrations.AlterField( - model_name='userconsent', - name='expires_at', - field=models.DateTimeField(verbose_name='Expiration Date'), + model_name="userconsent", + name="expires_at", + field=models.DateTimeField(verbose_name="Expiration Date"), ), migrations.AlterField( - model_name='userconsent', - name='user', + model_name="userconsent", + name="user", field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User'), + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + verbose_name="User", + ), ), ] diff --git a/oidc_provider/migrations/0017_auto_20160811_1954.py b/oidc_provider/migrations/0017_auto_20160811_1954.py index 2d564e30..16f1826b 100644 --- a/oidc_provider/migrations/0017_auto_20160811_1954.py +++ b/oidc_provider/migrations/0017_auto_20160811_1954.py @@ -8,64 +8,76 @@ class Migration(migrations.Migration): dependencies = [ - ('oidc_provider', '0016_userconsent_and_verbosenames'), + ("oidc_provider", "0016_userconsent_and_verbosenames"), ] operations = [ migrations.AlterField( - model_name='client', - name='_redirect_uris', - field=models.TextField(default='', help_text='Enter each URI on a new line.', verbose_name='Redirect URIs'), + model_name="client", + name="_redirect_uris", + field=models.TextField( + default="", + help_text="Enter each URI on a new line.", + verbose_name="Redirect URIs", + ), ), migrations.AlterField( - model_name='client', - name='client_secret', - field=models.CharField(blank=True, default='', max_length=255, verbose_name='Client SECRET'), + model_name="client", + name="client_secret", + field=models.CharField( + blank=True, default="", max_length=255, verbose_name="Client SECRET" + ), ), migrations.AlterField( - model_name='client', - name='client_type', + model_name="client", + name="client_type", field=models.CharField( - choices=[('confidential', 'Confidential'), ('public', 'Public')], - default='confidential', - help_text='Confidential clients are capable of maintaining the confidentiality of their ' - 'credentials. Public clients are incapable.', + choices=[("confidential", "Confidential"), ("public", "Public")], + default="confidential", + help_text="Confidential clients are capable of maintaining the confidentiality of their " + "credentials. Public clients are incapable.", max_length=30, - verbose_name='Client Type'), + verbose_name="Client Type", + ), ), migrations.AlterField( - model_name='client', - name='name', - field=models.CharField(default='', max_length=100, verbose_name='Name'), + model_name="client", + name="name", + field=models.CharField(default="", max_length=100, verbose_name="Name"), ), migrations.AlterField( - model_name='client', - name='response_type', + model_name="client", + name="response_type", field=models.CharField( choices=[ - ('code', 'code (Authorization Code Flow)'), ('id_token', 'id_token (Implicit Flow)'), - ('id_token token', 'id_token token (Implicit Flow)')], + ("code", "code (Authorization Code Flow)"), + ("id_token", "id_token (Implicit Flow)"), + ("id_token token", "id_token token (Implicit Flow)"), + ], max_length=30, - verbose_name='Response Type'), + verbose_name="Response Type", + ), ), migrations.AlterField( - model_name='code', - name='_scope', - field=models.TextField(default='', verbose_name='Scopes'), + model_name="code", + name="_scope", + field=models.TextField(default="", verbose_name="Scopes"), ), migrations.AlterField( - model_name='code', - name='nonce', - field=models.CharField(blank=True, default='', max_length=255, verbose_name='Nonce'), + model_name="code", + name="nonce", + field=models.CharField( + blank=True, default="", max_length=255, verbose_name="Nonce" + ), ), migrations.AlterField( - model_name='token', - name='_scope', - field=models.TextField(default='', verbose_name='Scopes'), + model_name="token", + name="_scope", + field=models.TextField(default="", verbose_name="Scopes"), ), migrations.AlterField( - model_name='userconsent', - name='_scope', - field=models.TextField(default='', verbose_name='Scopes'), + model_name="userconsent", + name="_scope", + field=models.TextField(default="", verbose_name="Scopes"), ), ] diff --git a/oidc_provider/migrations/0018_hybridflow_and_clientattrs.py b/oidc_provider/migrations/0018_hybridflow_and_clientattrs.py index 06328ddb..143a3bde 100644 --- a/oidc_provider/migrations/0018_hybridflow_and_clientattrs.py +++ b/oidc_provider/migrations/0018_hybridflow_and_clientattrs.py @@ -8,56 +8,70 @@ class Migration(migrations.Migration): dependencies = [ - ('oidc_provider', '0017_auto_20160811_1954'), + ("oidc_provider", "0017_auto_20160811_1954"), ] operations = [ migrations.AddField( - model_name='client', - name='contact_email', - field=models.CharField(blank=True, default='', max_length=255, verbose_name='Contact Email'), + model_name="client", + name="contact_email", + field=models.CharField( + blank=True, default="", max_length=255, verbose_name="Contact Email" + ), ), migrations.AddField( - model_name='client', - name='logo', + model_name="client", + name="logo", field=models.FileField( - blank=True, default='', upload_to='oidc_provider/clients', verbose_name='Logo Image'), + blank=True, + default="", + upload_to="oidc_provider/clients", + verbose_name="Logo Image", + ), ), migrations.AddField( - model_name='client', - name='terms_url', + model_name="client", + name="terms_url", field=models.CharField( blank=True, - default='', - help_text='External reference to the privacy policy of the client.', + default="", + help_text="External reference to the privacy policy of the client.", max_length=255, - verbose_name='Terms URL'), + verbose_name="Terms URL", + ), ), migrations.AddField( - model_name='client', - name='website_url', - field=models.CharField(blank=True, default='', max_length=255, verbose_name='Website URL'), + model_name="client", + name="website_url", + field=models.CharField( + blank=True, default="", max_length=255, verbose_name="Website URL" + ), ), migrations.AlterField( - model_name='client', - name='jwt_alg', + model_name="client", + name="jwt_alg", field=models.CharField( - choices=[('HS256', 'HS256'), ('RS256', 'RS256')], - default='RS256', - help_text='Algorithm used to encode ID Tokens.', + choices=[("HS256", "HS256"), ("RS256", "RS256")], + default="RS256", + help_text="Algorithm used to encode ID Tokens.", max_length=10, - verbose_name='JWT Algorithm'), + verbose_name="JWT Algorithm", + ), ), migrations.AlterField( - model_name='client', - name='response_type', + model_name="client", + name="response_type", field=models.CharField( choices=[ - ('code', 'code (Authorization Code Flow)'), ('id_token', 'id_token (Implicit Flow)'), - ('id_token token', 'id_token token (Implicit Flow)'), ('code token', 'code token (Hybrid Flow)'), - ('code id_token', 'code id_token (Hybrid Flow)'), - ('code id_token token', 'code id_token token (Hybrid Flow)')], + ("code", "code (Authorization Code Flow)"), + ("id_token", "id_token (Implicit Flow)"), + ("id_token token", "id_token token (Implicit Flow)"), + ("code token", "code token (Hybrid Flow)"), + ("code id_token", "code id_token (Hybrid Flow)"), + ("code id_token token", "code id_token token (Hybrid Flow)"), + ], max_length=30, - verbose_name='Response Type'), + verbose_name="Response Type", + ), ), ] diff --git a/oidc_provider/migrations/0019_auto_20161005_1552.py b/oidc_provider/migrations/0019_auto_20161005_1552.py index f2afff92..64e5f0fd 100644 --- a/oidc_provider/migrations/0019_auto_20161005_1552.py +++ b/oidc_provider/migrations/0019_auto_20161005_1552.py @@ -8,13 +8,15 @@ class Migration(migrations.Migration): dependencies = [ - ('oidc_provider', '0018_hybridflow_and_clientattrs'), + ("oidc_provider", "0018_hybridflow_and_clientattrs"), ] operations = [ migrations.AlterField( - model_name='client', - name='client_secret', - field=models.CharField(blank=True, max_length=255, verbose_name='Client SECRET'), + model_name="client", + name="client_secret", + field=models.CharField( + blank=True, max_length=255, verbose_name="Client SECRET" + ), ), ] diff --git a/oidc_provider/migrations/0020_client__post_logout_redirect_uris.py b/oidc_provider/migrations/0020_client__post_logout_redirect_uris.py index 158da245..1a477b8e 100644 --- a/oidc_provider/migrations/0020_client__post_logout_redirect_uris.py +++ b/oidc_provider/migrations/0020_client__post_logout_redirect_uris.py @@ -8,17 +8,18 @@ class Migration(migrations.Migration): dependencies = [ - ('oidc_provider', '0019_auto_20161005_1552'), + ("oidc_provider", "0019_auto_20161005_1552"), ] operations = [ migrations.AddField( - model_name='client', - name='_post_logout_redirect_uris', + model_name="client", + name="_post_logout_redirect_uris", field=models.TextField( blank=True, - default='', - help_text='Enter each URI on a new line.', - verbose_name='Post Logout Redirect URIs'), + default="", + help_text="Enter each URI on a new line.", + verbose_name="Post Logout Redirect URIs", + ), ), ] diff --git a/oidc_provider/migrations/0021_refresh_token_not_unique.py b/oidc_provider/migrations/0021_refresh_token_not_unique.py index 691ae8e3..b21b3a22 100644 --- a/oidc_provider/migrations/0021_refresh_token_not_unique.py +++ b/oidc_provider/migrations/0021_refresh_token_not_unique.py @@ -8,14 +8,16 @@ class Migration(migrations.Migration): dependencies = [ - ('oidc_provider', '0020_client__post_logout_redirect_uris'), + ("oidc_provider", "0020_client__post_logout_redirect_uris"), ] operations = [ migrations.AlterField( - model_name='token', - name='refresh_token', - field=models.CharField(default='', max_length=255, unique=True, verbose_name='Refresh Token'), + model_name="token", + name="refresh_token", + field=models.CharField( + default="", max_length=255, unique=True, verbose_name="Refresh Token" + ), preserve_default=False, ), ] diff --git a/oidc_provider/migrations/0022_auto_20170331_1626.py b/oidc_provider/migrations/0022_auto_20170331_1626.py index 78b7026a..9d185c7f 100644 --- a/oidc_provider/migrations/0022_auto_20170331_1626.py +++ b/oidc_provider/migrations/0022_auto_20170331_1626.py @@ -8,25 +8,27 @@ class Migration(migrations.Migration): dependencies = [ - ('oidc_provider', '0021_refresh_token_not_unique'), + ("oidc_provider", "0021_refresh_token_not_unique"), ] operations = [ migrations.AddField( - model_name='client', - name='require_consent', + model_name="client", + name="require_consent", field=models.BooleanField( default=True, - help_text='If disabled, the Server will NEVER ask the user for consent.', - verbose_name='Require Consent?'), + help_text="If disabled, the Server will NEVER ask the user for consent.", + verbose_name="Require Consent?", + ), ), migrations.AddField( - model_name='client', - name='reuse_consent', + model_name="client", + name="reuse_consent", field=models.BooleanField( default=True, help_text="If enabled, the Server will save the user consent given to a specific client," - " so that user won't be prompted for the same authorization multiple times.", - verbose_name='Reuse Consent?'), + " so that user won't be prompted for the same authorization multiple times.", + verbose_name="Reuse Consent?", + ), ), ] diff --git a/oidc_provider/migrations/0023_client_owner.py b/oidc_provider/migrations/0023_client_owner.py index b6d214dd..543fe266 100644 --- a/oidc_provider/migrations/0023_client_owner.py +++ b/oidc_provider/migrations/0023_client_owner.py @@ -11,13 +11,21 @@ class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('oidc_provider', '0022_auto_20170331_1626'), + ("oidc_provider", "0022_auto_20170331_1626"), ] operations = [ migrations.AddField( - model_name='client', - name='owner', - field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='oidc_clients_set', to=settings.AUTH_USER_MODEL, verbose_name='Owner'), + model_name="client", + name="owner", + field=models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="oidc_clients_set", + to=settings.AUTH_USER_MODEL, + verbose_name="Owner", + ), ), ] diff --git a/oidc_provider/migrations/0024_auto_20180327_1959.py b/oidc_provider/migrations/0024_auto_20180327_1959.py index 7171661d..c1a1a40a 100644 --- a/oidc_provider/migrations/0024_auto_20180327_1959.py +++ b/oidc_provider/migrations/0024_auto_20180327_1959.py @@ -6,13 +6,17 @@ class Migration(migrations.Migration): dependencies = [ - ('oidc_provider', '0023_client_owner'), + ("oidc_provider", "0023_client_owner"), ] operations = [ migrations.AlterField( - model_name='client', - name='reuse_consent', - field=models.BooleanField(default=True, help_text="If enabled, server will save the user consent given to a specific client, so that user won't be prompted for the same authorization multiple times.", verbose_name='Reuse Consent?'), + model_name="client", + name="reuse_consent", + field=models.BooleanField( + default=True, + help_text="If enabled, server will save the user consent given to a specific client, so that user won't be prompted for the same authorization multiple times.", + verbose_name="Reuse Consent?", + ), ), ] diff --git a/oidc_provider/migrations/0025_user_field_codetoken.py b/oidc_provider/migrations/0025_user_field_codetoken.py index d757fb0f..3f9e67ab 100644 --- a/oidc_provider/migrations/0025_user_field_codetoken.py +++ b/oidc_provider/migrations/0025_user_field_codetoken.py @@ -8,18 +8,28 @@ class Migration(migrations.Migration): dependencies = [ - ('oidc_provider', '0024_auto_20180327_1959'), + ("oidc_provider", "0024_auto_20180327_1959"), ] operations = [ migrations.AddField( - model_name='client', - name='_scope', - field=models.TextField(blank=True, default='', help_text='Specifies the authorized scope values for the client app.', verbose_name='Scopes'), + model_name="client", + name="_scope", + field=models.TextField( + blank=True, + default="", + help_text="Specifies the authorized scope values for the client app.", + verbose_name="Scopes", + ), ), migrations.AlterField( - model_name='token', - name='user', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User'), + model_name="token", + name="user", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + verbose_name="User", + ), ), ] diff --git a/oidc_provider/migrations/0026_client_multiple_response_types.py b/oidc_provider/migrations/0026_client_multiple_response_types.py index 067d2910..854ff802 100644 --- a/oidc_provider/migrations/0026_client_multiple_response_types.py +++ b/oidc_provider/migrations/0026_client_multiple_response_types.py @@ -5,48 +5,76 @@ def migrate_response_type(apps, schema_editor): RESPONSE_TYPES = [ - ('code', 'code (Authorization Code Flow)'), - ('id_token', 'id_token (Implicit Flow)'), - ('id_token token', 'id_token token (Implicit Flow)'), - ('code token', 'code token (Hybrid Flow)'), - ('code id_token', 'code id_token (Hybrid Flow)'), - ('code id_token token', 'code id_token token (Hybrid Flow)'), + ("code", "code (Authorization Code Flow)"), + ("id_token", "id_token (Implicit Flow)"), + ("id_token token", "id_token token (Implicit Flow)"), + ("code token", "code token (Hybrid Flow)"), + ("code id_token", "code id_token (Hybrid Flow)"), + ("code id_token token", "code id_token token (Hybrid Flow)"), ] # ensure we get proper, versioned model with the deleted response_type field; # importing directly yields the latest without response_type - ResponseType = apps.get_model('oidc_provider', 'ResponseType') - Client = apps.get_model('oidc_provider', 'Client') + ResponseType = apps.get_model("oidc_provider", "ResponseType") + Client = apps.get_model("oidc_provider", "Client") db = schema_editor.connection.alias for value, description in RESPONSE_TYPES: ResponseType.objects.using(db).create(value=value, description=description) for client in Client.objects.using(db).all(): - client.response_types.add(ResponseType.objects.using(db).get(value=client.response_type)) + client.response_types.add( + ResponseType.objects.using(db).get(value=client.response_type) + ) class Migration(migrations.Migration): dependencies = [ - ('oidc_provider', '0025_user_field_codetoken'), + ("oidc_provider", "0025_user_field_codetoken"), ] operations = [ migrations.CreateModel( - name='ResponseType', + name="ResponseType", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('value', models.CharField(choices=[('code', 'code (Authorization Code Flow)'), ('id_token', 'id_token (Implicit Flow)'), ('id_token token', 'id_token token (Implicit Flow)'), ('code token', 'code token (Hybrid Flow)'), ('code id_token', 'code id_token (Hybrid Flow)'), ('code id_token token', 'code id_token token (Hybrid Flow)')], max_length=30, unique=True, verbose_name='Response Type Value')), - ('description', models.CharField(max_length=50)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "value", + models.CharField( + choices=[ + ("code", "code (Authorization Code Flow)"), + ("id_token", "id_token (Implicit Flow)"), + ("id_token token", "id_token token (Implicit Flow)"), + ("code token", "code token (Hybrid Flow)"), + ("code id_token", "code id_token (Hybrid Flow)"), + ( + "code id_token token", + "code id_token token (Hybrid Flow)", + ), + ], + max_length=30, + unique=True, + verbose_name="Response Type Value", + ), + ), + ("description", models.CharField(max_length=50)), ], ), migrations.AddField( - model_name='client', - name='response_types', - field=models.ManyToManyField(to='oidc_provider.ResponseType'), + model_name="client", + name="response_types", + field=models.ManyToManyField(to="oidc_provider.ResponseType"), ), # omitting reverse migrate_response_type since removing response_type is irreversible (nonnull and no default) migrations.RunPython(migrate_response_type), migrations.RemoveField( - model_name='client', - name='response_type', + model_name="client", + name="response_type", ), ] diff --git a/oidc_provider/migrations/0027_alter_rsakey_options.py b/oidc_provider/migrations/0027_alter_rsakey_options.py index ddec150a..7decb464 100644 --- a/oidc_provider/migrations/0027_alter_rsakey_options.py +++ b/oidc_provider/migrations/0027_alter_rsakey_options.py @@ -6,12 +6,16 @@ class Migration(migrations.Migration): dependencies = [ - ('oidc_provider', '0026_client_multiple_response_types'), + ("oidc_provider", "0026_client_multiple_response_types"), ] operations = [ migrations.AlterModelOptions( - name='rsakey', - options={'ordering': ['id'], 'verbose_name': 'RSA Key', 'verbose_name_plural': 'RSA Keys'}, + name="rsakey", + options={ + "ordering": ["id"], + "verbose_name": "RSA Key", + "verbose_name_plural": "RSA Keys", + }, ), ] diff --git a/oidc_provider/models.py b/oidc_provider/models.py index d2111086..dd33d50c 100644 --- a/oidc_provider/models.py +++ b/oidc_provider/models.py @@ -10,22 +10,22 @@ CLIENT_TYPE_CHOICES = [ - ('confidential', 'Confidential'), - ('public', 'Public'), + ("confidential", "Confidential"), + ("public", "Public"), ] RESPONSE_TYPE_CHOICES = [ - ('code', 'code (Authorization Code Flow)'), - ('id_token', 'id_token (Implicit Flow)'), - ('id_token token', 'id_token token (Implicit Flow)'), - ('code token', 'code token (Hybrid Flow)'), - ('code id_token', 'code id_token (Hybrid Flow)'), - ('code id_token token', 'code id_token token (Hybrid Flow)'), + ("code", "code (Authorization Code Flow)"), + ("id_token", "id_token (Implicit Flow)"), + ("id_token token", "id_token token (Implicit Flow)"), + ("code token", "code token (Hybrid Flow)"), + ("code id_token", "code id_token (Hybrid Flow)"), + ("code id_token token", "code id_token token (Hybrid Flow)"), ] JWT_ALGS = [ - ('HS256', 'HS256'), - ('RS256', 'RS256'), + ("HS256", "HS256"), + ("RS256", "RS256"), ] @@ -41,82 +41,112 @@ class ResponseType(models.Model): max_length=30, choices=RESPONSE_TYPE_CHOICES, unique=True, - verbose_name=_(u'Response Type Value')) + verbose_name=_("Response Type Value"), + ) description = models.CharField( max_length=50, ) def natural_key(self): - return self.value, # natural_key must return tuple + return (self.value,) # natural_key must return tuple def __str__(self): - return u'{0}'.format(self.description) + return "{0}".format(self.description) class Client(models.Model): - name = models.CharField(max_length=100, default='', verbose_name=_(u'Name')) + name = models.CharField(max_length=100, default="", verbose_name=_("Name")) owner = models.ForeignKey( - settings.AUTH_USER_MODEL, verbose_name=_(u'Owner'), blank=True, - null=True, default=None, on_delete=models.SET_NULL, related_name='oidc_clients_set') + settings.AUTH_USER_MODEL, + verbose_name=_("Owner"), + blank=True, + null=True, + default=None, + on_delete=models.SET_NULL, + related_name="oidc_clients_set", + ) client_type = models.CharField( max_length=30, choices=CLIENT_TYPE_CHOICES, - default='confidential', - verbose_name=_(u'Client Type'), - help_text=_(u'Confidential clients are capable of maintaining the confidentiality' - u' of their credentials. Public clients are incapable.')) - client_id = models.CharField(max_length=255, unique=True, verbose_name=_(u'Client ID')) - client_secret = models.CharField(max_length=255, blank=True, verbose_name=_(u'Client SECRET')) + default="confidential", + verbose_name=_("Client Type"), + help_text=_( + "Confidential clients are capable of maintaining the confidentiality" + " of their credentials. Public clients are incapable." + ), + ) + client_id = models.CharField( + max_length=255, unique=True, verbose_name=_("Client ID") + ) + client_secret = models.CharField( + max_length=255, blank=True, verbose_name=_("Client SECRET") + ) response_types = models.ManyToManyField(ResponseType) jwt_alg = models.CharField( max_length=10, choices=JWT_ALGS, - default='RS256', - verbose_name=_(u'JWT Algorithm'), - help_text=_(u'Algorithm used to encode ID Tokens.')) - date_created = models.DateField(auto_now_add=True, verbose_name=_(u'Date Created')) + default="RS256", + verbose_name=_("JWT Algorithm"), + help_text=_("Algorithm used to encode ID Tokens."), + ) + date_created = models.DateField(auto_now_add=True, verbose_name=_("Date Created")) website_url = models.CharField( - max_length=255, blank=True, default='', verbose_name=_(u'Website URL')) + max_length=255, blank=True, default="", verbose_name=_("Website URL") + ) terms_url = models.CharField( max_length=255, blank=True, - default='', - verbose_name=_(u'Terms URL'), - help_text=_(u'External reference to the privacy policy of the client.')) + default="", + verbose_name=_("Terms URL"), + help_text=_("External reference to the privacy policy of the client."), + ) contact_email = models.CharField( - max_length=255, blank=True, default='', verbose_name=_(u'Contact Email')) + max_length=255, blank=True, default="", verbose_name=_("Contact Email") + ) logo = models.FileField( - blank=True, default='', upload_to='oidc_provider/clients', verbose_name=_(u'Logo Image')) + blank=True, + default="", + upload_to="oidc_provider/clients", + verbose_name=_("Logo Image"), + ) reuse_consent = models.BooleanField( default=True, - verbose_name=_('Reuse Consent?'), - help_text=_('If enabled, server will save the user consent given to a specific client, ' - 'so that user won\'t be prompted for the same authorization multiple times.')) + verbose_name=_("Reuse Consent?"), + help_text=_( + "If enabled, server will save the user consent given to a specific client, " + "so that user won't be prompted for the same authorization multiple times." + ), + ) require_consent = models.BooleanField( default=True, - verbose_name=_('Require Consent?'), - help_text=_('If disabled, the Server will NEVER ask the user for consent.')) + verbose_name=_("Require Consent?"), + help_text=_("If disabled, the Server will NEVER ask the user for consent."), + ) _redirect_uris = models.TextField( - default='', verbose_name=_(u'Redirect URIs'), - help_text=_(u'Enter each URI on a new line.')) + default="", + verbose_name=_("Redirect URIs"), + help_text=_("Enter each URI on a new line."), + ) _post_logout_redirect_uris = models.TextField( blank=True, - default='', - verbose_name=_(u'Post Logout Redirect URIs'), - help_text=_(u'Enter each URI on a new line.')) + default="", + verbose_name=_("Post Logout Redirect URIs"), + help_text=_("Enter each URI on a new line."), + ) _scope = models.TextField( blank=True, - default='', - verbose_name=_(u'Scopes'), - help_text=_('Specifies the authorized scope values for the client app.')) + default="", + verbose_name=_("Scopes"), + help_text=_("Specifies the authorized scope values for the client app."), + ) class Meta: - verbose_name = _(u'Client') - verbose_name_plural = _(u'Clients') + verbose_name = _("Client") + verbose_name_plural = _("Clients") def __str__(self): - return u'{0}'.format(self.name) + return "{0}".format(self.name) def __unicode__(self): return self.__str__() @@ -126,7 +156,9 @@ def response_type_values(self): def response_type_descriptions(self): # return as a list, rather than a generator, so descriptions display correctly in admin - return [response_type.description for response_type in self.response_types.all()] + return [ + response_type.description for response_type in self.response_types.all() + ] @property def redirect_uris(self): @@ -134,7 +166,7 @@ def redirect_uris(self): @redirect_uris.setter def redirect_uris(self, value): - self._redirect_uris = '\n'.join(value) + self._redirect_uris = "\n".join(value) @property def post_logout_redirect_uris(self): @@ -142,7 +174,7 @@ def post_logout_redirect_uris(self): @post_logout_redirect_uris.setter def post_logout_redirect_uris(self, value): - self._post_logout_redirect_uris = '\n'.join(value) + self._post_logout_redirect_uris = "\n".join(value) @property def scope(self): @@ -150,18 +182,20 @@ def scope(self): @scope.setter def scope(self, value): - self._scope = ' '.join(value) + self._scope = " ".join(value) @property def default_redirect_uri(self): - return self.redirect_uris[0] if self.redirect_uris else '' + return self.redirect_uris[0] if self.redirect_uris else "" class BaseCodeTokenModel(models.Model): - client = models.ForeignKey(Client, verbose_name=_(u'Client'), on_delete=models.CASCADE) - expires_at = models.DateTimeField(verbose_name=_(u'Expiration Date')) - _scope = models.TextField(default='', verbose_name=_(u'Scopes')) + client = models.ForeignKey( + Client, verbose_name=_("Client"), on_delete=models.CASCADE + ) + expires_at = models.DateTimeField(verbose_name=_("Expiration Date")) + _scope = models.TextField(default="", verbose_name=_("Scopes")) class Meta: abstract = True @@ -172,7 +206,7 @@ def scope(self): @scope.setter def scope(self, value): - self._scope = ' '.join(value) + self._scope = " ".join(value) def __unicode__(self): return self.__str__() @@ -184,33 +218,49 @@ def has_expired(self): class Code(BaseCodeTokenModel): user = models.ForeignKey( - settings.AUTH_USER_MODEL, verbose_name=_(u'User'), on_delete=models.CASCADE) - code = models.CharField(max_length=255, unique=True, verbose_name=_(u'Code')) - nonce = models.CharField(max_length=255, blank=True, default='', verbose_name=_(u'Nonce')) - is_authentication = models.BooleanField(default=False, verbose_name=_(u'Is Authentication?')) - code_challenge = models.CharField(max_length=255, null=True, verbose_name=_(u'Code Challenge')) + settings.AUTH_USER_MODEL, verbose_name=_("User"), on_delete=models.CASCADE + ) + code = models.CharField(max_length=255, unique=True, verbose_name=_("Code")) + nonce = models.CharField( + max_length=255, blank=True, default="", verbose_name=_("Nonce") + ) + is_authentication = models.BooleanField( + default=False, verbose_name=_("Is Authentication?") + ) + code_challenge = models.CharField( + max_length=255, null=True, verbose_name=_("Code Challenge") + ) code_challenge_method = models.CharField( - max_length=255, null=True, verbose_name=_(u'Code Challenge Method')) + max_length=255, null=True, verbose_name=_("Code Challenge Method") + ) class Meta: - verbose_name = _(u'Authorization Code') - verbose_name_plural = _(u'Authorization Codes') + verbose_name = _("Authorization Code") + verbose_name_plural = _("Authorization Codes") def __str__(self): - return u'{0} - {1}'.format(self.client, self.code) + return "{0} - {1}".format(self.client, self.code) class Token(BaseCodeTokenModel): user = models.ForeignKey( - settings.AUTH_USER_MODEL, null=True, verbose_name=_(u'User'), on_delete=models.CASCADE) - access_token = models.CharField(max_length=255, unique=True, verbose_name=_(u'Access Token')) - refresh_token = models.CharField(max_length=255, unique=True, verbose_name=_(u'Refresh Token')) - _id_token = models.TextField(verbose_name=_(u'ID Token')) + settings.AUTH_USER_MODEL, + null=True, + verbose_name=_("User"), + on_delete=models.CASCADE, + ) + access_token = models.CharField( + max_length=255, unique=True, verbose_name=_("Access Token") + ) + refresh_token = models.CharField( + max_length=255, unique=True, verbose_name=_("Refresh Token") + ) + _id_token = models.TextField(verbose_name=_("ID Token")) class Meta: - verbose_name = _(u'Token') - verbose_name_plural = _(u'Tokens') + verbose_name = _("Token") + verbose_name_plural = _("Tokens") @property def id_token(self): @@ -221,47 +271,53 @@ def id_token(self, value): self._id_token = json.dumps(value) def __str__(self): - return u'{0} - {1}'.format(self.client, self.access_token) + return "{0} - {1}".format(self.client, self.access_token) @property def at_hash(self): # @@@ d-o-p only supports 256 bits (change this if that changes) - hashed_access_token = sha256( - self.access_token.encode('ascii') - ).hexdigest().encode('ascii') - return base64.urlsafe_b64encode( - binascii.unhexlify( - hashed_access_token[:len(hashed_access_token) // 2] + hashed_access_token = ( + sha256(self.access_token.encode("ascii")).hexdigest().encode("ascii") + ) + return ( + base64.urlsafe_b64encode( + binascii.unhexlify(hashed_access_token[: len(hashed_access_token) // 2]) ) - ).rstrip(b'=').decode('ascii') + .rstrip(b"=") + .decode("ascii") + ) class UserConsent(BaseCodeTokenModel): user = models.ForeignKey( - settings.AUTH_USER_MODEL, verbose_name=_(u'User'), on_delete=models.CASCADE) - date_given = models.DateTimeField(verbose_name=_(u'Date Given')) + settings.AUTH_USER_MODEL, verbose_name=_("User"), on_delete=models.CASCADE + ) + date_given = models.DateTimeField(verbose_name=_("Date Given")) class Meta: - unique_together = ('user', 'client') + unique_together = ("user", "client") class RSAKey(models.Model): key = models.TextField( - verbose_name=_(u'Key'), help_text=_(u'Paste your private RSA Key here.')) + verbose_name=_("Key"), help_text=_("Paste your private RSA Key here.") + ) class Meta: ordering = ["id"] - verbose_name = _(u'RSA Key') - verbose_name_plural = _(u'RSA Keys') + verbose_name = _("RSA Key") + verbose_name_plural = _("RSA Keys") def __str__(self): - return u'{0}'.format(self.kid) + return "{0}".format(self.kid) def __unicode__(self): return self.__str__() @property def kid(self): - return u'{0}'.format(md5(self.key.encode('utf-8')).hexdigest() if self.key else '') + return "{0}".format( + md5(self.key.encode("utf-8")).hexdigest() if self.key else "" + ) diff --git a/oidc_provider/settings.py b/oidc_provider/settings.py index 45fb1984..28fad55d 100644 --- a/oidc_provider/settings.py +++ b/oidc_provider/settings.py @@ -40,7 +40,7 @@ def OIDC_AFTER_USERLOGIN_HOOK(self): OPTIONAL. Provide a way to plug into the process after the user has logged in, typically to perform some business logic. """ - return 'oidc_provider.lib.utils.common.default_after_userlogin_hook' + return "oidc_provider.lib.utils.common.default_after_userlogin_hook" @property def OIDC_AFTER_END_SESSION_HOOK(self): @@ -48,14 +48,14 @@ def OIDC_AFTER_END_SESSION_HOOK(self): OPTIONAL. Provide a way to plug into the end session process just before calling Django's logout function, typically to perform some business logic. """ - return 'oidc_provider.lib.utils.common.default_after_end_session_hook' + return "oidc_provider.lib.utils.common.default_after_end_session_hook" @property def OIDC_CODE_EXPIRE(self): """ OPTIONAL. Code expiration time expressed in seconds. """ - return 60*10 + return 60 * 10 @property def OIDC_DISCOVERY_CACHE_ENABLE(self): @@ -69,7 +69,7 @@ def OIDC_DISCOVERY_CACHE_EXPIRE(self): """ OPTIONAL. Discovery endpoint cache expiration time expressed in seconds. """ - return 60*60*24 + return 60 * 60 * 24 @property def OIDC_EXTRA_SCOPE_CLAIMS(self): @@ -84,7 +84,7 @@ def OIDC_IDTOKEN_EXPIRE(self): """ OPTIONAL. Id token expiration time expressed in seconds. """ - return 60*10 + return 60 * 10 @property def OIDC_IDTOKEN_SUB_GENERATOR(self): @@ -93,7 +93,7 @@ def OIDC_IDTOKEN_SUB_GENERATOR(self): reassigned identifier within the Issuer for the End-User, which is intended to be consumed by the Client. """ - return 'oidc_provider.lib.utils.common.default_sub_generator' + return "oidc_provider.lib.utils.common.default_sub_generator" @property def OIDC_IDTOKEN_INCLUDE_CLAIMS(self): @@ -117,8 +117,10 @@ def OIDC_UNAUTHENTICATED_SESSION_MANAGEMENT_KEY(self): # Memoize generated value if not self._unauthenticated_session_management_key: - self._unauthenticated_session_management_key = ''.join( - random.choice(string.ascii_uppercase + string.digits) for _ in range(100)) + self._unauthenticated_session_management_key = "".join( + random.choice(string.ascii_uppercase + string.digits) + for _ in range(100) + ) return self._unauthenticated_session_management_key @property @@ -126,7 +128,7 @@ def OIDC_SKIP_CONSENT_EXPIRE(self): """ OPTIONAL. User consent expiration after been granted. """ - return 30*3 + return 30 * 3 @property def OIDC_TOKEN_EXPIRE(self): @@ -134,7 +136,7 @@ def OIDC_TOKEN_EXPIRE(self): OPTIONAL. Token object expiration after been created. Expressed in seconds. """ - return 60*60 + return 60 * 60 @property def OIDC_USERINFO(self): @@ -142,7 +144,7 @@ def OIDC_USERINFO(self): OPTIONAL. A string with the location of your function. Used to populate standard claims with your user information. """ - return 'oidc_provider.lib.utils.common.default_userinfo' + return "oidc_provider.lib.utils.common.default_userinfo" @property def OIDC_IDTOKEN_PROCESSING_HOOK(self): @@ -150,7 +152,7 @@ def OIDC_IDTOKEN_PROCESSING_HOOK(self): OPTIONAL. A string with the location of your hook. Used to add extra dictionary values specific for your app into id_token. """ - return 'oidc_provider.lib.utils.common.default_idtoken_processing_hook' + return "oidc_provider.lib.utils.common.default_idtoken_processing_hook" @property def OIDC_IDTOKEN_CREATE_HOOK(self): @@ -158,7 +160,7 @@ def OIDC_IDTOKEN_CREATE_HOOK(self): OPTIONAL. A string with the location of your hook. Used to create a dictionary that will be the payload of the id_token. """ - return 'oidc_provider.lib.utils.token.create_id_token' + return "oidc_provider.lib.utils.token.create_id_token" @property def OIDC_IDTOKEN_ENCODE_HOOK(self): @@ -166,7 +168,7 @@ def OIDC_IDTOKEN_ENCODE_HOOK(self): OPTIONAL. A string with the location of your hook. Used to encode a dictionary that will be the payload of the id_token. """ - return 'oidc_provider.lib.utils.token.encode_id_token' + return "oidc_provider.lib.utils.token.encode_id_token" @property def OIDC_INTROSPECTION_PROCESSING_HOOK(self): @@ -174,7 +176,7 @@ def OIDC_INTROSPECTION_PROCESSING_HOOK(self): OPTIONAL. A string with the location of your function. Used to update the response for a valid introspection token request. """ - return 'oidc_provider.lib.utils.common.default_introspection_processing_hook' + return "oidc_provider.lib.utils.common.default_introspection_processing_hook" @property def OIDC_INTROSPECTION_VALIDATE_AUDIENCE_SCOPE(self): @@ -203,8 +205,8 @@ def OIDC_GRANT_TYPE_PASSWORD_ENABLE(self): @property def OIDC_TEMPLATES(self): return { - 'authorize': 'oidc_provider/authorize.html', - 'error': 'oidc_provider/error.html' + "authorize": "oidc_provider/authorize.html", + "error": "oidc_provider/error.html", } @property @@ -219,7 +221,7 @@ def OIDC_SCOPES_SUPPORTED(self): """ OPTIONAL: A list of scopes supported by the OP. """ - return ['openid'] + return ["openid"] default_settings = DefaultSettings() @@ -230,12 +232,16 @@ def import_from_str(value): Attempt to import a class from a string representation. """ try: - parts = value.split('.') - module_path, class_name = '.'.join(parts[:-1]), parts[-1] + parts = value.split(".") + module_path, class_name = ".".join(parts[:-1]), parts[-1] module = importlib.import_module(module_path) return getattr(module, class_name) except ImportError as e: - msg = 'Could not import %s for settings. %s: %s.' % (value, e.__class__.__name__, e) + msg = "Could not import %s for settings. %s: %s." % ( + value, + e.__class__.__name__, + e, + ) raise ImportError(msg) @@ -255,7 +261,7 @@ def get(name, import_str=False): value = getattr(settings, name) except AttributeError: if name in default_settings.required_attrs: - raise Exception('You must set ' + name + ' in your settings.') + raise Exception("You must set " + name + " in your settings.") if isinstance(default_value, dict) and value: default_value.update(value) diff --git a/oidc_provider/tests/app/urls.py b/oidc_provider/tests/app/urls.py index a7f8c943..7a46b741 100644 --- a/oidc_provider/tests/app/urls.py +++ b/oidc_provider/tests/app/urls.py @@ -10,13 +10,17 @@ urlpatterns = [ - re_path(r'^$', TemplateView.as_view(template_name='home.html'), name='home'), - re_path(r'^accounts/login/$', - auth_views.LoginView.as_view(template_name='accounts/login.html'), - name='login'), - re_path(r'^accounts/logout/$', - auth_views.LogoutView.as_view(template_name='accounts/logout.html'), - name='logout'), - re_path(r'^openid/', include('oidc_provider.urls', namespace='oidc_provider')), - re_path(r'^admin/', admin.site.urls), + re_path(r"^$", TemplateView.as_view(template_name="home.html"), name="home"), + re_path( + r"^accounts/login/$", + auth_views.LoginView.as_view(template_name="accounts/login.html"), + name="login", + ), + re_path( + r"^accounts/logout/$", + auth_views.LogoutView.as_view(template_name="accounts/logout.html"), + name="logout", + ), + re_path(r"^openid/", include("oidc_provider.urls", namespace="oidc_provider")), + re_path(r"^admin/", admin.site.urls), ] diff --git a/oidc_provider/tests/app/utils.py b/oidc_provider/tests/app/utils.py index 491af0e5..eafc9323 100644 --- a/oidc_provider/tests/app/utils.py +++ b/oidc_provider/tests/app/utils.py @@ -178,6 +178,8 @@ def fake_introspection_processing_hook(response_dict, client, id_token): class TestAuthBackend: def authenticate(self, *args, **kwargs): - if django.VERSION[0] >= 2 or (django.VERSION[0] == 1 and django.VERSION[1] >= 11): + if django.VERSION[0] >= 2 or ( + django.VERSION[0] == 1 and django.VERSION[1] >= 11 + ): assert len(args) > 0 and args[0] return ModelBackend().authenticate(*args, **kwargs) diff --git a/oidc_provider/tests/cases/test_authorize_endpoint.py b/oidc_provider/tests/cases/test_authorize_endpoint.py index 7ccd0a23..2429043a 100644 --- a/oidc_provider/tests/cases/test_authorize_endpoint.py +++ b/oidc_provider/tests/cases/test_authorize_endpoint.py @@ -11,6 +11,7 @@ from django.contrib.auth.models import AnonymousUser from django.core.management import call_command + try: from django.urls import reverse except ImportError: @@ -40,17 +41,17 @@ class AuthorizeEndpointMixin(object): def _auth_request(self, method, data=None, is_user_authenticated=False): if data is None: data = {} - url = reverse('oidc_provider:authorize') + url = reverse("oidc_provider:authorize") - if method.lower() == 'get': - query_str = urlencode(data).replace('+', '%20') + if method.lower() == "get": + query_str = urlencode(data).replace("+", "%20") if query_str: - url += '?' + query_str + url += "?" + query_str request = self.factory.get(url) - elif method.lower() == 'post': + elif method.lower() == "post": request = self.factory.post(url, data=data) else: - raise Exception('Method unsupported for an Authorization Request.') + raise Exception("Method unsupported for an Authorization Request.") # Simulate that the user is logged. request.user = self.user if is_user_authenticated else AnonymousUser() @@ -66,15 +67,17 @@ class AuthorizationCodeFlowTestCase(TestCase, AuthorizeEndpointMixin): """ def setUp(self): - call_command('creatersakey') + call_command("creatersakey") self.factory = RequestFactory() self.user = create_fake_user() - self.client = create_fake_client(response_type='code') + self.client = create_fake_client(response_type="code") self.client_with_no_consent = create_fake_client( - response_type='code', require_consent=False) - self.client_public = create_fake_client(response_type='code', is_public=True) + response_type="code", require_consent=False + ) + self.client_public = create_fake_client(response_type="code", is_public=True) self.client_public_with_no_consent = create_fake_client( - response_type='code', is_public=True, require_consent=False) + response_type="code", is_public=True, require_consent=False + ) self.state = uuid.uuid4().hex self.nonce = uuid.uuid4().hex @@ -86,7 +89,7 @@ def test_missing_parameters(self): See: https://tools.ietf.org/html/rfc6749#section-4.1.2.1 """ - response = self._auth_request('get') + response = self._auth_request("get") self.assertEqual(response.status_code, 200) self.assertEqual(bool(response.content), True) @@ -100,20 +103,20 @@ def test_invalid_response_type(self): """ # Create an authorize request with an unsupported response_type. data = { - 'client_id': self.client.client_id, - 'response_type': 'something_wrong', - 'redirect_uri': self.client.default_redirect_uri, - 'scope': 'openid email', - 'state': self.state, + "client_id": self.client.client_id, + "response_type": "something_wrong", + "redirect_uri": self.client.default_redirect_uri, + "scope": "openid email", + "state": self.state, } - response = self._auth_request('get', data) + response = self._auth_request("get", data) self.assertEqual(response.status_code, 302) - self.assertEqual(response.has_header('Location'), True) + self.assertEqual(response.has_header("Location"), True) # Should be an 'error' component in query. - self.assertIn('error=', response['Location']) + self.assertIn("error=", response["Location"]) def test_user_not_logged(self): """ @@ -123,17 +126,17 @@ def test_user_not_logged(self): See: http://openid.net/specs/openid-connect-core-1_0.html#Authenticates """ data = { - 'client_id': self.client.client_id, - 'response_type': 'code', - 'redirect_uri': self.client.default_redirect_uri, - 'scope': 'openid email', - 'state': self.state, + "client_id": self.client.client_id, + "response_type": "code", + "redirect_uri": self.client.default_redirect_uri, + "scope": "openid email", + "state": self.state, } - response = self._auth_request('get', data) + response = self._auth_request("get", data) # Check if user was redirected to the login view. - self.assertIn(settings.get('OIDC_LOGIN_URL'), response['Location']) + self.assertIn(settings.get("OIDC_LOGIN_URL"), response["Location"]) def test_user_consent_inputs(self): """ @@ -144,33 +147,37 @@ def test_user_consent_inputs(self): See: http://openid.net/specs/openid-connect-core-1_0.html#Consent """ data = { - 'client_id': self.client.client_id, - 'response_type': 'code', - 'redirect_uri': self.client.default_redirect_uri, - 'scope': 'openid email', - 'state': self.state, + "client_id": self.client.client_id, + "response_type": "code", + "redirect_uri": self.client.default_redirect_uri, + "scope": "openid email", + "state": self.state, # PKCE parameters. - 'code_challenge': FAKE_CODE_CHALLENGE, - 'code_challenge_method': 'S256', + "code_challenge": FAKE_CODE_CHALLENGE, + "code_challenge_method": "S256", } - response = self._auth_request('get', data, is_user_authenticated=True) + response = self._auth_request("get", data, is_user_authenticated=True) # Check if hidden inputs exists in the form, # also if their values are valid. input_html = '' to_check = { - 'client_id': self.client.client_id, - 'redirect_uri': self.client.default_redirect_uri, - 'response_type': 'code', - 'code_challenge': FAKE_CODE_CHALLENGE, - 'code_challenge_method': 'S256', + "client_id": self.client.client_id, + "redirect_uri": self.client.default_redirect_uri, + "response_type": "code", + "code_challenge": FAKE_CODE_CHALLENGE, + "code_challenge_method": "S256", } for key, value in iter(to_check.items()): - is_input_ok = input_html.format(key, value) in response.content.decode('utf-8') - self.assertEqual(is_input_ok, True, msg='Hidden input for "' + key + '" fails.') + is_input_ok = input_html.format(key, value) in response.content.decode( + "utf-8" + ) + self.assertEqual( + is_input_ok, True, msg='Hidden input for "' + key + '" fails.' + ) def test_user_consent_response(self): """ @@ -185,37 +192,42 @@ def test_user_consent_response(self): by adding them as query parameters to the redirect_uri. """ data = { - 'client_id': self.client.client_id, - 'redirect_uri': self.client.default_redirect_uri, - 'response_type': 'code', - 'scope': 'openid email', - 'state': self.state, + "client_id": self.client.client_id, + "redirect_uri": self.client.default_redirect_uri, + "response_type": "code", + "scope": "openid email", + "state": self.state, # PKCE parameters. - 'code_challenge': FAKE_CODE_CHALLENGE, - 'code_challenge_method': 'S256', + "code_challenge": FAKE_CODE_CHALLENGE, + "code_challenge_method": "S256", } - response = self._auth_request('post', data, is_user_authenticated=True) + response = self._auth_request("post", data, is_user_authenticated=True) # Because user doesn't allow app, SHOULD exists an error parameter # in the query. - self.assertIn('error=', response['Location'], msg='error param is missing in query.') self.assertIn( - 'access_denied', response['Location'], msg='"access_denied" code is missing in query.') + "error=", response["Location"], msg="error param is missing in query." + ) + self.assertIn( + "access_denied", + response["Location"], + msg='"access_denied" code is missing in query.', + ) # Simulate user authorization. - data['allow'] = 'Accept' # Will be the value of the button. + data["allow"] = "Accept" # Will be the value of the button. - response = self._auth_request('post', data, is_user_authenticated=True) + response = self._auth_request("post", data, is_user_authenticated=True) - is_code_ok = is_code_valid(url=response['Location'], - user=self.user, - client=self.client) - self.assertEqual(is_code_ok, True, msg='Code returned is invalid.') + is_code_ok = is_code_valid( + url=response["Location"], user=self.user, client=self.client + ) + self.assertEqual(is_code_ok, True, msg="Code returned is invalid.") # Check if the state is returned. - state = (response['Location'].split('state='))[1].split('&')[0] - self.assertEqual(state, self.state, msg='State change or is missing.') + state = (response["Location"].split("state="))[1].split("&")[0] + self.assertEqual(state, self.state, msg="State change or is missing.") def test_user_consent_skipped(self): """ @@ -224,37 +236,38 @@ def test_user_consent_skipped(self): authorization multiple times, the server skip it. """ data = { - 'client_id': self.client_with_no_consent.client_id, - 'redirect_uri': self.client_with_no_consent.default_redirect_uri, - 'response_type': 'code', - 'scope': 'openid email', - 'state': self.state, - 'allow': 'Accept', + "client_id": self.client_with_no_consent.client_id, + "redirect_uri": self.client_with_no_consent.default_redirect_uri, + "response_type": "code", + "scope": "openid email", + "state": self.state, + "allow": "Accept", } - request = self.factory.post(reverse('oidc_provider:authorize'), - data=data) + request = self.factory.post(reverse("oidc_provider:authorize"), data=data) # Simulate that the user is logged. request.user = self.user - response = self._auth_request('post', data, is_user_authenticated=True) + response = self._auth_request("post", data, is_user_authenticated=True) - self.assertIn('code', response['Location'], msg='Code is missing in the returned url.') + self.assertIn( + "code", response["Location"], msg="Code is missing in the returned url." + ) - response = self._auth_request('post', data, is_user_authenticated=True) + response = self._auth_request("post", data, is_user_authenticated=True) - is_code_ok = is_code_valid(url=response['Location'], - user=self.user, - client=self.client_with_no_consent) - self.assertEqual(is_code_ok, True, msg='Code returned is invalid.') + is_code_ok = is_code_valid( + url=response["Location"], user=self.user, client=self.client_with_no_consent + ) + self.assertEqual(is_code_ok, True, msg="Code returned is invalid.") - del data['allow'] - response = self._auth_request('get', data, is_user_authenticated=True) + del data["allow"] + response = self._auth_request("get", data, is_user_authenticated=True) - is_code_ok = is_code_valid(url=response['Location'], - user=self.user, - client=self.client_with_no_consent) - self.assertEqual(is_code_ok, True, msg='Code returned is invalid or missing.') + is_code_ok = is_code_valid( + url=response["Location"], user=self.user, client=self.client_with_no_consent + ) + self.assertEqual(is_code_ok, True, msg="Code returned is invalid or missing.") def test_response_uri_is_properly_constructed(self): """ @@ -262,33 +275,36 @@ def test_response_uri_is_properly_constructed(self): Only 'state' and 'code' should be appended. """ data = { - 'client_id': self.client.client_id, - 'redirect_uri': self.client.default_redirect_uri, - 'response_type': 'code', - 'scope': 'openid email', - 'state': self.state, - 'allow': 'Accept', + "client_id": self.client.client_id, + "redirect_uri": self.client.default_redirect_uri, + "response_type": "code", + "scope": "openid email", + "state": self.state, + "allow": "Accept", } - response = self._auth_request('post', data, is_user_authenticated=True) + response = self._auth_request("post", data, is_user_authenticated=True) - parsed = urlsplit(response['Location']) + parsed = urlsplit(response["Location"]) params = parse_qs(parsed.query or parsed.fragment) - state = params['state'][0] + state = params["state"][0] self.assertEqual(self.state, state, msg="State returned is invalid or missing") - is_code_ok = is_code_valid(url=response['Location'], - user=self.user, - client=self.client) - self.assertTrue(is_code_ok, msg='Code returned is invalid or missing') + is_code_ok = is_code_valid( + url=response["Location"], user=self.user, client=self.client + ) + self.assertTrue(is_code_ok, msg="Code returned is invalid or missing") self.assertEqual( - set(params.keys()), {'state', 'code'}, - msg='More than state or code appended as query params') + set(params.keys()), + {"state", "code"}, + msg="More than state or code appended as query params", + ) self.assertTrue( - response['Location'].startswith(self.client.default_redirect_uri), - msg='Different redirect_uri returned') + response["Location"].startswith(self.client.default_redirect_uri), + msg="Different redirect_uri returned", + ) def test_unknown_redirect_uris_are_rejected(self): """ @@ -296,16 +312,19 @@ def test_unknown_redirect_uris_are_rejected(self): See http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest. """ data = { - 'client_id': self.client.client_id, - 'response_type': 'code', - 'redirect_uri': 'http://neverseenthis.com', - 'scope': 'openid email', - 'state': self.state, + "client_id": self.client.client_id, + "response_type": "code", + "redirect_uri": "http://neverseenthis.com", + "scope": "openid email", + "state": self.state, } - response = self._auth_request('get', data) + response = self._auth_request("get", data) self.assertIn( - RedirectUriError.error, response.content.decode('utf-8'), msg='No redirect_uri error') + RedirectUriError.error, + response.content.decode("utf-8"), + msg="No redirect_uri error", + ) def test_manipulated_redirect_uris_are_rejected(self): """ @@ -313,16 +332,19 @@ def test_manipulated_redirect_uris_are_rejected(self): See http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest. """ data = { - 'client_id': self.client.client_id, - 'response_type': 'code', - 'redirect_uri': self.client.default_redirect_uri + "?some=query", - 'scope': 'openid email', - 'state': self.state, + "client_id": self.client.client_id, + "response_type": "code", + "redirect_uri": self.client.default_redirect_uri + "?some=query", + "scope": "openid email", + "state": self.state, } - response = self._auth_request('get', data) + response = self._auth_request("get", data) self.assertIn( - RedirectUriError.error, response.content.decode('utf-8'), msg='No redirect_uri error') + RedirectUriError.error, + response.content.decode("utf-8"), + msg="No redirect_uri error", + ) def test_public_client_auto_approval(self): """ @@ -330,16 +352,16 @@ def test_public_client_auto_approval(self): clients using Authorization Code. """ data = { - 'client_id': self.client_public_with_no_consent.client_id, - 'response_type': 'code', - 'redirect_uri': self.client_public_with_no_consent.default_redirect_uri, - 'scope': 'openid email', - 'state': self.state, + "client_id": self.client_public_with_no_consent.client_id, + "response_type": "code", + "redirect_uri": self.client_public_with_no_consent.default_redirect_uri, + "scope": "openid email", + "state": self.state, } - response = self._auth_request('get', data, is_user_authenticated=True) + response = self._auth_request("get", data, is_user_authenticated=True) - self.assertIn('Request for Permission', response.content.decode('utf-8')) + self.assertIn("Request for Permission", response.content.decode("utf-8")) def test_prompt_none_parameter(self): """ @@ -348,26 +370,26 @@ def test_prompt_none_parameter(self): See: http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest """ data = { - 'client_id': self.client.client_id, - 'response_type': next(self.client.response_type_values()), - 'redirect_uri': self.client.default_redirect_uri, - 'scope': 'openid email', - 'state': self.state, - 'prompt': 'none' + "client_id": self.client.client_id, + "response_type": next(self.client.response_type_values()), + "redirect_uri": self.client.default_redirect_uri, + "scope": "openid email", + "state": self.state, + "prompt": "none", } - response = self._auth_request('get', data) + response = self._auth_request("get", data) # An error is returned if an End-User is not already authenticated. - self.assertIn('login_required', response['Location']) + self.assertIn("login_required", response["Location"]) - response = self._auth_request('get', data, is_user_authenticated=True) + response = self._auth_request("get", data, is_user_authenticated=True) # An error is returned if the Client does not have pre-configured # consent for the requested Claims. - self.assertIn('consent_required', response['Location']) + self.assertIn("consent_required", response["Location"]) - @patch('oidc_provider.views.django_user_logout') + @patch("oidc_provider.views.django_user_logout") def test_prompt_login_parameter(self, logout_function): """ Specifies whether the Authorization Server prompts the End-User for @@ -375,31 +397,31 @@ def test_prompt_login_parameter(self, logout_function): See: http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest """ data = { - 'client_id': self.client.client_id, - 'response_type': next(self.client.response_type_values()), - 'redirect_uri': self.client.default_redirect_uri, - 'scope': 'openid email', - 'state': self.state, - 'prompt': 'login' + "client_id": self.client.client_id, + "response_type": next(self.client.response_type_values()), + "redirect_uri": self.client.default_redirect_uri, + "scope": "openid email", + "state": self.state, + "prompt": "login", } - response = self._auth_request('get', data) - self.assertIn(settings.get('OIDC_LOGIN_URL'), response['Location']) + response = self._auth_request("get", data) + self.assertIn(settings.get("OIDC_LOGIN_URL"), response["Location"]) self.assertNotIn( - quote('prompt=login'), - response['Location'], + quote("prompt=login"), + response["Location"], "Found prompt=login, this leads to infinite login loop. See " - "https://github.com/juanifioren/django-oidc-provider/issues/197." + "https://github.com/juanifioren/django-oidc-provider/issues/197.", ) - response = self._auth_request('get', data, is_user_authenticated=True) - self.assertIn(settings.get('OIDC_LOGIN_URL'), response['Location']) + response = self._auth_request("get", data, is_user_authenticated=True) + self.assertIn(settings.get("OIDC_LOGIN_URL"), response["Location"]) logout_function.assert_called_once() self.assertNotIn( - quote('prompt=login'), - response['Location'], + quote("prompt=login"), + response["Location"], "Found prompt=login, this leads to infinite login loop. See " - "https://github.com/juanifioren/django-oidc-provider/issues/197." + "https://github.com/juanifioren/django-oidc-provider/issues/197.", ) def test_prompt_login_none_parameter(self): @@ -409,21 +431,21 @@ def test_prompt_login_none_parameter(self): See: http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest """ data = { - 'client_id': self.client.client_id, - 'response_type': next(self.client.response_type_values()), - 'redirect_uri': self.client.default_redirect_uri, - 'scope': 'openid email', - 'state': self.state, - 'prompt': 'login none' + "client_id": self.client.client_id, + "response_type": next(self.client.response_type_values()), + "redirect_uri": self.client.default_redirect_uri, + "scope": "openid email", + "state": self.state, + "prompt": "login none", } - response = self._auth_request('get', data) - self.assertIn('login_required', response['Location']) + response = self._auth_request("get", data) + self.assertIn("login_required", response["Location"]) - response = self._auth_request('get', data, is_user_authenticated=True) - self.assertIn('login_required', response['Location']) + response = self._auth_request("get", data, is_user_authenticated=True) + self.assertIn("login_required", response["Location"]) - @patch('oidc_provider.views.render') + @patch("oidc_provider.views.render") def test_prompt_consent_parameter(self, render_patched): """ Specifies whether the Authorization Server prompts the End-User for @@ -431,21 +453,22 @@ def test_prompt_consent_parameter(self, render_patched): See: http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest """ data = { - 'client_id': self.client.client_id, - 'response_type': next(self.client.response_type_values()), - 'redirect_uri': self.client.default_redirect_uri, - 'scope': 'openid email', - 'state': self.state, - 'prompt': 'consent' + "client_id": self.client.client_id, + "response_type": next(self.client.response_type_values()), + "redirect_uri": self.client.default_redirect_uri, + "scope": "openid email", + "state": self.state, + "prompt": "consent", } - response = self._auth_request('get', data) - self.assertIn(settings.get('OIDC_LOGIN_URL'), response['Location']) + response = self._auth_request("get", data) + self.assertIn(settings.get("OIDC_LOGIN_URL"), response["Location"]) - response = self._auth_request('get', data, is_user_authenticated=True) + response = self._auth_request("get", data, is_user_authenticated=True) render_patched.assert_called_once() self.assertTrue( - render_patched.call_args[0][1], settings.get('OIDC_TEMPLATES')['authorize']) + render_patched.call_args[0][1], settings.get("OIDC_TEMPLATES")["authorize"] + ) def test_prompt_consent_none_parameter(self): """ @@ -454,47 +477,51 @@ def test_prompt_consent_none_parameter(self): See: http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest """ data = { - 'client_id': self.client.client_id, - 'response_type': next(self.client.response_type_values()), - 'redirect_uri': self.client.default_redirect_uri, - 'scope': 'openid email', - 'state': self.state, - 'prompt': 'consent none' + "client_id": self.client.client_id, + "response_type": next(self.client.response_type_values()), + "redirect_uri": self.client.default_redirect_uri, + "scope": "openid email", + "state": self.state, + "prompt": "consent none", } - response = self._auth_request('get', data) - self.assertIn('login_required', response['Location']) + response = self._auth_request("get", data) + self.assertIn("login_required", response["Location"]) - response = self._auth_request('get', data, is_user_authenticated=True) - self.assertIn('consent_required', response['Location']) + response = self._auth_request("get", data, is_user_authenticated=True) + self.assertIn("consent_required", response["Location"]) def test_strip_prompt_login(self): """ Test for helper method test_strip_prompt_login. """ # Original paths - path0 = 'http://idp.com/?prompt=login' - path1 = 'http://idp.com/?prompt=consent login none' - path2 = ('http://idp.com/?response_type=code&client' + - '_id=112233&prompt=consent login') - path3 = ('http://idp.com/?response_type=code&client' + - '_id=112233&prompt=login none&redirect_uri' + - '=http://localhost:8000') + path0 = "http://idp.com/?prompt=login" + path1 = "http://idp.com/?prompt=consent login none" + path2 = ( + "http://idp.com/?response_type=code&client" + + "_id=112233&prompt=consent login" + ) + path3 = ( + "http://idp.com/?response_type=code&client" + + "_id=112233&prompt=login none&redirect_uri" + + "=http://localhost:8000" + ) - self.assertNotIn('prompt', strip_prompt_login(path0)) + self.assertNotIn("prompt", strip_prompt_login(path0)) - self.assertIn('prompt', strip_prompt_login(path1)) - self.assertIn('consent', strip_prompt_login(path1)) - self.assertIn('none', strip_prompt_login(path1)) - self.assertNotIn('login', strip_prompt_login(path1)) + self.assertIn("prompt", strip_prompt_login(path1)) + self.assertIn("consent", strip_prompt_login(path1)) + self.assertIn("none", strip_prompt_login(path1)) + self.assertNotIn("login", strip_prompt_login(path1)) - self.assertIn('prompt', strip_prompt_login(path2)) - self.assertIn('consent', strip_prompt_login(path1)) - self.assertNotIn('login', strip_prompt_login(path2)) + self.assertIn("prompt", strip_prompt_login(path2)) + self.assertIn("consent", strip_prompt_login(path1)) + self.assertNotIn("login", strip_prompt_login(path2)) - self.assertIn('prompt', strip_prompt_login(path3)) - self.assertIn('none', strip_prompt_login(path3)) - self.assertNotIn('login', strip_prompt_login(path3)) + self.assertIn("prompt", strip_prompt_login(path3)) + self.assertIn("none", strip_prompt_login(path3)) + self.assertNotIn("login", strip_prompt_login(path3)) class AuthorizationImplicitFlowTestCase(TestCase, AuthorizeEndpointMixin): @@ -503,18 +530,23 @@ class AuthorizationImplicitFlowTestCase(TestCase, AuthorizeEndpointMixin): """ def setUp(self): - call_command('creatersakey') + call_command("creatersakey") self.factory = RequestFactory() self.user = create_fake_user() - self.client = create_fake_client(response_type='id_token token') - self.client_public = create_fake_client(response_type='id_token token', is_public=True) + self.client = create_fake_client(response_type="id_token token") + self.client_public = create_fake_client( + response_type="id_token token", is_public=True + ) self.client_public_no_consent = create_fake_client( - response_type='id_token token', is_public=True, - require_consent=False) - self.client_no_access = create_fake_client(response_type='id_token') - self.client_public_no_access = create_fake_client(response_type='id_token', is_public=True) + response_type="id_token token", is_public=True, require_consent=False + ) + self.client_no_access = create_fake_client(response_type="id_token") + self.client_public_no_access = create_fake_client( + response_type="id_token", is_public=True + ) self.client_multiple_response_types = create_fake_client( - response_type=('id_token', 'id_token token')) + response_type=("id_token", "id_token token") + ) self.state = uuid.uuid4().hex self.nonce = uuid.uuid4().hex @@ -523,16 +555,16 @@ def test_missing_nonce(self): The `nonce` parameter is REQUIRED if you use the Implicit Flow. """ data = { - 'client_id': self.client.client_id, - 'response_type': next(self.client.response_type_values()), - 'redirect_uri': self.client.default_redirect_uri, - 'scope': 'openid email', - 'state': self.state, + "client_id": self.client.client_id, + "response_type": next(self.client.response_type_values()), + "redirect_uri": self.client.default_redirect_uri, + "scope": "openid email", + "state": self.state, } - response = self._auth_request('get', data, is_user_authenticated=True) + response = self._auth_request("get", data, is_user_authenticated=True) - self.assertIn('#error=invalid_request', response['Location']) + self.assertIn("#error=invalid_request", response["Location"]) def test_idtoken_token_response(self): """ @@ -540,29 +572,29 @@ def test_idtoken_token_response(self): and access token as the result of the authorization request. """ data = { - 'client_id': self.client.client_id, - 'redirect_uri': self.client.default_redirect_uri, - 'response_type': next(self.client.response_type_values()), - 'scope': 'openid email', - 'state': self.state, - 'nonce': self.nonce, - 'allow': 'Accept', + "client_id": self.client.client_id, + "redirect_uri": self.client.default_redirect_uri, + "response_type": next(self.client.response_type_values()), + "scope": "openid email", + "state": self.state, + "nonce": self.nonce, + "allow": "Accept", } - response = self._auth_request('post', data, is_user_authenticated=True) + response = self._auth_request("post", data, is_user_authenticated=True) - self.assertIn('access_token', response['Location']) - self.assertIn('id_token', response['Location']) + self.assertIn("access_token", response["Location"]) + self.assertIn("id_token", response["Location"]) # same for public client - data['client_id'] = self.client_public.client_id, - data['redirect_uri'] = self.client_public.default_redirect_uri, - data['response_type'] = next(self.client_public.response_type_values()), + data["client_id"] = (self.client_public.client_id,) + data["redirect_uri"] = (self.client_public.default_redirect_uri,) + data["response_type"] = (next(self.client_public.response_type_values()),) - response = self._auth_request('post', data, is_user_authenticated=True) + response = self._auth_request("post", data, is_user_authenticated=True) - self.assertIn('access_token', response['Location']) - self.assertIn('id_token', response['Location']) + self.assertIn("access_token", response["Location"]) + self.assertIn("id_token", response["Location"]) def test_idtoken_response(self): """ @@ -570,29 +602,31 @@ def test_idtoken_response(self): only an id token as the result of the authorization request. """ data = { - 'client_id': self.client_no_access.client_id, - 'redirect_uri': self.client_no_access.default_redirect_uri, - 'response_type': next(self.client_no_access.response_type_values()), - 'scope': 'openid email', - 'state': self.state, - 'nonce': self.nonce, - 'allow': 'Accept', + "client_id": self.client_no_access.client_id, + "redirect_uri": self.client_no_access.default_redirect_uri, + "response_type": next(self.client_no_access.response_type_values()), + "scope": "openid email", + "state": self.state, + "nonce": self.nonce, + "allow": "Accept", } - response = self._auth_request('post', data, is_user_authenticated=True) + response = self._auth_request("post", data, is_user_authenticated=True) - self.assertNotIn('access_token', response['Location']) - self.assertIn('id_token', response['Location']) + self.assertNotIn("access_token", response["Location"]) + self.assertIn("id_token", response["Location"]) # same for public client - data['client_id'] = self.client_public_no_access.client_id, - data['redirect_uri'] = self.client_public_no_access.default_redirect_uri, - data['response_type'] = next(self.client_public_no_access.response_type_values()), + data["client_id"] = (self.client_public_no_access.client_id,) + data["redirect_uri"] = (self.client_public_no_access.default_redirect_uri,) + data["response_type"] = ( + next(self.client_public_no_access.response_type_values()), + ) - response = self._auth_request('post', data, is_user_authenticated=True) + response = self._auth_request("post", data, is_user_authenticated=True) - self.assertNotIn('access_token', response['Location']) - self.assertIn('id_token', response['Location']) + self.assertNotIn("access_token", response["Location"]) + self.assertIn("id_token", response["Location"]) def test_idtoken_token_at_hash(self): """ @@ -600,25 +634,25 @@ def test_idtoken_token_at_hash(self): `at_hash` in `id_token`. """ data = { - 'client_id': self.client.client_id, - 'redirect_uri': self.client.default_redirect_uri, - 'response_type': next(self.client.response_type_values()), - 'scope': 'openid email', - 'state': self.state, - 'nonce': self.nonce, - 'allow': 'Accept', + "client_id": self.client.client_id, + "redirect_uri": self.client.default_redirect_uri, + "response_type": next(self.client.response_type_values()), + "scope": "openid email", + "state": self.state, + "nonce": self.nonce, + "allow": "Accept", } - response = self._auth_request('post', data, is_user_authenticated=True) + response = self._auth_request("post", data, is_user_authenticated=True) - self.assertIn('id_token', response['Location']) + self.assertIn("id_token", response["Location"]) # obtain `id_token` portion of Location - components = urlsplit(response['Location']) + components = urlsplit(response["Location"]) fragment = parse_qs(components[4]) - id_token = JWT().unpack(fragment["id_token"][0].encode('utf-8')).payload() + id_token = JWT().unpack(fragment["id_token"][0].encode("utf-8")).payload() - self.assertIn('at_hash', id_token) + self.assertIn("at_hash", id_token) def test_idtoken_at_hash(self): """ @@ -626,74 +660,74 @@ def test_idtoken_at_hash(self): `at_hash` in `id_token`. """ data = { - 'client_id': self.client_no_access.client_id, - 'redirect_uri': self.client_no_access.default_redirect_uri, - 'response_type': next(self.client_no_access.response_type_values()), - 'scope': 'openid email', - 'state': self.state, - 'nonce': self.nonce, - 'allow': 'Accept', + "client_id": self.client_no_access.client_id, + "redirect_uri": self.client_no_access.default_redirect_uri, + "response_type": next(self.client_no_access.response_type_values()), + "scope": "openid email", + "state": self.state, + "nonce": self.nonce, + "allow": "Accept", } - response = self._auth_request('post', data, is_user_authenticated=True) + response = self._auth_request("post", data, is_user_authenticated=True) - self.assertIn('id_token', response['Location']) + self.assertIn("id_token", response["Location"]) # obtain `id_token` portion of Location - components = urlsplit(response['Location']) + components = urlsplit(response["Location"]) fragment = parse_qs(components[4]) - id_token = JWT().unpack(fragment["id_token"][0].encode('utf-8')).payload() + id_token = JWT().unpack(fragment["id_token"][0].encode("utf-8")).payload() - self.assertNotIn('at_hash', id_token) + self.assertNotIn("at_hash", id_token) def test_public_client_implicit_auto_approval(self): """ Public clients using Implicit Flow should be able to reuse consent. """ data = { - 'client_id': self.client_public_no_consent.client_id, - 'response_type': next(self.client_public_no_consent.response_type_values()), - 'redirect_uri': self.client_public_no_consent.default_redirect_uri, - 'scope': 'openid email', - 'state': self.state, - 'nonce': self.nonce, + "client_id": self.client_public_no_consent.client_id, + "response_type": next(self.client_public_no_consent.response_type_values()), + "redirect_uri": self.client_public_no_consent.default_redirect_uri, + "scope": "openid email", + "state": self.state, + "nonce": self.nonce, } - response = self._auth_request('get', data, is_user_authenticated=True) - response_text = response.content.decode('utf-8') - self.assertEqual(response_text, '') - components = urlsplit(response['Location']) + response = self._auth_request("get", data, is_user_authenticated=True) + response_text = response.content.decode("utf-8") + self.assertEqual(response_text, "") + components = urlsplit(response["Location"]) fragment = parse_qs(components[4]) - self.assertIn('access_token', fragment) - self.assertIn('id_token', fragment) - self.assertIn('expires_in', fragment) + self.assertIn("access_token", fragment) + self.assertIn("id_token", fragment) + self.assertIn("expires_in", fragment) def test_multiple_response_types(self): """ Clients should be able to be configured for multiple response types. """ data = { - 'client_id': self.client_multiple_response_types.client_id, - 'redirect_uri': self.client_multiple_response_types.default_redirect_uri, - 'response_type': 'id_token', - 'scope': 'openid email', - 'state': self.state, - 'nonce': self.nonce, - 'allow': 'Accept', + "client_id": self.client_multiple_response_types.client_id, + "redirect_uri": self.client_multiple_response_types.default_redirect_uri, + "response_type": "id_token", + "scope": "openid email", + "state": self.state, + "nonce": self.nonce, + "allow": "Accept", } - response = self._auth_request('post', data, is_user_authenticated=True) + response = self._auth_request("post", data, is_user_authenticated=True) - self.assertNotIn('access_token', response['Location']) - self.assertIn('id_token', response['Location']) + self.assertNotIn("access_token", response["Location"]) + self.assertIn("id_token", response["Location"]) # should also support "id_token token" response_type - data['response_type'] = 'id_token token' + data["response_type"] = "id_token token" - response = self._auth_request('post', data, is_user_authenticated=True) + response = self._auth_request("post", data, is_user_authenticated=True) - self.assertIn('access_token', response['Location']) - self.assertIn('id_token', response['Location']) + self.assertIn("access_token", response["Location"]) + self.assertIn("id_token", response["Location"]) class AuthorizationHybridFlowTestCase(TestCase, AuthorizeEndpointMixin): @@ -702,23 +736,26 @@ class AuthorizationHybridFlowTestCase(TestCase, AuthorizeEndpointMixin): """ def setUp(self): - call_command('creatersakey') + call_command("creatersakey") self.factory = RequestFactory() self.user = create_fake_user() self.client_code_idtoken_token = create_fake_client( - response_type='code id_token token', is_public=True) + response_type="code id_token token", is_public=True + ) self.state = uuid.uuid4().hex self.nonce = uuid.uuid4().hex # Base data for the auth request. self.data = { - 'client_id': self.client_code_idtoken_token.client_id, - 'redirect_uri': self.client_code_idtoken_token.default_redirect_uri, - 'response_type': next(self.client_code_idtoken_token.response_type_values()), - 'scope': 'openid email', - 'state': self.state, - 'nonce': self.nonce, - 'allow': 'Accept', + "client_id": self.client_code_idtoken_token.client_id, + "redirect_uri": self.client_code_idtoken_token.default_redirect_uri, + "response_type": next( + self.client_code_idtoken_token.response_type_values() + ), + "scope": "openid email", + "state": self.state, + "nonce": self.nonce, + "allow": "Accept", } def test_code_idtoken_token_response(self): @@ -726,49 +763,51 @@ def test_code_idtoken_token_response(self): Implicit client requesting `id_token token` receives both id token and access token as the result of the authorization request. """ - response = self._auth_request('post', self.data, is_user_authenticated=True) + response = self._auth_request("post", self.data, is_user_authenticated=True) - self.assertIn('#', response['Location']) - self.assertIn('access_token', response['Location']) - self.assertIn('id_token', response['Location']) - self.assertIn('state', response['Location']) - self.assertIn('code', response['Location']) + self.assertIn("#", response["Location"]) + self.assertIn("access_token", response["Location"]) + self.assertIn("id_token", response["Location"]) + self.assertIn("state", response["Location"]) + self.assertIn("code", response["Location"]) # Validate code. - is_code_ok = is_code_valid(url=response['Location'], - user=self.user, - client=self.client_code_idtoken_token) - self.assertEqual(is_code_ok, True, msg='Code returned is invalid.') + is_code_ok = is_code_valid( + url=response["Location"], + user=self.user, + client=self.client_code_idtoken_token, + ) + self.assertEqual(is_code_ok, True, msg="Code returned is invalid.") @override_settings(OIDC_TOKEN_EXPIRE=36000) def test_access_token_expiration(self): """ Add ten hours of expiration to access_token. Check for the expires_in query in fragment. """ - response = self._auth_request('post', self.data, is_user_authenticated=True) + response = self._auth_request("post", self.data, is_user_authenticated=True) - self.assertIn('expires_in=36000', response['Location']) + self.assertIn("expires_in=36000", response["Location"]) class TestCreateResponseURI(TestCase): def setUp(self): - url = reverse('oidc_provider:authorize') + url = reverse("oidc_provider:authorize") user = create_fake_user() - client = create_fake_client(response_type='code', is_public=True) + client = create_fake_client(response_type="code", is_public=True) # Base data to create a uri response data = { - 'client_id': client.client_id, - 'redirect_uri': client.default_redirect_uri, - 'response_type': next(client.response_type_values()), + "client_id": client.client_id, + "redirect_uri": client.default_redirect_uri, + "response_type": next(client.response_type_values()), } factory = RequestFactory() self.request = factory.post(url, data=data) self.request.user = user - @patch('oidc_provider.lib.endpoints.authorize.create_code') - @patch('oidc_provider.lib.endpoints.authorize.logger.exception') + @patch("oidc_provider.lib.endpoints.authorize.create_code") + @patch("oidc_provider.lib.endpoints.authorize.logger.exception") def test_create_response_uri_logs_to_error(self, log_exception, create_code): """ A lot can go wrong when creating a response uri and this is caught @@ -786,10 +825,13 @@ def test_create_response_uri_logs_to_error(self, log_exception, create_code): authorization_endpoint.create_response_uri() log_exception.assert_called_once_with( - '[Authorize] Error when trying to create response uri: %s', exception) + "[Authorize] Error when trying to create response uri: %s", exception + ) @override_settings(OIDC_SESSION_MANAGEMENT_ENABLE=True) - def test_create_response_uri_generates_session_state_if_session_management_enabled(self): + def test_create_response_uri_generates_session_state_if_session_management_enabled( + self, + ): # RequestFactory doesn't support sessions, so we mock it self.request.session = mock.Mock(session_key=None) @@ -797,4 +839,4 @@ def test_create_response_uri_generates_session_state_if_session_management_enabl authorization_endpoint.validate_params() uri = authorization_endpoint.create_response_uri() - self.assertIn('session_state=', uri) + self.assertIn("session_state=", uri) diff --git a/oidc_provider/tests/cases/test_claims.py b/oidc_provider/tests/cases/test_claims.py index 1610f773..56c2ed57 100644 --- a/oidc_provider/tests/cases/test_claims.py +++ b/oidc_provider/tests/cases/test_claims.py @@ -6,82 +6,88 @@ from six import text_type from oidc_provider.lib.claims import ScopeClaims, StandardScopeClaims, STANDARD_CLAIMS -from oidc_provider.tests.app.utils import create_fake_user, create_fake_client, create_fake_token +from oidc_provider.tests.app.utils import ( + create_fake_user, + create_fake_client, + create_fake_token, +) class ClaimsTestCase(TestCase): def setUp(self): self.user = create_fake_user() - self.scopes = ['openid', 'address', 'email', 'phone', 'profile', 'foo'] - self.client = create_fake_client('code') + self.scopes = ["openid", "address", "email", "phone", "profile", "foo"] + self.client = create_fake_client("code") self.token = create_fake_token(self.user, self.scopes, self.client) self.scopeClaims = ScopeClaims(self.token) def test_empty_standard_claims(self): - for v in [v for k, v in STANDARD_CLAIMS.items() if k != 'address']: - self.assertEqual(v, '') + for v in [v for k, v in STANDARD_CLAIMS.items() if k != "address"]: + self.assertEqual(v, "") - for v in STANDARD_CLAIMS['address'].values(): - self.assertEqual(v, '') + for v in STANDARD_CLAIMS["address"].values(): + self.assertEqual(v, "") def test_clean_dic(self): - """ assert that _clean_dic function returns a clean dictionnary - (no empty claims) """ + """assert that _clean_dic function returns a clean dictionnary + (no empty claims)""" dict_to_clean = { - 'phone_number_verified': '', - 'middle_name': '', - 'name': 'John Doe', - 'website': '', - 'profile': '', - 'family_name': 'Doe', - 'birthdate': '', - 'preferred_username': '', - 'picture': '', - 'zoneinfo': '', - 'locale': '', - 'gender': '', - 'updated_at': '', - 'address': {}, - 'given_name': 'John', - 'email_verified': '', - 'nickname': '', - 'email': u'johndoe@example.com', - 'phone_number': '', + "phone_number_verified": "", + "middle_name": "", + "name": "John Doe", + "website": "", + "profile": "", + "family_name": "Doe", + "birthdate": "", + "preferred_username": "", + "picture": "", + "zoneinfo": "", + "locale": "", + "gender": "", + "updated_at": "", + "address": {}, + "given_name": "John", + "email_verified": "", + "nickname": "", + "email": "johndoe@example.com", + "phone_number": "", } clean_dict = self.scopeClaims._clean_dic(dict_to_clean) self.assertEqual( clean_dict, { - 'family_name': 'Doe', - 'given_name': 'John', - 'name': 'John Doe', - 'email': u'johndoe@example.com' - } + "family_name": "Doe", + "given_name": "John", + "name": "John Doe", + "email": "johndoe@example.com", + }, ) def test_locale(self): - with override_language('fr'): - self.assertEqual(text_type(StandardScopeClaims.info_profile[0]), 'Profil de base') + with override_language("fr"): + self.assertEqual( + text_type(StandardScopeClaims.info_profile[0]), "Profil de base" + ) def test_scopeclaims_class_inheritance(self): # Generate example class that will be used for `OIDC_EXTRA_SCOPE_CLAIMS` setting. class CustomScopeClaims(ScopeClaims): - info_foo = ('Title', 'Description') + info_foo = ("Title", "Description") def scope_foo(self): - dic = {'test': self.user.id} + dic = {"test": self.user.id} return dic - info_notadd = ('Title', 'Description') + info_notadd = ("Title", "Description") def scope_notadd(self): - dic = {'test': self.user.id} + dic = {"test": self.user.id} return dic claims = CustomScopeClaims(self.token) response = claims.create_response_dic() - self.assertTrue('test' in response.keys()) - self.assertFalse('notadd' in response.keys()) + self.assertTrue("test" in response.keys()) + self.assertFalse("notadd" in response.keys()) diff --git a/oidc_provider/tests/cases/test_commands.py b/oidc_provider/tests/cases/test_commands.py index 2f9248fe..2ad7a334 100644 --- a/oidc_provider/tests/cases/test_commands.py +++ b/oidc_provider/tests/cases/test_commands.py @@ -8,10 +8,10 @@ class CommandsTest(TestCase): def test_creatersakey_output(self): out = StringIO() - call_command('creatersakey', stdout=out) - self.assertIn('RSA key successfully created', out.getvalue()) + call_command("creatersakey", stdout=out) + self.assertIn("RSA key successfully created", out.getvalue()) def test_makemigrations_output(self): out = StringIO() - call_command('makemigrations', 'oidc_provider', stdout=out) - self.assertIn('No changes detected in app', out.getvalue()) + call_command("makemigrations", "oidc_provider", stdout=out) + self.assertIn("No changes detected in app", out.getvalue()) diff --git a/oidc_provider/tests/cases/test_end_session_endpoint.py b/oidc_provider/tests/cases/test_end_session_endpoint.py index f02596c2..7ac45b37 100644 --- a/oidc_provider/tests/cases/test_end_session_endpoint.py +++ b/oidc_provider/tests/cases/test_end_session_endpoint.py @@ -35,15 +35,17 @@ def setUp(self): # Create a valid ID Token for the user. token = create_token(self.user, self.oidc_client, []) - id_token_dic = create_id_token(token=token, user=self.user, aud=self.oidc_client.client_id) + id_token_dic = create_id_token( + token=token, user=self.user, aud=self.oidc_client.client_id + ) self.id_token = encode_id_token(id_token_dic, self.oidc_client) self.url = reverse("oidc_provider:end-session") self.url_prompt = reverse("oidc_provider:end-session-prompt") - @override_settings(OIDC_LOGOUT_URL='/post-logout/') + @override_settings(OIDC_LOGOUT_URL="/post-logout/") def test_redirects_when_aud_is_str(self): - query_params = {'post_logout_redirect_uri': self.url_logout} + query_params = {"post_logout_redirect_uri": self.url_logout} response = self.client.get(self.url, query_params) # With no id_token the OP MUST NOT redirect to the requested # redirect_uri. @@ -51,10 +53,11 @@ def test_redirects_when_aud_is_str(self): token = create_token(self.user, self.oidc_client, []) id_token_dic = create_id_token( - token=token, user=self.user, aud=self.oidc_client.client_id) + token=token, user=self.user, aud=self.oidc_client.client_id + ) id_token = encode_id_token(id_token_dic, self.oidc_client) - query_params['id_token_hint'] = id_token + query_params["id_token_hint"] = id_token response = self.client.get(self.url, query_params) self.assertEqual(response.headers["Location"], self.url_logout) @@ -110,7 +113,8 @@ def test_state_is_present_and_being_passed_to_logout_url(self): # Let's ensure state is being passed to the logout url. self.assertEqual(response.status_code, 302) self.assertEqual( - response.headers["Location"], "{0}?state={1}".format(self.url_logout, "ABCDE") + response.headers["Location"], + "{0}?state={1}".format(self.url_logout, "ABCDE"), ) def test_post_logout_uri_not_in_client_urls(self): @@ -127,7 +131,9 @@ def test_post_logout_uri_not_in_client_urls(self): "{0}?client_id={1}".format(self.url_prompt, self.oidc_client.client_id), ) - def test_prompt_view_redirecting_to_client_post_logout_since_user_unauthenticated(self): + def test_prompt_view_redirecting_to_client_post_logout_since_user_unauthenticated( + self, + ): self.client.logout() query_params = { "client_id": self.oidc_client.client_id, @@ -174,7 +180,9 @@ def test_prompt_view_displaying_logout_decision_form_to_user_no_client(self): ) @mock.patch("oidc_provider.views.after_end_session_hook") - def test_prompt_view_user_logged_out_after_form_allowed(self, after_end_session_hook): + def test_prompt_view_user_logged_out_after_form_allowed( + self, after_end_session_hook + ): self.assertIn("_auth_user_id", self.client.session) # We want to POST to /end-session-prompt/?client_id=ABC endpoint. url_prompt_with_client = ( @@ -199,7 +207,9 @@ def test_prompt_view_user_logged_out_after_form_allowed(self, after_end_session_ self.assertTrue(after_end_session_hook.call_count == 1) @mock.patch("oidc_provider.views.after_end_session_hook") - def test_prompt_view_user_logged_out_after_form_not_allowed(self, after_end_session_hook): + def test_prompt_view_user_logged_out_after_form_not_allowed( + self, after_end_session_hook + ): self.assertIn("_auth_user_id", self.client.session) # We want to POST to /end-session-prompt/?client_id=ABC endpoint. url_prompt_with_client = ( diff --git a/oidc_provider/tests/cases/test_introspection_endpoint.py b/oidc_provider/tests/cases/test_introspection_endpoint.py index 34a8ac73..203f4202 100644 --- a/oidc_provider/tests/cases/test_introspection_endpoint.py +++ b/oidc_provider/tests/cases/test_introspection_endpoint.py @@ -2,6 +2,7 @@ import random from mock import patch + try: from urllib.parse import urlencode except ImportError: @@ -10,6 +11,7 @@ from django.core.management import call_command from django.test import TestCase, RequestFactory, override_settings from django.utils import timezone + try: from django.urls import reverse except ImportError: @@ -19,7 +21,8 @@ create_fake_user, create_fake_client, create_fake_token, - FAKE_RANDOM_STRING) + FAKE_RANDOM_STRING, +) from oidc_provider.lib.utils.token import create_id_token from oidc_provider.views import TokenIntrospectionView @@ -27,71 +30,72 @@ class IntrospectionTestCase(TestCase): def setUp(self): - call_command('creatersakey') + call_command("creatersakey") self.factory = RequestFactory() self.user = create_fake_user() - self.aud = 'testaudience' - self.client = create_fake_client(response_type='id_token token') - self.resource = create_fake_client(response_type='id_token token') - self.resource.scope = ['token_introspection', self.aud] + self.aud = "testaudience" + self.client = create_fake_client(response_type="id_token token") + self.resource = create_fake_client(response_type="id_token token") + self.resource.scope = ["token_introspection", self.aud] self.resource.save() self.token = create_fake_token(self.user, self.client.scope, self.client) self.token.access_token = str(random.randint(1, 999999)).zfill(6) self.now = time.time() - with patch('oidc_provider.lib.utils.token.time.time') as time_func: + with patch("oidc_provider.lib.utils.token.time.time") as time_func: time_func.return_value = self.now self.token.id_token = create_id_token(self.token, self.user, self.aud) self.token.save() def _assert_inactive(self, response): self.assertEqual(response.status_code, 200) - self.assertJSONEqual(force_str(response.content), {'active': False}) + self.assertJSONEqual(force_str(response.content), {"active": False}) def _assert_active(self, response, **kwargs): self.assertEqual(response.status_code, 200) expected_content = { - 'active': True, - 'aud': self.aud, - 'client_id': self.client.client_id, - 'sub': str(self.user.pk), - 'iat': int(self.now), - 'exp': int(self.now + 600), - 'iss': 'http://localhost:8000/openid', + "active": True, + "aud": self.aud, + "client_id": self.client.client_id, + "sub": str(self.user.pk), + "iat": int(self.now), + "exp": int(self.now + 600), + "iss": "http://localhost:8000/openid", } expected_content.update(kwargs) self.assertJSONEqual(force_str(response.content), expected_content) def _make_request(self, **kwargs): - url = reverse('oidc_provider:token-introspection') + url = reverse("oidc_provider:token-introspection") data = { - 'client_id': kwargs.get('client_id', self.resource.client_id), - 'client_secret': kwargs.get('client_secret', self.resource.client_secret), - 'token': kwargs.get('access_token', self.token.access_token), + "client_id": kwargs.get("client_id", self.resource.client_id), + "client_secret": kwargs.get("client_secret", self.resource.client_secret), + "token": kwargs.get("access_token", self.token.access_token), } - request = self.factory.post(url, data=urlencode(data), - content_type='application/x-www-form-urlencoded') + request = self.factory.post( + url, data=urlencode(data), content_type="application/x-www-form-urlencoded" + ) return TokenIntrospectionView.as_view()(request) def test_no_client_params_returns_inactive(self): - response = self._make_request(client_id='') + response = self._make_request(client_id="") self._assert_inactive(response) def test_no_client_secret_returns_inactive(self): - response = self._make_request(client_secret='') + response = self._make_request(client_secret="") self._assert_inactive(response) def test_invalid_client_returns_inactive(self): - response = self._make_request(client_id='invalid') + response = self._make_request(client_id="invalid") self._assert_inactive(response) def test_token_not_found_returns_inactive(self): - response = self._make_request(access_token='invalid') + response = self._make_request(access_token="invalid") self._assert_inactive(response) def test_scope_no_audience_returns_inactive(self): - self.resource.scope = ['token_introspection'] + self.resource.scope = ["token_introspection"] self.resource.save() response = self._make_request() self._assert_inactive(response) @@ -106,14 +110,18 @@ def test_valid_request_returns_default_properties(self): response = self._make_request() self._assert_active(response) - @override_settings(OIDC_INTROSPECTION_PROCESSING_HOOK='oidc_provider.tests.app.utils.fake_introspection_processing_hook') # NOQA + @override_settings( + OIDC_INTROSPECTION_PROCESSING_HOOK="oidc_provider.tests.app.utils.fake_introspection_processing_hook" + ) # NOQA def test_custom_introspection_hook_called_on_valid_request(self): response = self._make_request() - self._assert_active(response, test_introspection_processing_hook=FAKE_RANDOM_STRING) + self._assert_active( + response, test_introspection_processing_hook=FAKE_RANDOM_STRING + ) @override_settings(OIDC_INTROSPECTION_VALIDATE_AUDIENCE_SCOPE=False) def test_disable_audience_validation(self): - self.resource.scope = ['token_introspection'] + self.resource.scope = ["token_introspection"] self.resource.save() response = self._make_request() self._assert_active(response) @@ -122,16 +130,19 @@ def test_disable_audience_validation(self): def test_valid_client_grant_token_without_aud_validation(self): self.token.id_token = None # client_credentials tokens do not have id_token self.token.save() - self.resource.scope = ['token_introspection'] + self.resource.scope = ["token_introspection"] self.resource.save() response = self._make_request() self.assertEqual(response.status_code, 200) - self.assertJSONEqual(force_str(response.content), { - 'active': True, - 'client_id': self.client.client_id, - }) + self.assertJSONEqual( + force_str(response.content), + { + "active": True, + "client_id": self.client.client_id, + }, + ) @override_settings(OIDC_INTROSPECTION_RESPONSE_SCOPE_ENABLE=True) def test_enable_scope(self): response = self._make_request() - self._assert_active(response, scope='openid email') + self._assert_active(response, scope="openid email") diff --git a/oidc_provider/tests/cases/test_middleware.py b/oidc_provider/tests/cases/test_middleware.py index 17339285..9afa932d 100644 --- a/oidc_provider/tests/cases/test_middleware.py +++ b/oidc_provider/tests/cases/test_middleware.py @@ -9,33 +9,41 @@ class StubbedViews: class SampleView(View): pass - urlpatterns = [re_path('^test/', SampleView.as_view())] + urlpatterns = [re_path("^test/", SampleView.as_view())] -MW_CLASSES = ('django.contrib.sessions.middleware.SessionMiddleware', - 'oidc_provider.middleware.SessionManagementMiddleware') +MW_CLASSES = ( + "django.contrib.sessions.middleware.SessionMiddleware", + "oidc_provider.middleware.SessionManagementMiddleware", +) -@override_settings(ROOT_URLCONF=StubbedViews, - MIDDLEWARE=MW_CLASSES, - MIDDLEWARE_CLASSES=MW_CLASSES, - OIDC_SESSION_MANAGEMENT_ENABLE=True) +@override_settings( + ROOT_URLCONF=StubbedViews, + MIDDLEWARE=MW_CLASSES, + MIDDLEWARE_CLASSES=MW_CLASSES, + OIDC_SESSION_MANAGEMENT_ENABLE=True, +) class MiddlewareTestCase(TestCase): def setUp(self): - patcher = mock.patch('oidc_provider.middleware.get_browser_state_or_default') + patcher = mock.patch("oidc_provider.middleware.get_browser_state_or_default") self.mock_get_state = patcher.start() def test_session_management_middleware_sets_cookie_on_response(self): - response = self.client.get('/test/') + response = self.client.get("/test/") - self.assertIn('op_browser_state', response.cookies) - self.assertEqual(response.cookies['op_browser_state'].value, - str(self.mock_get_state.return_value)) + self.assertIn("op_browser_state", response.cookies) + self.assertEqual( + response.cookies["op_browser_state"].value, + str(self.mock_get_state.return_value), + ) self.mock_get_state.assert_called_once_with(response.wsgi_request) @override_settings(OIDC_SESSION_MANAGEMENT_ENABLE=False) - def test_session_management_middleware_does_not_set_cookie_if_session_management_disabled(self): - response = self.client.get('/test/') + def test_session_management_middleware_does_not_set_cookie_if_session_management_disabled( + self, + ): + response = self.client.get("/test/") - self.assertNotIn('op_browser_state', response.cookies) + self.assertNotIn("op_browser_state", response.cookies) diff --git a/oidc_provider/tests/cases/test_provider_info_endpoint.py b/oidc_provider/tests/cases/test_provider_info_endpoint.py index 1dfe2777..22973986 100644 --- a/oidc_provider/tests/cases/test_provider_info_endpoint.py +++ b/oidc_provider/tests/cases/test_provider_info_endpoint.py @@ -1,6 +1,7 @@ from mock import patch from django.core.cache import cache + try: from django.urls import reverse except ImportError: @@ -20,13 +21,13 @@ def setUp(self): def tearDown(self): cache.clear() - @patch('oidc_provider.views.ProviderInfoView._build_cache_key') + @patch("oidc_provider.views.ProviderInfoView._build_cache_key") def test_response(self, build_cache_key): """ See if the endpoint is returning the corresponding server information by checking status, content type, etc. """ - url = reverse('oidc_provider:provider-info') + url = reverse("oidc_provider:provider-info") request = self.factory.get(url) @@ -36,18 +37,18 @@ def test_response(self, build_cache_key): build_cache_key.assert_not_called() self.assertEqual(response.status_code, 200) - self.assertEqual(response['Content-Type'] == 'application/json', True) + self.assertEqual(response["Content-Type"] == "application/json", True) self.assertEqual(bool(response.content), True) @override_settings(OIDC_DISCOVERY_CACHE_ENABLE=True) - @patch('oidc_provider.views.ProviderInfoView._build_cache_key') + @patch("oidc_provider.views.ProviderInfoView._build_cache_key") def test_response_with_cache_enabled(self, build_cache_key): """ Enable caching on the discovery endpoint and ensure data is being saved on cache. """ - build_cache_key.return_value = 'key' + build_cache_key.return_value = "key" - url = reverse('oidc_provider:provider-info') + url = reverse("oidc_provider:provider-info") request = self.factory.get(url) @@ -55,9 +56,9 @@ def test_response_with_cache_enabled(self, build_cache_key): self.assertEqual(response.status_code, 200) build_cache_key.assert_called_once() - assert 'authorization_endpoint' in cache.get('key') + assert "authorization_endpoint" in cache.get("key") response = ProviderInfoView.as_view()(request) self.assertEqual(response.status_code, 200) - self.assertEqual(response['Content-Type'] == 'application/json', True) + self.assertEqual(response["Content-Type"] == "application/json", True) self.assertEqual(bool(response.content), True) diff --git a/oidc_provider/tests/cases/test_settings.py b/oidc_provider/tests/cases/test_settings.py index 1a8a0f7b..c66910a3 100644 --- a/oidc_provider/tests/cases/test_settings.py +++ b/oidc_provider/tests/cases/test_settings.py @@ -2,28 +2,25 @@ from oidc_provider import settings -CUSTOM_TEMPLATES = { - 'authorize': 'custom/authorize.html', - 'error': 'custom/error.html' -} +CUSTOM_TEMPLATES = {"authorize": "custom/authorize.html", "error": "custom/error.html"} class SettingsTest(TestCase): @override_settings(OIDC_TEMPLATES=CUSTOM_TEMPLATES) def test_override_templates(self): - self.assertEqual(settings.get('OIDC_TEMPLATES'), CUSTOM_TEMPLATES) + self.assertEqual(settings.get("OIDC_TEMPLATES"), CUSTOM_TEMPLATES) def test_unauthenticated_session_management_key_has_default(self): - key = settings.get('OIDC_UNAUTHENTICATED_SESSION_MANAGEMENT_KEY') - self.assertRegex(key, r'[a-zA-Z0-9]+') + key = settings.get("OIDC_UNAUTHENTICATED_SESSION_MANAGEMENT_KEY") + self.assertRegex(key, r"[a-zA-Z0-9]+") self.assertGreater(len(key), 50) def test_unauthenticated_session_management_key_has_constant_value(self): - key1 = settings.get('OIDC_UNAUTHENTICATED_SESSION_MANAGEMENT_KEY') - key2 = settings.get('OIDC_UNAUTHENTICATED_SESSION_MANAGEMENT_KEY') + key1 = settings.get("OIDC_UNAUTHENTICATED_SESSION_MANAGEMENT_KEY") + key2 = settings.get("OIDC_UNAUTHENTICATED_SESSION_MANAGEMENT_KEY") self.assertEqual(key1, key2) @override_settings(OIDC_INTROSPECTION_VALIDATE_AUDIENCE_SCOPE=False) def test_can_override_with_false_value(self): - self.assertFalse(settings.get('OIDC_INTROSPECTION_VALIDATE_AUDIENCE_SCOPE')) + self.assertFalse(settings.get("OIDC_INTROSPECTION_VALIDATE_AUDIENCE_SCOPE")) diff --git a/oidc_provider/tests/cases/test_token_endpoint.py b/oidc_provider/tests/cases/test_token_endpoint.py index 8990d3d2..abbe9ce7 100644 --- a/oidc_provider/tests/cases/test_token_endpoint.py +++ b/oidc_provider/tests/cases/test_token_endpoint.py @@ -54,25 +54,26 @@ class TokenTestCase(TestCase): Token Request to the Token Endpoint to obtain a Token Response when using the Authorization Code Flow. """ - SCOPE = 'openid email' - SCOPE_LIST = SCOPE.split(' ') + + SCOPE = "openid email" + SCOPE_LIST = SCOPE.split(" ") def setUp(self): - call_command('creatersakey') + call_command("creatersakey") self.factory = RequestFactory() self.user = create_fake_user() self.request_client = self.client - self.client = create_fake_client(response_type='code') + self.client = create_fake_client(response_type="code") def _password_grant_post_data(self, scope=None): result = { - 'username': 'johndoe', - 'password': '1234', - 'grant_type': 'password', - 'scope': TokenTestCase.SCOPE, + "username": "johndoe", + "password": "1234", + "grant_type": "password", + "scope": TokenTestCase.SCOPE, } if scope is not None: - result['scope'] = ' '.join(scope) + result["scope"] = " ".join(scope) return result def _auth_code_post_data(self, code, scope=None): @@ -80,15 +81,15 @@ def _auth_code_post_data(self, code, scope=None): All the data that will be POSTed to the Token Endpoint. """ post_data = { - 'client_id': self.client.client_id, - 'client_secret': self.client.client_secret, - 'redirect_uri': self.client.default_redirect_uri, - 'grant_type': 'authorization_code', - 'code': code, - 'state': uuid.uuid4().hex, + "client_id": self.client.client_id, + "client_secret": self.client.client_secret, + "redirect_uri": self.client.default_redirect_uri, + "grant_type": "authorization_code", + "code": code, + "state": uuid.uuid4().hex, } if scope is not None: - post_data['scope'] = ' '.join(scope) + post_data["scope"] = " ".join(scope) return post_data @@ -97,24 +98,24 @@ def _refresh_token_post_data(self, refresh_token, scope=None): All the data that will be POSTed to the Token Endpoint. """ post_data = { - 'client_id': self.client.client_id, - 'client_secret': self.client.client_secret, - 'grant_type': 'refresh_token', - 'refresh_token': refresh_token, + "client_id": self.client.client_id, + "client_secret": self.client.client_secret, + "grant_type": "refresh_token", + "refresh_token": refresh_token, } if scope is not None: - post_data['scope'] = ' '.join(scope) + post_data["scope"] = " ".join(scope) return post_data def _client_credentials_post_data(self, scope=None): post_data = { - 'client_id': self.client.client_id, - 'client_secret': self.client.client_secret, - 'grant_type': 'client_credentials', + "client_id": self.client.client_id, + "client_secret": self.client.client_secret, + "grant_type": "client_credentials", } if scope is not None: - post_data['scope'] = ' '.join(scope) + post_data["scope"] = " ".join(scope) return post_data def _post_request(self, post_data, extras={}): @@ -123,13 +124,14 @@ def _post_request(self, post_data, extras={}): `post_data` parameters using the 'application/x-www-form-urlencoded' format. """ - url = reverse('oidc_provider:token') + url = reverse("oidc_provider:token") request = self.factory.post( url, data=urlencode(post_data), - content_type='application/x-www-form-urlencoded', - **extras) + content_type="application/x-www-form-urlencoded", + **extras + ) response = TokenView.as_view()(request) @@ -144,7 +146,8 @@ def _create_code(self, scope=None): client=self.client, scope=(scope if scope else TokenTestCase.SCOPE_LIST), nonce=FAKE_NONCE, - is_authentication=True) + is_authentication=True, + ) code.save() return code @@ -153,141 +156,140 @@ def _get_keys(self): """ Get public key from discovery. """ - request = self.factory.get(reverse('oidc_provider:jwks')) + request = self.factory.get(reverse("oidc_provider:jwks")) response = JwksView.as_view()(request) - jwks_dic = json.loads(response.content.decode('utf-8')) + jwks_dic = json.loads(response.content.decode("utf-8")) SIGKEYS = KEYS() SIGKEYS.load_dict(jwks_dic) return SIGKEYS def _get_userinfo(self, access_token): - url = reverse('oidc_provider:userinfo') + url = reverse("oidc_provider:userinfo") request = self.factory.get(url) - request.META['HTTP_AUTHORIZATION'] = 'Bearer ' + access_token + request.META["HTTP_AUTHORIZATION"] = "Bearer " + access_token return userinfo(request) def _password_grant_auth_header(self): - user_pass = self.client.client_id + ':' + self.client.client_secret - auth = b'Basic ' + b64encode(user_pass.encode('utf-8')) - auth_header = {'HTTP_AUTHORIZATION': auth.decode('utf-8')} + user_pass = self.client.client_id + ":" + self.client.client_secret + auth = b"Basic " + b64encode(user_pass.encode("utf-8")) + auth_header = {"HTTP_AUTHORIZATION": auth.decode("utf-8")} return auth_header def test_default_setting_does_not_allow_grant_type_password(self): post_data = self._password_grant_post_data() response = self._post_request( - post_data=post_data, - extras=self._password_grant_auth_header() + post_data=post_data, extras=self._password_grant_auth_header() ) - response_dict = json.loads(response.content.decode('utf-8')) + response_dict = json.loads(response.content.decode("utf-8")) self.assertEqual(400, response.status_code) - self.assertEqual('unsupported_grant_type', response_dict['error']) + self.assertEqual("unsupported_grant_type", response_dict["error"]) @override_settings(OIDC_GRANT_TYPE_PASSWORD_ENABLE=True) def test_password_grant_get_access_token_without_scope(self): post_data = self._password_grant_post_data() - del (post_data['scope']) + del post_data["scope"] response = self._post_request( - post_data=post_data, - extras=self._password_grant_auth_header() + post_data=post_data, extras=self._password_grant_auth_header() ) - response_dict = json.loads(response.content.decode('utf-8')) - self.assertIn('access_token', response_dict) + response_dict = json.loads(response.content.decode("utf-8")) + self.assertIn("access_token", response_dict) @override_settings(OIDC_GRANT_TYPE_PASSWORD_ENABLE=True) def test_password_grant_get_access_token_with_scope(self): response = self._post_request( post_data=self._password_grant_post_data(), - extras=self._password_grant_auth_header() + extras=self._password_grant_auth_header(), ) - response_dict = json.loads(response.content.decode('utf-8')) - self.assertIn('access_token', response_dict) + response_dict = json.loads(response.content.decode("utf-8")) + self.assertIn("access_token", response_dict) @override_settings(OIDC_GRANT_TYPE_PASSWORD_ENABLE=True) def test_password_grant_get_access_token_invalid_user_credentials(self): invalid_post = self._password_grant_post_data() - invalid_post['password'] = 'wrong!' + invalid_post["password"] = "wrong!" response = self._post_request( - post_data=invalid_post, - extras=self._password_grant_auth_header() + post_data=invalid_post, extras=self._password_grant_auth_header() ) - response_dict = json.loads(response.content.decode('utf-8')) + response_dict = json.loads(response.content.decode("utf-8")) self.assertEqual(403, response.status_code) - self.assertEqual('access_denied', response_dict['error']) + self.assertEqual("access_denied", response_dict["error"]) def test_password_grant_get_access_token_invalid_client_credentials(self): - self.client.client_id = 'foo' - self.client.client_secret = 'bar' + self.client.client_id = "foo" + self.client.client_secret = "bar" response = self._post_request( post_data=self._password_grant_post_data(), - extras=self._password_grant_auth_header() + extras=self._password_grant_auth_header(), ) - response_dict = json.loads(response.content.decode('utf-8')) + response_dict = json.loads(response.content.decode("utf-8")) self.assertEqual(400, response.status_code) - self.assertEqual('invalid_client', response_dict['error']) + self.assertEqual("invalid_client", response_dict["error"]) def test_password_grant_full_response(self): - self.check_password_grant(scope=['openid', 'email']) + self.check_password_grant(scope=["openid", "email"]) def test_password_grant_scope(self): - scopes_list = ['openid', 'profile'] + scopes_list = ["openid", "profile"] self.client.scope = scopes_list self.client.save() self.check_password_grant(scope=scopes_list) - @override_settings(OIDC_TOKEN_EXPIRE=120, - OIDC_GRANT_TYPE_PASSWORD_ENABLE=True) + @override_settings(OIDC_TOKEN_EXPIRE=120, OIDC_GRANT_TYPE_PASSWORD_ENABLE=True) def check_password_grant(self, scope): response = self._post_request( post_data=self._password_grant_post_data(scope), - extras=self._password_grant_auth_header() + extras=self._password_grant_auth_header(), ) - response_dict = json.loads(response.content.decode('utf-8')) + response_dict = json.loads(response.content.decode("utf-8")) id_token = JWS().verify_compact( - response_dict['id_token'].encode('utf-8'), self._get_keys()) + response_dict["id_token"].encode("utf-8"), self._get_keys() + ) token = Token.objects.get(user=self.user) - self.assertEqual(response_dict['access_token'], token.access_token) - self.assertEqual(response_dict['refresh_token'], token.refresh_token) - self.assertEqual(response_dict['expires_in'], 120) - self.assertEqual(response_dict['token_type'], 'bearer') - self.assertEqual(id_token['sub'], str(self.user.id)) - self.assertEqual(id_token['aud'], self.client.client_id) + self.assertEqual(response_dict["access_token"], token.access_token) + self.assertEqual(response_dict["refresh_token"], token.refresh_token) + self.assertEqual(response_dict["expires_in"], 120) + self.assertEqual(response_dict["token_type"], "bearer") + self.assertEqual(id_token["sub"], str(self.user.id)) + self.assertEqual(id_token["aud"], self.client.client_id) # Check the scope is honored by checking the claims in the userinfo - userinfo_response = self._get_userinfo(response_dict['access_token']) - userinfo = json.loads(userinfo_response.content.decode('utf-8')) + userinfo_response = self._get_userinfo(response_dict["access_token"]) + userinfo = json.loads(userinfo_response.content.decode("utf-8")) - for (scope_param, claim) in [('email', 'email'), ('profile', 'name')]: + for scope_param, claim in [("email", "email"), ("profile", "name")]: if scope_param in scope: self.assertIn(claim, userinfo) else: self.assertNotIn(claim, userinfo) - @override_settings(OIDC_GRANT_TYPE_PASSWORD_ENABLE=True, - AUTHENTICATION_BACKENDS=("oidc_provider.tests.app.utils.TestAuthBackend",)) + @override_settings( + OIDC_GRANT_TYPE_PASSWORD_ENABLE=True, + AUTHENTICATION_BACKENDS=("oidc_provider.tests.app.utils.TestAuthBackend",), + ) def test_password_grant_passes_request_to_backend(self): response = self._post_request( post_data=self._password_grant_post_data(), - extras=self._password_grant_auth_header() + extras=self._password_grant_auth_header(), ) - response_dict = json.loads(response.content.decode('utf-8')) - self.assertIn('access_token', response_dict) + response_dict = json.loads(response.content.decode("utf-8")) + self.assertIn("access_token", response_dict) @override_settings(OIDC_TOKEN_EXPIRE=720) def test_authorization_code(self): @@ -302,17 +304,19 @@ def test_authorization_code(self): post_data = self._auth_code_post_data(code=code.code) response = self._post_request(post_data) - response_dic = json.loads(response.content.decode('utf-8')) + response_dic = json.loads(response.content.decode("utf-8")) - id_token = JWS().verify_compact(response_dic['id_token'].encode('utf-8'), SIGKEYS) + id_token = JWS().verify_compact( + response_dic["id_token"].encode("utf-8"), SIGKEYS + ) token = Token.objects.get(user=self.user) - self.assertEqual(response_dic['access_token'], token.access_token) - self.assertEqual(response_dic['refresh_token'], token.refresh_token) - self.assertEqual(response_dic['token_type'], 'bearer') - self.assertEqual(response_dic['expires_in'], 720) - self.assertEqual(id_token['sub'], str(self.user.id)) - self.assertEqual(id_token['aud'], self.client.client_id) + self.assertEqual(response_dic["access_token"], token.access_token) + self.assertEqual(response_dic["refresh_token"], token.refresh_token) + self.assertEqual(response_dic["token_type"], "bearer") + self.assertEqual(response_dic["expires_in"], 720) + self.assertEqual(id_token["sub"], str(self.user.id)) + self.assertEqual(id_token["aud"], self.client.client_id) @override_settings(OIDC_TOKEN_EXPIRE=720) def test_authorization_code_cant_be_reused(self): @@ -323,46 +327,48 @@ def test_authorization_code_cant_be_reused(self): code = self._create_code() post_data = self._auth_code_post_data(code=code.code) - with patch('django.db.models.query.QuerySet.select_for_update') as select_for_update_func: + with patch( + "django.db.models.query.QuerySet.select_for_update" + ) as select_for_update_func: select_for_update_func.side_effect = DatabaseError() response = self._post_request(post_data) select_for_update_func.assert_called_once() self.assertEqual(response.status_code, 400) - response_dic = json.loads(response.content.decode('utf-8')) - self.assertEqual(response_dic['error'], 'invalid_grant') + response_dic = json.loads(response.content.decode("utf-8")) + self.assertEqual(response_dic["error"], "invalid_grant") - @override_settings(OIDC_TOKEN_EXPIRE=720, - OIDC_IDTOKEN_INCLUDE_CLAIMS=True) + @override_settings(OIDC_TOKEN_EXPIRE=720, OIDC_IDTOKEN_INCLUDE_CLAIMS=True) def test_scope_is_ignored_for_auth_code(self): """ Scope is ignored for token respones to auth code grant type. This comes down to that the scopes requested in authorize are returned. """ SIGKEYS = self._get_keys() - for code_scope in [['openid'], ['openid', 'email'], ['openid', 'profile']]: + for code_scope in [["openid"], ["openid", "email"], ["openid", "profile"]]: code = self._create_code(code_scope) - post_data = self._auth_code_post_data( - code=code.code, scope=code_scope) + post_data = self._auth_code_post_data(code=code.code, scope=code_scope) response = self._post_request(post_data) - response_dic = json.loads(response.content.decode('utf-8')) + response_dic = json.loads(response.content.decode("utf-8")) self.assertEqual(response.status_code, 200) - id_token = JWS().verify_compact(response_dic['id_token'].encode('utf-8'), SIGKEYS) + id_token = JWS().verify_compact( + response_dic["id_token"].encode("utf-8"), SIGKEYS + ) - if 'email' in code_scope: - self.assertIn('email', id_token) - self.assertIn('email_verified', id_token) + if "email" in code_scope: + self.assertIn("email", id_token) + self.assertIn("email_verified", id_token) else: - self.assertNotIn('email', id_token) + self.assertNotIn("email", id_token) - if 'profile' in code_scope: - self.assertIn('given_name', id_token) + if "profile" in code_scope: + self.assertIn("given_name", id_token) else: - self.assertNotIn('given_name', id_token) + self.assertNotIn("given_name", id_token) def test_refresh_token(self): """ @@ -380,7 +386,7 @@ def test_refresh_token_invalid_scope(self): though the original authorized scope in the authorization code request is only ['openid', 'email']. """ - self.do_refresh_token_check(scope=['openid', 'profile']) + self.do_refresh_token_check(scope=["openid", "profile"]) def test_refresh_token_narrowed_scope(self): """ @@ -390,7 +396,7 @@ def test_refresh_token_narrowed_scope(self): though the original authorized scope in the authorization code request is ['openid', 'email']. """ - self.do_refresh_token_check(scope=['openid']) + self.do_refresh_token_check(scope=["openid"]) @override_settings(OIDC_IDTOKEN_INCLUDE_CLAIMS=True) def do_refresh_token_check(self, scope=None): @@ -401,70 +407,81 @@ def do_refresh_token_check(self, scope=None): self.assertEqual(code.scope, TokenTestCase.SCOPE_LIST) post_data = self._auth_code_post_data(code=code.code) start_time = time.time() - with patch('oidc_provider.lib.utils.token.time.time') as time_func: + with patch("oidc_provider.lib.utils.token.time.time") as time_func: time_func.return_value = start_time response = self._post_request(post_data) - response_dic1 = json.loads(response.content.decode('utf-8')) - id_token1 = JWS().verify_compact(response_dic1['id_token'].encode('utf-8'), SIGKEYS) + response_dic1 = json.loads(response.content.decode("utf-8")) + id_token1 = JWS().verify_compact( + response_dic1["id_token"].encode("utf-8"), SIGKEYS + ) # Use refresh token to obtain new token - post_data = self._refresh_token_post_data( - response_dic1['refresh_token'], scope) - with patch('oidc_provider.lib.utils.token.time.time') as time_func: + post_data = self._refresh_token_post_data(response_dic1["refresh_token"], scope) + with patch("oidc_provider.lib.utils.token.time.time") as time_func: time_func.return_value = start_time + 600 response = self._post_request(post_data) - response_dic2 = json.loads(response.content.decode('utf-8')) + response_dic2 = json.loads(response.content.decode("utf-8")) if scope and set(scope) - set(code.scope): # too broad scope self.assertEqual(response.status_code, 400) # Bad Request - self.assertIn('error', response_dic2) - self.assertEqual(response_dic2['error'], 'invalid_scope') + self.assertIn("error", response_dic2) + self.assertEqual(response_dic2["error"], "invalid_scope") return # No more checks - id_token2 = JWS().verify_compact(response_dic2['id_token'].encode('utf-8'), SIGKEYS) + id_token2 = JWS().verify_compact( + response_dic2["id_token"].encode("utf-8"), SIGKEYS + ) - if scope and 'email' not in scope: # narrowed scope The auth + if scope and "email" not in scope: # narrowed scope The auth # The auth code request had email in scope, so it should be # in the first id token - self.assertIn('email', id_token1) + self.assertIn("email", id_token1) # but the refresh request had no email in scope - self.assertNotIn('email', id_token2, 'email was not requested') + self.assertNotIn("email", id_token2, "email was not requested") - self.assertNotEqual(response_dic1['id_token'], response_dic2['id_token']) - self.assertNotEqual(response_dic1['access_token'], response_dic2['access_token']) - self.assertNotEqual(response_dic1['refresh_token'], response_dic2['refresh_token']) + self.assertNotEqual(response_dic1["id_token"], response_dic2["id_token"]) + self.assertNotEqual( + response_dic1["access_token"], response_dic2["access_token"] + ) + self.assertNotEqual( + response_dic1["refresh_token"], response_dic2["refresh_token"] + ) # http://openid.net/specs/openid-connect-core-1_0.html#rfc.section.12.2 - self.assertEqual(id_token1['iss'], id_token2['iss']) - self.assertEqual(id_token1['sub'], id_token2['sub']) - self.assertNotEqual(id_token1['iat'], id_token2['iat']) - self.assertEqual(id_token1['iat'], int(start_time)) - self.assertEqual(id_token2['iat'], int(start_time + 600)) - self.assertEqual(id_token1['aud'], id_token2['aud']) - self.assertEqual(id_token1['auth_time'], id_token2['auth_time']) - self.assertEqual(id_token1.get('azp'), id_token2.get('azp')) + self.assertEqual(id_token1["iss"], id_token2["iss"]) + self.assertEqual(id_token1["sub"], id_token2["sub"]) + self.assertNotEqual(id_token1["iat"], id_token2["iat"]) + self.assertEqual(id_token1["iat"], int(start_time)) + self.assertEqual(id_token2["iat"], int(start_time + 600)) + self.assertEqual(id_token1["aud"], id_token2["aud"]) + self.assertEqual(id_token1["auth_time"], id_token2["auth_time"]) + self.assertEqual(id_token1.get("azp"), id_token2.get("azp")) # Refresh token can't be reused - post_data = self._refresh_token_post_data(response_dic1['refresh_token']) + post_data = self._refresh_token_post_data(response_dic1["refresh_token"]) response = self._post_request(post_data) - self.assertIn('invalid_grant', response.content.decode('utf-8')) + self.assertIn("invalid_grant", response.content.decode("utf-8")) # Old access token is invalidated - self.assertEqual(self._get_userinfo(response_dic1['access_token']).status_code, 401) - self.assertEqual(self._get_userinfo(response_dic2['access_token']).status_code, 200) + self.assertEqual( + self._get_userinfo(response_dic1["access_token"]).status_code, 401 + ) + self.assertEqual( + self._get_userinfo(response_dic2["access_token"]).status_code, 200 + ) # Empty refresh token is invalid - post_data = self._refresh_token_post_data('') + post_data = self._refresh_token_post_data("") response = self._post_request(post_data) - self.assertIn('invalid_grant', response.content.decode('utf-8')) + self.assertIn("invalid_grant", response.content.decode("utf-8")) # No refresh token is invalid - post_data = self._refresh_token_post_data('') - del post_data['refresh_token'] + post_data = self._refresh_token_post_data("") + del post_data["refresh_token"] response = self._post_request(post_data) - self.assertIn('invalid_grant', response.content.decode('utf-8')) + self.assertIn("invalid_grant", response.content.decode("utf-8")) def test_client_redirect_uri(self): """ @@ -477,29 +494,29 @@ def test_client_redirect_uri(self): post_data = self._auth_code_post_data(code=code.code) # Unregistered URI - post_data['redirect_uri'] = 'http://invalid.example.org' + post_data["redirect_uri"] = "http://invalid.example.org" response = self._post_request(post_data) - self.assertIn('invalid_client', response.content.decode('utf-8')) + self.assertIn("invalid_client", response.content.decode("utf-8")) # Registered URI, but with query string appended - post_data['redirect_uri'] = self.client.default_redirect_uri + '?foo=bar' + post_data["redirect_uri"] = self.client.default_redirect_uri + "?foo=bar" response = self._post_request(post_data) - self.assertIn('invalid_client', response.content.decode('utf-8')) + self.assertIn("invalid_client", response.content.decode("utf-8")) # Registered URI - post_data['redirect_uri'] = self.client.default_redirect_uri + post_data["redirect_uri"] = self.client.default_redirect_uri response = self._post_request(post_data) - self.assertNotIn('invalid_client', response.content.decode('utf-8')) + self.assertNotIn("invalid_client", response.content.decode("utf-8")) def test_request_methods(self): """ Client sends an HTTP POST request to the Token Endpoint. Other request methods MUST NOT be allowed. """ - url = reverse('oidc_provider:token') + url = reverse("oidc_provider:token") requests = [ self.factory.get(url), @@ -511,16 +528,20 @@ def test_request_methods(self): response = TokenView.as_view()(request) self.assertEqual( - response.status_code, 405, - msg=request.method + ' request does not return a 405 status.') + response.status_code, + 405, + msg=request.method + " request does not return a 405 status.", + ) request = self.factory.post(url) response = TokenView.as_view()(request) self.assertEqual( - response.status_code, 400, - msg=request.method + ' request does not return a 400 status.') + response.status_code, + 400, + msg=request.method + " request does not return a 400 status.", + ) def test_client_authentication(self): """ @@ -538,42 +559,47 @@ def test_client_authentication(self): response = self._post_request(post_data) self.assertNotIn( - 'invalid_client', - response.content.decode('utf-8'), - msg='Client authentication fails using request-body credentials.') + "invalid_client", + response.content.decode("utf-8"), + msg="Client authentication fails using request-body credentials.", + ) # Now, test with an invalid client_id. invalid_data = post_data.copy() - invalid_data['client_id'] = self.client.client_id * 2 # Fake id. + invalid_data["client_id"] = self.client.client_id * 2 # Fake id. # Create another grant code. code = self._create_code() - invalid_data['code'] = code.code + invalid_data["code"] = code.code response = self._post_request(invalid_data) self.assertIn( - 'invalid_client', - response.content.decode('utf-8'), - msg='Client authentication success with an invalid "client_id".') + "invalid_client", + response.content.decode("utf-8"), + msg='Client authentication success with an invalid "client_id".', + ) # Now, test using HTTP Basic Authentication method. basicauth_data = post_data.copy() # Create another grant code. code = self._create_code() - basicauth_data['code'] = code.code + basicauth_data["code"] = code.code - del basicauth_data['client_id'] - del basicauth_data['client_secret'] + del basicauth_data["client_id"] + del basicauth_data["client_secret"] - response = self._post_request(basicauth_data, self._password_grant_auth_header()) - response.content.decode('utf-8') + response = self._post_request( + basicauth_data, self._password_grant_auth_header() + ) + response.content.decode("utf-8") self.assertNotIn( - 'invalid_client', - response.content.decode('utf-8'), - msg='Client authentication fails using HTTP Basic Auth.') + "invalid_client", + response.content.decode("utf-8"), + msg="Client authentication fails using HTTP Basic Auth.", + ) def test_access_token_contains_nonce(self): """ @@ -591,21 +617,21 @@ def test_access_token_contains_nonce(self): response = self._post_request(post_data) - response_dic = json.loads(response.content.decode('utf-8')) - id_token = JWT().unpack(response_dic['id_token'].encode('utf-8')).payload() + response_dic = json.loads(response.content.decode("utf-8")) + id_token = JWT().unpack(response_dic["id_token"].encode("utf-8")).payload() - self.assertEqual(id_token.get('nonce'), FAKE_NONCE) + self.assertEqual(id_token.get("nonce"), FAKE_NONCE) # Client does not supply a nonce parameter. - code.nonce = '' + code.nonce = "" code.save() response = self._post_request(post_data) - response_dic = json.loads(response.content.decode('utf-8')) + response_dic = json.loads(response.content.decode("utf-8")) - id_token = JWT().unpack(response_dic['id_token'].encode('utf-8')).payload() + id_token = JWT().unpack(response_dic["id_token"].encode("utf-8")).payload() - self.assertEqual(id_token.get('nonce'), None) + self.assertEqual(id_token.get("nonce"), None) def test_id_token_contains_at_hash(self): """ @@ -617,10 +643,10 @@ def test_id_token_contains_at_hash(self): response = self._post_request(post_data) - response_dic = json.loads(response.content.decode('utf-8')) - id_token = JWT().unpack(response_dic['id_token'].encode('utf-8')).payload() + response_dic = json.loads(response.content.decode("utf-8")) + id_token = JWT().unpack(response_dic["id_token"].encode("utf-8")).payload() - self.assertTrue(id_token.get('at_hash')) + self.assertTrue(id_token.get("at_hash")) def test_idtoken_sign_validation(self): """ @@ -629,19 +655,20 @@ def test_idtoken_sign_validation(self): the JOSE Header. """ SIGKEYS = self._get_keys() - RSAKEYS = [k for k in SIGKEYS if k.kty == 'RSA'] + RSAKEYS = [k for k in SIGKEYS if k.kty == "RSA"] code = self._create_code() post_data = self._auth_code_post_data(code=code.code) response = self._post_request(post_data) - response_dic = json.loads(response.content.decode('utf-8')) + response_dic = json.loads(response.content.decode("utf-8")) - JWS().verify_compact(response_dic['id_token'].encode('utf-8'), RSAKEYS) + JWS().verify_compact(response_dic["id_token"].encode("utf-8"), RSAKEYS) @override_settings( - OIDC_IDTOKEN_SUB_GENERATOR='oidc_provider.tests.app.utils.fake_sub_generator') + OIDC_IDTOKEN_SUB_GENERATOR="oidc_provider.tests.app.utils.fake_sub_generator" + ) def test_custom_sub_generator(self): """ Test custom function for setting OIDC_IDTOKEN_SUB_GENERATOR. @@ -652,13 +679,14 @@ def test_custom_sub_generator(self): response = self._post_request(post_data) - response_dic = json.loads(response.content.decode('utf-8')) - id_token = JWT().unpack(response_dic['id_token'].encode('utf-8')).payload() + response_dic = json.loads(response.content.decode("utf-8")) + id_token = JWT().unpack(response_dic["id_token"].encode("utf-8")).payload() - self.assertEqual(id_token.get('sub'), self.user.email) + self.assertEqual(id_token.get("sub"), self.user.email) @override_settings( - OIDC_IDTOKEN_PROCESSING_HOOK='oidc_provider.tests.app.utils.fake_idtoken_processing_hook') + OIDC_IDTOKEN_PROCESSING_HOOK="oidc_provider.tests.app.utils.fake_idtoken_processing_hook" + ) def test_additional_idtoken_processing_hook(self): """ Test custom function for setting OIDC_IDTOKEN_PROCESSING_HOOK. @@ -669,15 +697,19 @@ def test_additional_idtoken_processing_hook(self): response = self._post_request(post_data) - response_dic = json.loads(response.content.decode('utf-8')) - id_token = JWT().unpack(response_dic['id_token'].encode('utf-8')).payload() + response_dic = json.loads(response.content.decode("utf-8")) + id_token = JWT().unpack(response_dic["id_token"].encode("utf-8")).payload() - self.assertEqual(id_token.get('test_idtoken_processing_hook'), FAKE_RANDOM_STRING) - self.assertEqual(id_token.get('test_idtoken_processing_hook_user_email'), self.user.email) + self.assertEqual( + id_token.get("test_idtoken_processing_hook"), FAKE_RANDOM_STRING + ) + self.assertEqual( + id_token.get("test_idtoken_processing_hook_user_email"), self.user.email + ) @override_settings( OIDC_IDTOKEN_PROCESSING_HOOK=( - 'oidc_provider.tests.app.utils.fake_idtoken_processing_hook', + "oidc_provider.tests.app.utils.fake_idtoken_processing_hook", ) ) def test_additional_idtoken_processing_hook_one_element_in_tuple(self): @@ -690,15 +722,19 @@ def test_additional_idtoken_processing_hook_one_element_in_tuple(self): response = self._post_request(post_data) - response_dic = json.loads(response.content.decode('utf-8')) - id_token = JWT().unpack(response_dic['id_token'].encode('utf-8')).payload() + response_dic = json.loads(response.content.decode("utf-8")) + id_token = JWT().unpack(response_dic["id_token"].encode("utf-8")).payload() - self.assertEqual(id_token.get('test_idtoken_processing_hook'), FAKE_RANDOM_STRING) - self.assertEqual(id_token.get('test_idtoken_processing_hook_user_email'), self.user.email) + self.assertEqual( + id_token.get("test_idtoken_processing_hook"), FAKE_RANDOM_STRING + ) + self.assertEqual( + id_token.get("test_idtoken_processing_hook_user_email"), self.user.email + ) @override_settings( OIDC_IDTOKEN_PROCESSING_HOOK=[ - 'oidc_provider.tests.app.utils.fake_idtoken_processing_hook', + "oidc_provider.tests.app.utils.fake_idtoken_processing_hook", ] ) def test_additional_idtoken_processing_hook_one_element_in_list(self): @@ -711,16 +747,20 @@ def test_additional_idtoken_processing_hook_one_element_in_list(self): response = self._post_request(post_data) - response_dic = json.loads(response.content.decode('utf-8')) - id_token = JWT().unpack(response_dic['id_token'].encode('utf-8')).payload() + response_dic = json.loads(response.content.decode("utf-8")) + id_token = JWT().unpack(response_dic["id_token"].encode("utf-8")).payload() - self.assertEqual(id_token.get('test_idtoken_processing_hook'), FAKE_RANDOM_STRING) - self.assertEqual(id_token.get('test_idtoken_processing_hook_user_email'), self.user.email) + self.assertEqual( + id_token.get("test_idtoken_processing_hook"), FAKE_RANDOM_STRING + ) + self.assertEqual( + id_token.get("test_idtoken_processing_hook_user_email"), self.user.email + ) @override_settings( OIDC_IDTOKEN_PROCESSING_HOOK=[ - 'oidc_provider.tests.app.utils.fake_idtoken_processing_hook', - 'oidc_provider.tests.app.utils.fake_idtoken_processing_hook2', + "oidc_provider.tests.app.utils.fake_idtoken_processing_hook", + "oidc_provider.tests.app.utils.fake_idtoken_processing_hook2", ] ) def test_additional_idtoken_processing_hook_two_elements_in_list(self): @@ -733,19 +773,27 @@ def test_additional_idtoken_processing_hook_two_elements_in_list(self): response = self._post_request(post_data) - response_dic = json.loads(response.content.decode('utf-8')) - id_token = JWT().unpack(response_dic['id_token'].encode('utf-8')).payload() + response_dic = json.loads(response.content.decode("utf-8")) + id_token = JWT().unpack(response_dic["id_token"].encode("utf-8")).payload() - self.assertEqual(id_token.get('test_idtoken_processing_hook'), FAKE_RANDOM_STRING) - self.assertEqual(id_token.get('test_idtoken_processing_hook_user_email'), self.user.email) + self.assertEqual( + id_token.get("test_idtoken_processing_hook"), FAKE_RANDOM_STRING + ) + self.assertEqual( + id_token.get("test_idtoken_processing_hook_user_email"), self.user.email + ) - self.assertEqual(id_token.get('test_idtoken_processing_hook2'), FAKE_RANDOM_STRING) - self.assertEqual(id_token.get('test_idtoken_processing_hook_user_email2'), self.user.email) + self.assertEqual( + id_token.get("test_idtoken_processing_hook2"), FAKE_RANDOM_STRING + ) + self.assertEqual( + id_token.get("test_idtoken_processing_hook_user_email2"), self.user.email + ) @override_settings( OIDC_IDTOKEN_PROCESSING_HOOK=( - 'oidc_provider.tests.app.utils.fake_idtoken_processing_hook', - 'oidc_provider.tests.app.utils.fake_idtoken_processing_hook2', + "oidc_provider.tests.app.utils.fake_idtoken_processing_hook", + "oidc_provider.tests.app.utils.fake_idtoken_processing_hook2", ) ) def test_additional_idtoken_processing_hook_two_elements_in_tuple(self): @@ -758,43 +806,57 @@ def test_additional_idtoken_processing_hook_two_elements_in_tuple(self): response = self._post_request(post_data) - response_dic = json.loads(response.content.decode('utf-8')) - id_token = JWT().unpack(response_dic['id_token'].encode('utf-8')).payload() + response_dic = json.loads(response.content.decode("utf-8")) + id_token = JWT().unpack(response_dic["id_token"].encode("utf-8")).payload() - self.assertEqual(id_token.get('test_idtoken_processing_hook'), FAKE_RANDOM_STRING) - self.assertEqual(id_token.get('test_idtoken_processing_hook_user_email'), self.user.email) + self.assertEqual( + id_token.get("test_idtoken_processing_hook"), FAKE_RANDOM_STRING + ) + self.assertEqual( + id_token.get("test_idtoken_processing_hook_user_email"), self.user.email + ) - self.assertEqual(id_token.get('test_idtoken_processing_hook2'), FAKE_RANDOM_STRING) - self.assertEqual(id_token.get('test_idtoken_processing_hook_user_email2'), self.user.email) + self.assertEqual( + id_token.get("test_idtoken_processing_hook2"), FAKE_RANDOM_STRING + ) + self.assertEqual( + id_token.get("test_idtoken_processing_hook_user_email2"), self.user.email + ) @override_settings( OIDC_IDTOKEN_PROCESSING_HOOK=( - 'oidc_provider.tests.app.utils.fake_idtoken_processing_hook3')) + "oidc_provider.tests.app.utils.fake_idtoken_processing_hook3" + ) + ) def test_additional_idtoken_processing_hook_scope_available(self): """ Test scope is available in OIDC_IDTOKEN_PROCESSING_HOOK. """ id_token = self._request_id_token_with_scope( - ['openid', 'email', 'profile', 'dummy']) + ["openid", "email", "profile", "dummy"] + ) self.assertEqual( - id_token.get('scope_of_token_passed_to_processing_hook'), - ['openid', 'email', 'profile', 'dummy']) + id_token.get("scope_of_token_passed_to_processing_hook"), + ["openid", "email", "profile", "dummy"], + ) @override_settings( OIDC_IDTOKEN_PROCESSING_HOOK=( - 'oidc_provider.tests.app.utils.fake_idtoken_processing_hook4')) + "oidc_provider.tests.app.utils.fake_idtoken_processing_hook4" + ) + ) def test_additional_idtoken_processing_hook_kwargs(self): """ Test correct kwargs are passed to OIDC_IDTOKEN_PROCESSING_HOOK. """ - id_token = self._request_id_token_with_scope(['openid', 'profile']) - kwargs_passed = id_token.get('kwargs_passed_to_processing_hook') + id_token = self._request_id_token_with_scope(["openid", "profile"]) + kwargs_passed = id_token.get("kwargs_passed_to_processing_hook") assert kwargs_passed - self.assertTrue(kwargs_passed.get('token').startswith( - '") - self.assertEqual(set(kwargs_passed.keys()), {'token', 'request'}) + self.assertTrue(kwargs_passed.get("token").startswith("" + ) + self.assertEqual(set(kwargs_passed.keys()), {"token", "request"}) def _request_id_token_with_scope(self, scope): code = self._create_code(scope) @@ -803,8 +865,8 @@ def _request_id_token_with_scope(self, scope): response = self._post_request(post_data) - response_dic = json.loads(response.content.decode('utf-8')) - id_token = JWT().unpack(response_dic['id_token'].encode('utf-8')).payload() + response_dic = json.loads(response.content.decode("utf-8")) + id_token = JWT().unpack(response_dic["id_token"].encode("utf-8")).payload() return id_token def test_pkce_parameters(self): @@ -812,19 +874,25 @@ def test_pkce_parameters(self): Test Proof Key for Code Exchange by OAuth Public Clients. https://tools.ietf.org/html/rfc7636 """ - code = create_code(user=self.user, client=self.client, - scope=['openid', 'email'], nonce=FAKE_NONCE, is_authentication=True, - code_challenge=FAKE_CODE_CHALLENGE, code_challenge_method='S256') + code = create_code( + user=self.user, + client=self.client, + scope=["openid", "email"], + nonce=FAKE_NONCE, + is_authentication=True, + code_challenge=FAKE_CODE_CHALLENGE, + code_challenge_method="S256", + ) code.save() post_data = self._auth_code_post_data(code=code.code) # Add parameters. - post_data['code_verifier'] = FAKE_CODE_VERIFIER + post_data["code_verifier"] = FAKE_CODE_VERIFIER response = self._post_request(post_data) - self.assertIn('access_token', json.loads(response.content.decode('utf-8'))) + self.assertIn("access_token", json.loads(response.content.decode("utf-8"))) def test_pkce_missing_code_verifier(self): """ @@ -832,22 +900,30 @@ def test_pkce_missing_code_verifier(self): fails when PKCE was used during the authorization request. """ - code = create_code(user=self.user, client=self.client, - scope=['openid', 'email'], nonce=FAKE_NONCE, is_authentication=True, - code_challenge=FAKE_CODE_CHALLENGE, code_challenge_method='S256') + code = create_code( + user=self.user, + client=self.client, + scope=["openid", "email"], + nonce=FAKE_NONCE, + is_authentication=True, + code_challenge=FAKE_CODE_CHALLENGE, + code_challenge_method="S256", + ) code.save() post_data = self._auth_code_post_data(code=code.code) - assert 'code_verifier' not in post_data + assert "code_verifier" not in post_data response = self._post_request(post_data) - assert json.loads(response.content.decode('utf-8')).get('error') == 'invalid_grant' + assert ( + json.loads(response.content.decode("utf-8")).get("error") == "invalid_grant" + ) @override_settings(OIDC_INTROSPECTION_VALIDATE_AUDIENCE_SCOPE=False) def test_client_credentials_grant_type(self): - fake_scopes_list = ['scopeone', 'scopetwo', INTROSPECTION_SCOPE] + fake_scopes_list = ["scopeone", "scopetwo", INTROSPECTION_SCOPE] # Add scope for this client. self.client.scope = fake_scopes_list @@ -855,139 +931,144 @@ def test_client_credentials_grant_type(self): post_data = self._client_credentials_post_data() response = self._post_request(post_data) - response_dict = json.loads(response.content.decode('utf-8')) + response_dict = json.loads(response.content.decode("utf-8")) # Ensure access token exists in the response, also check if scopes are # the ones we registered previously. - self.assertTrue('access_token' in response_dict) - self.assertEqual(' '.join(fake_scopes_list), response_dict['scope']) + self.assertTrue("access_token" in response_dict) + self.assertEqual(" ".join(fake_scopes_list), response_dict["scope"]) - access_token = response_dict['access_token'] + access_token = response_dict["access_token"] # Create a protected resource and test the access_token. - @require_http_methods(['GET']) + @require_http_methods(["GET"]) @protected_resource_view(fake_scopes_list) def protected_api(request, *args, **kwargs): - return JsonResponse({'protected': 'information'}, status=200) + return JsonResponse({"protected": "information"}, status=200) # Deploy view on some url. So, base url could be anything. request = self.factory.get( - '/api/protected/?access_token={0}'.format(access_token)) + "/api/protected/?access_token={0}".format(access_token) + ) response = protected_api(request) - response_dict = json.loads(response.content.decode('utf-8')) + response_dict = json.loads(response.content.decode("utf-8")) self.assertEqual(response.status_code, 200) - self.assertTrue('protected' in response_dict) + self.assertTrue("protected" in response_dict) # Protected resource test ends here. # Verify access_token can be validated with token introspection response = self.request_client.post( - reverse('oidc_provider:token-introspection'), data={'token': access_token}, - **self._password_grant_auth_header()) + reverse("oidc_provider:token-introspection"), + data={"token": access_token}, + **self._password_grant_auth_header() + ) self.assertEqual(response.status_code, 200) - response_dict = json.loads(response.content.decode('utf-8')) - self.assertTrue(response_dict.get('active')) + response_dict = json.loads(response.content.decode("utf-8")) + self.assertTrue(response_dict.get("active")) # End token introspection test # Clean scopes for this client. - self.client.scope = '' + self.client.scope = "" self.client.save() response = self._post_request(post_data) - response_dict = json.loads(response.content.decode('utf-8')) + response_dict = json.loads(response.content.decode("utf-8")) # It should fail when client does not have any scope added. self.assertEqual(400, response.status_code) - self.assertEqual('invalid_scope', response_dict['error']) + self.assertEqual("invalid_scope", response_dict["error"]) def test_printing_token_used_by_client_credentials_grant_type(self): # Add scope for this client. - self.client.scope = ['something'] + self.client.scope = ["something"] self.client.save() response = self._post_request(self._client_credentials_post_data()) - response_dict = json.loads(response.content.decode('utf-8')) - token = Token.objects.get(access_token=response_dict['access_token']) + response_dict = json.loads(response.content.decode("utf-8")) + token = Token.objects.get(access_token=response_dict["access_token"]) self.assertTrue(str(token)) @override_settings(OIDC_GRANT_TYPE_PASSWORD_ENABLE=True) def test_requested_scope(self): # GRANT_TYPE=PASSWORD response = self._post_request( - post_data=self._password_grant_post_data(['openid', 'invalid_scope']), - extras=self._password_grant_auth_header() + post_data=self._password_grant_post_data(["openid", "invalid_scope"]), + extras=self._password_grant_auth_header(), ) - response_dict = json.loads(response.content.decode('utf-8')) + response_dict = json.loads(response.content.decode("utf-8")) # It should fail when client requested an invalid scope. self.assertEqual(400, response.status_code) - self.assertEqual('invalid_scope', response_dict['error']) + self.assertEqual("invalid_scope", response_dict["error"]) # happy path: no scope response = self._post_request( post_data=self._password_grant_post_data([]), - extras=self._password_grant_auth_header() + extras=self._password_grant_auth_header(), ) - response_dict = json.loads(response.content.decode('utf-8')) + response_dict = json.loads(response.content.decode("utf-8")) self.assertEqual(200, response.status_code) - self.assertEqual(TokenTestCase.SCOPE, response_dict['scope']) + self.assertEqual(TokenTestCase.SCOPE, response_dict["scope"]) # happy path: single scope response = self._post_request( - post_data=self._password_grant_post_data(['email']), - extras=self._password_grant_auth_header() + post_data=self._password_grant_post_data(["email"]), + extras=self._password_grant_auth_header(), ) - response_dict = json.loads(response.content.decode('utf-8')) + response_dict = json.loads(response.content.decode("utf-8")) self.assertEqual(200, response.status_code) - self.assertEqual('email', response_dict['scope']) + self.assertEqual("email", response_dict["scope"]) # happy path: multiple scopes response = self._post_request( - post_data=self._password_grant_post_data(['email', 'openid']), - extras=self._password_grant_auth_header() + post_data=self._password_grant_post_data(["email", "openid"]), + extras=self._password_grant_auth_header(), ) # GRANT_TYPE=CLIENT_CREDENTIALS - response_dict = json.loads(response.content.decode('utf-8')) + response_dict = json.loads(response.content.decode("utf-8")) self.assertEqual(200, response.status_code) - self.assertEqual('email openid', response_dict['scope']) + self.assertEqual("email openid", response_dict["scope"]) response = self._post_request( - post_data=self._client_credentials_post_data(['openid', 'invalid_scope']) + post_data=self._client_credentials_post_data(["openid", "invalid_scope"]) ) - response_dict = json.loads(response.content.decode('utf-8')) + response_dict = json.loads(response.content.decode("utf-8")) # It should fail when client requested an invalid scope. self.assertEqual(400, response.status_code) - self.assertEqual('invalid_scope', response_dict['error']) + self.assertEqual("invalid_scope", response_dict["error"]) # happy path: no scope response = self._post_request(post_data=self._client_credentials_post_data()) - response_dict = json.loads(response.content.decode('utf-8')) + response_dict = json.loads(response.content.decode("utf-8")) self.assertEqual(200, response.status_code) - self.assertEqual(TokenTestCase.SCOPE, response_dict['scope']) + self.assertEqual(TokenTestCase.SCOPE, response_dict["scope"]) # happy path: single scope - response = self._post_request(post_data=self._client_credentials_post_data(['email'])) + response = self._post_request( + post_data=self._client_credentials_post_data(["email"]) + ) - response_dict = json.loads(response.content.decode('utf-8')) + response_dict = json.loads(response.content.decode("utf-8")) self.assertEqual(200, response.status_code) - self.assertEqual('email', response_dict['scope']) + self.assertEqual("email", response_dict["scope"]) # happy path: multiple scopes response = self._post_request( - post_data=self._client_credentials_post_data(['email', 'openid']) + post_data=self._client_credentials_post_data(["email", "openid"]) ) - response_dict = json.loads(response.content.decode('utf-8')) + response_dict = json.loads(response.content.decode("utf-8")) self.assertEqual(200, response.status_code) - self.assertEqual('email openid', response_dict['scope']) + self.assertEqual("email openid", response_dict["scope"]) diff --git a/oidc_provider/tests/cases/test_userinfo_endpoint.py b/oidc_provider/tests/cases/test_userinfo_endpoint.py index 832d4354..8547815b 100644 --- a/oidc_provider/tests/cases/test_userinfo_endpoint.py +++ b/oidc_provider/tests/cases/test_userinfo_endpoint.py @@ -1,6 +1,7 @@ import json from datetime import timedelta + try: from urllib.parse import urlencode except ImportError: @@ -31,7 +32,7 @@ class UserInfoTestCase(TestCase): def setUp(self): self.factory = RequestFactory() self.user = create_fake_user() - self.client = create_fake_client(response_type='code') + self.client = create_fake_client(response_type="code") def _create_token(self, extra_scope=None): """ @@ -39,12 +40,9 @@ def _create_token(self, extra_scope=None): """ if extra_scope is None: extra_scope = [] - scope = ['openid', 'email'] + extra_scope + scope = ["openid", "email"] + extra_scope - token = create_token( - user=self.user, - client=self.client, - scope=scope) + token = create_token(user=self.user, client=self.client, scope=scope) id_token_dic = create_id_token( token=token, @@ -59,17 +57,17 @@ def _create_token(self, extra_scope=None): return token - def _post_request(self, access_token, schema='Bearer'): + def _post_request(self, access_token, schema="Bearer"): """ Makes a request to the userinfo endpoint by sending the `post_data` parameters using the 'multipart/form-data' format. """ - url = reverse('oidc_provider:userinfo') + url = reverse("oidc_provider:userinfo") - request = self.factory.post(url, data={}, content_type='multipart/form-data') + request = self.factory.post(url, data={}, content_type="multipart/form-data") - request.META['HTTP_AUTHORIZATION'] = schema + ' ' + access_token + request.META["HTTP_AUTHORIZATION"] = schema + " " + access_token response = userinfo(request) @@ -91,7 +89,7 @@ def test_response_with_valid_token_lowercase_bearer(self): """ token = self._create_token() - response = self._post_request(token.access_token, schema='bearer') + response = self._post_request(token.access_token, schema="bearer") self.assertEqual(response.status_code, 200) self.assertEqual(bool(response.content), True) @@ -108,7 +106,7 @@ def test_response_with_expired_token(self): self.assertEqual(response.status_code, 401) try: - is_header_field_ok = 'invalid_token' in response['WWW-Authenticate'] + is_header_field_ok = "invalid_token" in response["WWW-Authenticate"] except KeyError: is_header_field_ok = False self.assertEqual(is_header_field_ok, True) @@ -116,7 +114,7 @@ def test_response_with_expired_token(self): def test_response_with_invalid_scope(self): token = self._create_token() - token.scope = ['otherone'] + token.scope = ["otherone"] token.save() response = self._post_request(token.access_token) @@ -124,7 +122,7 @@ def test_response_with_invalid_scope(self): self.assertEqual(response.status_code, 403) try: - is_header_field_ok = 'insufficient_scope' in response['WWW-Authenticate'] + is_header_field_ok = "insufficient_scope" in response["WWW-Authenticate"] except KeyError: is_header_field_ok = False self.assertEqual(is_header_field_ok, True) @@ -136,9 +134,15 @@ def test_accesstoken_query_string_parameter(self): """ token = self._create_token() - url = reverse('oidc_provider:userinfo') + '?' + urlencode({ - 'access_token': token.access_token, - }) + url = ( + reverse("oidc_provider:userinfo") + + "?" + + urlencode( + { + "access_token": token.access_token, + } + ) + ) request = self.factory.get(url) response = userinfo(request) @@ -147,20 +151,29 @@ def test_accesstoken_query_string_parameter(self): self.assertEqual(bool(response.content), True) def test_user_claims_in_response(self): - token = self._create_token(extra_scope=['profile']) + token = self._create_token(extra_scope=["profile"]) response = self._post_request(token.access_token) - response_dic = json.loads(response.content.decode('utf-8')) + response_dic = json.loads(response.content.decode("utf-8")) self.assertEqual(response.status_code, 200) self.assertEqual(bool(response.content), True) - self.assertIn('given_name', response_dic, msg='"given_name" claim should be in response.') - self.assertNotIn('profile', response_dic, msg='"profile" claim should not be in response.') + self.assertIn( + "given_name", response_dic, msg='"given_name" claim should be in response.' + ) + self.assertNotIn( + "profile", response_dic, msg='"profile" claim should not be in response.' + ) # Now adding `address` scope. - token = self._create_token(extra_scope=['profile', 'address']) + token = self._create_token(extra_scope=["profile", "address"]) response = self._post_request(token.access_token) - response_dic = json.loads(response.content.decode('utf-8')) + response_dic = json.loads(response.content.decode("utf-8")) - self.assertIn('address', response_dic, msg='"address" claim should be in response.') self.assertIn( - 'country', response_dic['address'], msg='"country" claim should be in response.') + "address", response_dic, msg='"address" claim should be in response.' + ) + self.assertIn( + "country", + response_dic["address"], + msg='"country" claim should be in response.', + ) diff --git a/oidc_provider/tests/cases/test_utils.py b/oidc_provider/tests/cases/test_utils.py index 24c9ae65..d146c687 100644 --- a/oidc_provider/tests/cases/test_utils.py +++ b/oidc_provider/tests/cases/test_utils.py @@ -44,7 +44,9 @@ def test_get_issuer(self): # `SITE_URL` not set, from `request` with self.settings(SITE_URL=""): - self.assertEqual(get_issuer(request=request), "http://host-from-request:8888/openid") + self.assertEqual( + get_issuer(request=request), "http://host-from-request:8888/openid" + ) # use settings first if both are provided self.assertEqual(get_issuer(request=request), "http://localhost:8000/openid") @@ -119,13 +121,17 @@ def test_create_id_token_with_include_claims_setting_and_extra(self): class BrowserStateTest(TestCase): @override_settings(OIDC_UNAUTHENTICATED_SESSION_MANAGEMENT_KEY="my_static_key") - def test_get_browser_state_uses_value_from_settings_to_calculate_browser_state(self): + def test_get_browser_state_uses_value_from_settings_to_calculate_browser_state( + self, + ): request = HttpRequest() request.session = mock.Mock(session_key=None) state = get_browser_state_or_default(request) self.assertEqual(state, sha224("my_static_key".encode("utf-8")).hexdigest()) - def test_get_browser_state_uses_session_key_to_calculate_browser_state_if_available(self): + def test_get_browser_state_uses_session_key_to_calculate_browser_state_if_available( + self, + ): request = HttpRequest() request.session = mock.Mock(session_key="my_session_key") state = get_browser_state_or_default(request) diff --git a/oidc_provider/tests/settings.py b/oidc_provider/tests/settings.py index ea61262f..c6973ee4 100644 --- a/oidc_provider/tests/settings.py +++ b/oidc_provider/tests/settings.py @@ -1,79 +1,79 @@ DEBUG = False -SECRET_KEY = 'this-should-be-top-secret' +SECRET_KEY = "this-should-be-top-secret" DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': ':memory:', + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", } } SITE_ID = 1 MIDDLEWARE_CLASSES = [ - 'django.middleware.common.CommonMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', + "django.middleware.common.CommonMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", ] MIDDLEWARE = [ - 'django.middleware.common.CommonMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', + "django.middleware.common.CommonMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", ] TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], }, }, ] INSTALLED_APPS = [ - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.sites', - 'django.contrib.messages', - 'django.contrib.admin', - 'oidc_provider', + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.sites", + "django.contrib.messages", + "django.contrib.admin", + "oidc_provider", ] -ROOT_URLCONF = 'oidc_provider.tests.app.urls' +ROOT_URLCONF = "oidc_provider.tests.app.urls" TEMPLATE_DIRS = [ - 'oidc_provider/tests/templates', + "oidc_provider/tests/templates", ] USE_TZ = True LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'handlers': { - 'console': { - 'class': 'logging.StreamHandler', + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "class": "logging.StreamHandler", }, }, - 'loggers': { - 'oidc_provider': { - 'handlers': ['console'], - 'level': 'DEBUG', + "loggers": { + "oidc_provider": { + "handlers": ["console"], + "level": "DEBUG", }, }, } # OIDC Provider settings. -SITE_URL = 'http://localhost:8000' -OIDC_USERINFO = 'oidc_provider.tests.app.utils.userinfo' +SITE_URL = "http://localhost:8000" +OIDC_USERINFO = "oidc_provider.tests.app.utils.userinfo" diff --git a/oidc_provider/urls.py b/oidc_provider/urls.py index cdebac8e..bbb997f2 100644 --- a/oidc_provider/urls.py +++ b/oidc_provider/urls.py @@ -13,14 +13,20 @@ re_path(r"^userinfo/?$", csrf_exempt(views.userinfo), name="userinfo"), re_path(r"^end-session/?$", views.EndSessionView.as_view(), name="end-session"), re_path( - r"^end-session-prompt/?$", views.EndSessionPromptView.as_view(), name="end-session-prompt" + r"^end-session-prompt/?$", + views.EndSessionPromptView.as_view(), + name="end-session-prompt", ), re_path( r"^\.well-known/openid-configuration/?$", views.ProviderInfoView.as_view(), name="provider-info", ), - re_path(r"^introspect/?$", views.TokenIntrospectionView.as_view(), name="token-introspection"), + re_path( + r"^introspect/?$", + views.TokenIntrospectionView.as_view(), + name="token-introspection", + ), re_path(r"^jwks/?$", views.JwksView.as_view(), name="jwks"), ] diff --git a/oidc_provider/views.py b/oidc_provider/views.py index 61317134..3b50b3b9 100644 --- a/oidc_provider/views.py +++ b/oidc_provider/views.py @@ -80,12 +80,16 @@ def get(self, request, *args, **kwargs): if "login" in authorize.params["prompt"]: if "none" in authorize.params["prompt"]: raise AuthorizeError( - authorize.params["redirect_uri"], "login_required", authorize.grant_type + authorize.params["redirect_uri"], + "login_required", + authorize.grant_type, ) else: django_user_logout(request) next_page = strip_prompt_login(request.get_full_path()) - return redirect_to_login(next_page, settings.get("OIDC_LOGIN_URL")) + return redirect_to_login( + next_page, settings.get("OIDC_LOGIN_URL") + ) if "select_account" in authorize.params["prompt"]: # TODO: see how we can support multiple accounts for the end-user. @@ -103,7 +107,9 @@ def get(self, request, *args, **kwargs): if {"none", "consent"}.issubset(authorize.params["prompt"]): raise AuthorizeError( - authorize.params["redirect_uri"], "consent_required", authorize.grant_type + authorize.params["redirect_uri"], + "consent_required", + authorize.grant_type, ) if not authorize.client.require_consent and ( @@ -122,14 +128,18 @@ def get(self, request, *args, **kwargs): if "none" in authorize.params["prompt"]: raise AuthorizeError( - authorize.params["redirect_uri"], "consent_required", authorize.grant_type + authorize.params["redirect_uri"], + "consent_required", + authorize.grant_type, ) # Generate hidden inputs for the form. context = { "params": authorize.params, } - hidden_inputs = render_to_string("oidc_provider/hidden_inputs.html", context) + hidden_inputs = render_to_string( + "oidc_provider/hidden_inputs.html", context + ) # Remove `openid` from scope list # since we don't need to print it. @@ -147,13 +157,17 @@ def get(self, request, *args, **kwargs): else: if "none" in authorize.params["prompt"]: raise AuthorizeError( - authorize.params["redirect_uri"], "login_required", authorize.grant_type + authorize.params["redirect_uri"], + "login_required", + authorize.grant_type, ) if "login" in authorize.params["prompt"]: next_page = strip_prompt_login(request.get_full_path()) return redirect_to_login(next_page, settings.get("OIDC_LOGIN_URL")) - return redirect_to_login(request.get_full_path(), settings.get("OIDC_LOGIN_URL")) + return redirect_to_login( + request.get_full_path(), settings.get("OIDC_LOGIN_URL") + ) except (ClientIdError, RedirectUriError) as error: context = { @@ -164,7 +178,9 @@ def get(self, request, *args, **kwargs): return render(request, OIDC_TEMPLATES["error"], context) except AuthorizeError as error: - uri = error.create_uri(authorize.params["redirect_uri"], authorize.params["state"]) + uri = error.create_uri( + authorize.params["redirect_uri"], authorize.params["state"] + ) return redirect(uri) @@ -183,7 +199,9 @@ def post(self, request, *args, **kwargs): ) raise AuthorizeError( - authorize.params["redirect_uri"], "access_denied", authorize.grant_type + authorize.params["redirect_uri"], + "access_denied", + authorize.grant_type, ) signals.user_accept_consent.send( @@ -201,7 +219,9 @@ def post(self, request, *args, **kwargs): return redirect(uri) except AuthorizeError as error: - uri = error.create_uri(authorize.params["redirect_uri"], authorize.params["state"]) + uri = error.create_uri( + authorize.params["redirect_uri"], authorize.params["state"] + ) return redirect(uri) @@ -284,7 +304,9 @@ def _build_response_dict(self, request): dic["token_endpoint"] = site_url + reverse("oidc_provider:token") dic["userinfo_endpoint"] = site_url + reverse("oidc_provider:userinfo") dic["end_session_endpoint"] = site_url + reverse("oidc_provider:end-session") - dic["introspection_endpoint"] = site_url + reverse("oidc_provider:token-introspection") + dic["introspection_endpoint"] = site_url + reverse( + "oidc_provider:token-introspection" + ) dic["response_types_supported"] = self.types_supported @@ -295,13 +317,18 @@ def _build_response_dict(self, request): # See: http://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes dic["subject_types_supported"] = ["public"] - dic["token_endpoint_auth_methods_supported"] = ["client_secret_post", "client_secret_basic"] + dic["token_endpoint_auth_methods_supported"] = [ + "client_secret_post", + "client_secret_basic", + ] if settings.get("OIDC_SESSION_MANAGEMENT_ENABLE"): - dic["check_session_iframe"] = site_url + reverse("oidc_provider:check-session-iframe") + dic["check_session_iframe"] = site_url + reverse( + "oidc_provider:check-session-iframe" + ) - if settings.get('OIDC_SCOPES_SUPPORTED'): - dic['scopes_supported'] = settings.get('OIDC_SCOPES_SUPPORTED') + if settings.get("OIDC_SCOPES_SUPPORTED"): + dic["scopes_supported"] = settings.get("OIDC_SCOPES_SUPPORTED") return dic @@ -321,7 +348,11 @@ def get(self, request): response_dict = cached_dict else: response_dict = self._build_response_dict(request) - cache.set(cache_key, response_dict, settings.get("OIDC_DISCOVERY_CACHE_EXPIRE")) + cache.set( + cache_key, + response_dict, + settings.get("OIDC_DISCOVERY_CACHE_EXPIRE"), + ) else: response_dict = self._build_response_dict(request) @@ -379,7 +410,9 @@ def logout_user( @method_decorator(csrf_exempt) def dispatch(self, request, *args, **kwargs): - self.id_token_hint = request.POST.get("id_token_hint") or request.GET.get("id_token_hint") + self.id_token_hint = request.POST.get("id_token_hint") or request.GET.get( + "id_token_hint" + ) self.post_logout_redirect_uri = request.POST.get( "post_logout_redirect_uri" ) or request.GET.get("post_logout_redirect_uri") @@ -392,7 +425,10 @@ def dispatch(self, request, *args, **kwargs): self.client = Client.objects.get(client_id=client_id) if self.post_logout_redirect_uri: - if self.post_logout_redirect_uri not in self.client.post_logout_redirect_uris: + if ( + self.post_logout_redirect_uri + not in self.client.post_logout_redirect_uris + ): return redirect( reverse("oidc_provider:end-session-prompt") + "?" @@ -403,7 +439,9 @@ def dispatch(self, request, *args, **kwargs): ) ) elif self.client.post_logout_redirect_uris: - self.post_logout_redirect_uri = self.client.post_logout_redirect_uris[0] + self.post_logout_redirect_uri = ( + self.client.post_logout_redirect_uris[0] + ) else: self.logout_user( request, @@ -413,7 +451,9 @@ def dispatch(self, request, *args, **kwargs): self.client, ) return render( - request, "oidc_provider/end_session_completed.html", {"client": self.client} + request, + "oidc_provider/end_session_completed.html", + {"client": self.client}, ) if self.state: @@ -457,7 +497,9 @@ def get(self, request, *args, **kwargs): return redirect(self.client.post_logout_redirect_uris[0]) else: return render( - request, "oidc_provider/end_session_completed.html", {"client": self.client} + request, + "oidc_provider/end_session_completed.html", + {"client": self.client}, ) return super(EndSessionPromptView, self).get(request, *args, **kwargs) @@ -494,10 +536,16 @@ def post(self, request, *args, **kwargs): return redirect(next_page) elif allowed: return render( - request, "oidc_provider/end_session_completed.html", {"client": self.client} + request, + "oidc_provider/end_session_completed.html", + {"client": self.client}, ) else: - return render(request, "oidc_provider/end_session_failed.html", {"client": self.client}) + return render( + request, + "oidc_provider/end_session_failed.html", + {"client": self.client}, + ) class CheckSessionIframeView(View): diff --git a/setup.py b/setup.py index f20b52b8..b916a686 100644 --- a/setup.py +++ b/setup.py @@ -12,40 +12,39 @@ os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) setup( - name='django-oidc-provider', - version=version['__version__'], + name="django-oidc-provider", + version=version["__version__"], packages=find_packages(), include_package_data=True, - license='MIT License', - description='OpenID Connect Provider implementation for Django.', - long_description='http://github.com/juanifioren/django-oidc-provider', - url='http://github.com/juanifioren/django-oidc-provider', - author='Juan Ignacio Fiorentino', - author_email='juanifioren@gmail.com', + license="MIT License", + description="OpenID Connect Provider implementation for Django.", + long_description="http://github.com/juanifioren/django-oidc-provider", + url="http://github.com/juanifioren/django-oidc-provider", + author="Juan Ignacio Fiorentino", + author_email="juanifioren@gmail.com", zip_safe=False, classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Environment :: Web Environment', - 'Framework :: Django', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: MIT License', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: 3.11', - 'Topic :: Internet :: WWW/HTTP', - 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', + "Development Status :: 5 - Production/Stable", + "Environment :: Web Environment", + "Framework :: Django", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Internet :: WWW/HTTP :: Dynamic Content", ], - test_suite='runtests.runtests', + test_suite="runtests.runtests", tests_require=[ - 'pyjwkest>=1.3.0', - 'mock>=2.0.0', + "pyjwkest>=1.3.0", + "mock>=2.0.0", ], - install_requires=[ - 'pyjwkest>=1.3.0', + "pyjwkest>=1.3.0", ], ) From e0d1df60a61a44857db81de986b153ce7b85a53f Mon Sep 17 00:00:00 2001 From: Kit Dallege Date: Tue, 30 Sep 2025 16:04:45 -0400 Subject: [PATCH 52/54] Add missing import for secrets.token_hex --- oidc_provider/lib/endpoints/authorize.py | 1 + 1 file changed, 1 insertion(+) diff --git a/oidc_provider/lib/endpoints/authorize.py b/oidc_provider/lib/endpoints/authorize.py index fad5d28f..273d554f 100644 --- a/oidc_provider/lib/endpoints/authorize.py +++ b/oidc_provider/lib/endpoints/authorize.py @@ -3,6 +3,7 @@ from datetime import timedelta from hashlib import md5 from hashlib import sha256 +from secrets import token_hex from oidc_provider.compat import get_attr_or_callable From f13330de13520740a63db3966615a30f24f0b8f3 Mon Sep 17 00:00:00 2001 From: Ben Davis Date: Tue, 30 Sep 2025 15:23:20 -0500 Subject: [PATCH 53/54] format fixes --- oidc_provider/models.py | 4 +--- oidc_provider/tests/app/utils.py | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/oidc_provider/models.py b/oidc_provider/models.py index 54a4fd72..2edf78b7 100644 --- a/oidc_provider/models.py +++ b/oidc_provider/models.py @@ -147,9 +147,7 @@ def response_type_values(self): def response_type_descriptions(self): # return as a list, rather than a generator, so descriptions display correctly in admin - return [ - response_type.description for response_type in self.response_types.all() - ] + return [response_type.description for response_type in self.response_types.all()] @property def redirect_uris(self): diff --git a/oidc_provider/tests/app/utils.py b/oidc_provider/tests/app/utils.py index 3d2b1a4b..5c3608cc 100644 --- a/oidc_provider/tests/app/utils.py +++ b/oidc_provider/tests/app/utils.py @@ -179,8 +179,6 @@ def fake_introspection_processing_hook(response_dict, client, id_token): class TestAuthBackend: def authenticate(self, *args, **kwargs): - if django.VERSION[0] >= 2 or ( - django.VERSION[0] == 1 and django.VERSION[1] >= 11 - ): + if django.VERSION[0] >= 2 or (django.VERSION[0] == 1 and django.VERSION[1] >= 11): assert len(args) > 0 and args[0] return ModelBackend().authenticate(*args, **kwargs) From 8e01a758ec76aaf9d91ef7f4cae2340300080de9 Mon Sep 17 00:00:00 2001 From: Ben Davis Date: Tue, 30 Sep 2025 15:28:04 -0500 Subject: [PATCH 54/54] lint fixes --- oidc_provider/lib/endpoints/authorize.py | 2 -- oidc_provider/lib/endpoints/token.py | 1 - 2 files changed, 3 deletions(-) diff --git a/oidc_provider/lib/endpoints/authorize.py b/oidc_provider/lib/endpoints/authorize.py index 273d554f..49220c2d 100644 --- a/oidc_provider/lib/endpoints/authorize.py +++ b/oidc_provider/lib/endpoints/authorize.py @@ -1,7 +1,6 @@ import logging from datetime import datetime from datetime import timedelta -from hashlib import md5 from hashlib import sha256 from secrets import token_hex @@ -18,7 +17,6 @@ from urllib.parse import urlencode from urllib.parse import urlsplit from urllib.parse import urlunsplit -from uuid import uuid4 from django.utils import dateformat from django.utils import timezone diff --git a/oidc_provider/lib/endpoints/token.py b/oidc_provider/lib/endpoints/token.py index f9107646..f795cd66 100644 --- a/oidc_provider/lib/endpoints/token.py +++ b/oidc_provider/lib/endpoints/token.py @@ -12,7 +12,6 @@ from oidc_provider.lib.errors import UserAuthError from oidc_provider.lib.utils.oauth2 import extract_client_auth from oidc_provider.lib.utils.sanitization import sanitize_client_id -from oidc_provider.lib.utils.token import create_id_token from oidc_provider.lib.utils.token import create_token from oidc_provider.lib.utils.token import encode_id_token from oidc_provider.models import Client