diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 662af4f4..aa1301bc 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,26 +1,61 @@ -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: - tests: + formatting: + name: "Check Code 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: + name: "Check Code Linting" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/ruff-action@v3 + with: + args: "--version" + - run: "ruff check --diff" + + 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@v3 + - 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 - - name: Install tox - run: | - python -m pip install --upgrade pip - pip install tox + python-version: ${{ matrix.python }} - name: Run tox - run: tox + run: tox run --skip-missing-interpreters=false -e py$(echo "${{ matrix.python }}" | tr -d '.')-django$(echo "${{ matrix.django }}" | tr -d '.') diff --git a/.vscode/settings.json b/.vscode/settings.json index e64211ad..7bcb17d7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,9 +2,14 @@ "[python]": { "editor.formatOnSave": true, "editor.codeActionsOnSave": { - "source.fixAll": "explicit", - "source.organizeImports": "explicit" + "source.fixAll.ruff": "explicit", + "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 diff --git a/README.md b/README.md index e9daf3a9..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-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/) @@ -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) 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/docs/sections/changelog.rst b/docs/sections/changelog.rst index 0dceca8d..e0eff7b8 100644 --- a/docs/sections/changelog.rst +++ b/docs/sections/changelog.rst @@ -8,7 +8,28 @@ All notable changes to this project will be documented in this file. Unreleased ========== -None +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. +* Fixed: client_id sanitization to prevent database errors. + +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. +* Added: Passing Request Parameters as JWTs now returning request_not_supported error. +* Added: Simplified chinese translation. +* Changed: ID Token JSON encoder improved using DjangoJSONEncoder. +* Changed: Use unittest.mock in tests. Remove mock library. 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/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 ================== 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. 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`. 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..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..f5da4dfd 100644 --- a/oidc_provider/admin.py +++ b/oidc_provider/admin.py @@ -2,45 +2,51 @@ 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.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 +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" + self.fields["jwt_alg"].required = False def clean_client_id(self): - instance = getattr(self, 'instance', None) + 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): - 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 +54,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 +106,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 +114,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..119d06ff 100644 --- a/oidc_provider/apps.py +++ b/oidc_provider/apps.py @@ -1,7 +1,7 @@ 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' + 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/authorize.py b/oidc_provider/lib/endpoints/authorize.py index a4f6d0a7..49220c2d 100644 --- a/oidc_provider/lib/endpoints/authorize.py +++ b/oidc_provider/lib/endpoints/authorize.py @@ -1,6 +1,10 @@ import logging +from datetime import datetime from datetime import timedelta from hashlib import sha256 +from secrets import token_hex + +from oidc_provider.compat import get_attr_or_callable try: from urllib import urlencode @@ -14,8 +18,7 @@ from urllib.parse import urlsplit from urllib.parse import urlunsplit -from secrets import token_hex - +from django.utils import dateformat from django.utils import timezone from oidc_provider import settings @@ -24,8 +27,11 @@ 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 +from oidc_provider.lib.utils.token import encode_id_token from oidc_provider.models import Client from oidc_provider.models import UserConsent @@ -66,17 +72,18 @@ 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", "") + 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() 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", "") @@ -103,6 +110,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"] @@ -187,15 +200,7 @@ def create_response_uri(self): # Include at_hash when access_token is being returned. if "access_token" in query_fragment: kwargs["at_hash"] = token.at_hash - - create_id_token_hook = settings.import_hook( - "OIDC_IDTOKEN_CREATE_HOOK" - ) - id_token_dic = create_id_token_hook(**kwargs) - - encode_id_token = settings.import_hook( - "OIDC_IDTOKEN_ENCODE_HOOK" - ) + id_token_dic = create_id_token(**kwargs) # Check if response_type must include id_token in the response. if self.params["response_type"] in [ @@ -308,6 +313,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(datetime.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/lib/endpoints/introspection.py b/oidc_provider/lib/endpoints/introspection.py index c1e8a8e6..f7845b28 100644 --- a/oidc_provider/lib/endpoints/introspection.py +++ b/oidc_provider/lib/endpoints/introspection.py @@ -2,19 +2,20 @@ 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.lib.utils.sanitization import sanitize_client_id +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 +26,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"] = sanitize_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 +109,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..f795cd66 100644 --- a/oidc_provider/lib/endpoints/token.py +++ b/oidc_provider/lib/endpoints/token.py @@ -11,7 +11,9 @@ 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_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 @@ -27,12 +29,12 @@ 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) - self.params["client_id"] = client_id + 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", "") @@ -56,30 +58,21 @@ def validate_params(self): try: self.client = Client.objects.get(client_id=self.params["client_id"]) except Client.DoesNotExist: - logger.info( - "[Token] Client does not exist: %s", - self.params["client_id"], - extra=log_extra, - ) + 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.info( + logger.debug( "[Token] Invalid client secret: client %s do not have secret %s", self.client.client_id, self.client.client_secret, - extra=log_extra, ) raise TokenError("invalid_client") if self.params["grant_type"] == "authorization_code": if self.params["redirect_uri"] not in self.client.redirect_uris: - logger.info( - "[Token] Invalid redirect uri: %s", - self.params["redirect_uri"], - extra=log_extra, - ) + logger.debug("[Token] Invalid redirect uri: %s", self.params["redirect_uri"]) raise TokenError("invalid_client") try: @@ -87,34 +80,19 @@ def validate_params(self): code=self.params["code"] ) except DatabaseError: - logger.info( - "[Token] Code cannot be reused: %s", - self.params["code"], - extra=log_extra, - ) + logger.debug("[Token] Code cannot be reused: %s", self.params["code"]) raise TokenError("invalid_grant") except Code.DoesNotExist: - logger.info( - "[Token] Code does not exist: %s", - self.params["code"], - extra=log_extra, - ) + logger.debug("[Token] Code does not exist: %s", self.params["code"]) raise TokenError("invalid_grant") - # Log the id instead of the code itself to reduce leak risk. We can look it up. - log_extra["code_id"] = self.code.id - if not (self.code.client == self.client) or self.code.has_expired(): - logger.info( - "[Token] Invalid code: invalid client or code has expired", - extra=log_extra, - ) + 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: - logger.info("[Token] Missing code_verifier", extra=log_extra) raise TokenError("invalid_grant") if self.code.code_challenge_method == "S256": @@ -130,10 +108,6 @@ def validate_params(self): # TODO: We should explain the error. if not (new_code_challenge == self.code.code_challenge): - logger.info( - "[Token] code verifier did not match code challenge", - extra=log_extra, - ) raise TokenError("invalid_grant") elif self.params["grant_type"] == "password": @@ -157,7 +131,7 @@ def validate_params(self): elif self.params["grant_type"] == "refresh_token": if not self.params["refresh_token"]: - logger.info("[Token] Missing refresh token") + logger.debug("[Token] Missing refresh token") raise TokenError("invalid_grant") try: @@ -166,16 +140,16 @@ def validate_params(self): ) except Token.DoesNotExist: - logger.info( + logger.debug( "[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.info("[Token] Client using client credentials with empty scope") + logger.debug("[Token] Client using client credentials with empty scope") raise TokenError("invalid_scope") else: - logger.info("[Token] Invalid grant type: %s", self.params["grant_type"]) + logger.debug("[Token] Invalid grant type: %s", self.params["grant_type"]) raise TokenError("unsupported_grant_type") def validate_requested_scopes(self): @@ -191,7 +165,7 @@ def validate_requested_scopes(self): if scope_requested in self.client.scope: token_scopes.append(scope_requested) else: - logger.error( + logger.debug( "[Token] The request scope %s is not supported by client %s", scope_requested, self.client.client_id, @@ -257,7 +231,7 @@ def create_code_response_dic(self): "refresh_token": token.refresh_token, "token_type": "bearer", "expires_in": settings.get("OIDC_TOKEN_EXPIRE"), - "id_token": self._encode_id_token(id_token_dic, token.client), + "id_token": encode_id_token(id_token_dic, token.client), } return dic @@ -304,7 +278,7 @@ def create_refresh_response_dic(self): "refresh_token": token.refresh_token, "token_type": "bearer", "expires_in": settings.get("OIDC_TOKEN_EXPIRE"), - "id_token": self._encode_id_token(id_token_dic, self.token.client), + "id_token": encode_id_token(id_token_dic, self.token.client), } return dic @@ -338,7 +312,7 @@ def create_access_token_response_dic(self): "refresh_token": token.refresh_token, "expires_in": settings.get("OIDC_TOKEN_EXPIRE"), "token_type": "bearer", - "id_token": self._encode_id_token(id_token_dic, token.client), + "id_token": encode_id_token(id_token_dic, token.client), "scope": " ".join(token.scope), } 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/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/lib/utils/token.py b/oidc_provider/lib/utils/token.py index 403440ad..7b8618f4 100644 --- a/oidc_provider/lib/utils/token.py +++ b/oidc_provider/lib/utils/token.py @@ -1,22 +1,23 @@ -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 jwkest.jwk import RSAKey as jwk_RSAKey -from jwkest.jwk import SYMKey -from jwkest.jws import JWS -from jwkest.jwt import JWT +import jwt +from cryptography.hazmat.primitives import serialization +from django.utils import dateformat +from django.utils import timezone -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 + +# 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/locale/ru/LC_MESSAGES/django.mo b/oidc_provider/locale/ru/LC_MESSAGES/django.mo new file mode 100644 index 00000000..382a073d Binary files /dev/null and b/oidc_provider/locale/ru/LC_MESSAGES/django.mo differ 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..1bd7c108 --- /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. +# Sinyawskiy Aleksey , 2025. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-20 13:01+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: Sinyawskiy Aleksey \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" 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 00000000..a247c136 Binary files /dev/null and b/oidc_provider/locale/zh_Hans/LC_MESSAGES/django.mo differ 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密钥" diff --git a/oidc_provider/management/commands/creatersakey.py b/oidc_provider/management/commands/creatersakey.py index 9d609c56..1b5b0c8f 100644 --- a/oidc_provider/management/commands/creatersakey.py +++ b/oidc_provider/management/commands/creatersakey.py @@ -1,16 +1,30 @@ -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 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')) + # 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(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 1be05def..dc80bbe2 100644 --- a/oidc_provider/migrations/0007_auto_20160111_1844.py +++ b/oidc_provider/migrations/0007_auto_20160111_1844.py @@ -3,40 +3,48 @@ from __future__ import unicode_literals import datetime -from datetime import timezone -from django.db import migrations, models +from django.db import migrations +from django.db import models 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=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 fc562414..62633829 100644 --- a/oidc_provider/migrations/0016_userconsent_and_verbosenames.py +++ b/oidc_provider/migrations/0016_userconsent_and_verbosenames.py @@ -3,183 +3,215 @@ from __future__ import unicode_literals import datetime -from datetime import timezone -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/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/settings.py b/oidc_provider/settings.py index 45fb1984..03e12812 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,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 @@ -126,7 +127,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 +135,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 +143,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 +151,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 +159,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 +167,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 +175,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): @@ -202,10 +203,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): @@ -219,7 +217,7 @@ def OIDC_SCOPES_SUPPORTED(self): """ OPTIONAL: A list of scopes supported by the OP. """ - return ['openid'] + return ["openid"] default_settings = DefaultSettings() @@ -230,12 +228,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) @@ -255,7 +253,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/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_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 7ccd0a23..a03f8a90 100644 --- a/oidc_provider/tests/cases/test_authorize_endpoint.py +++ b/oidc_provider/tests/cases/test_authorize_endpoint.py @@ -1,56 +1,60 @@ +from datetime import datetime + 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 freezegun import freeze_time -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, -) +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 jwkest.jwt import JWT +from django.test import override_settings 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 +70,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_code = 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 +92,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 +106,45 @@ 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_code.client_id, + "response_type": "something_wrong", + "redirect_uri": self.client_code.default_redirect_uri, + "scope": "openid email", + "state": self.state, + } + + 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=", 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) + 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=request_not_supported", response["Location"]) def test_user_not_logged(self): """ @@ -123,17 +154,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_code.client_id, + "response_type": "code", + "redirect_uri": self.client_code.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 +175,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_code.client_id, + "response_type": "code", + "redirect_uri": self.client_code.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_code.client_id, + "redirect_uri": self.client_code.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 +216,38 @@ 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_code.client_id, + "redirect_uri": self.client_code.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_code + ) + 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 +256,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 +293,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_code.client_id, + "redirect_uri": self.client_code.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_code + ) + 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_code.default_redirect_uri), + msg="Different redirect_uri returned", + ) def test_unknown_redirect_uris_are_rejected(self): """ @@ -296,16 +330,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_code.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 +348,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_code.client_id, + "response_type": "code", + "redirect_uri": self.client_code.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 +366,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,58 +384,56 @@ 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_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", } - 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') - def test_prompt_login_parameter(self, logout_function): + @patch("oidc_provider.views.django_user_logout") + 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, - 'scope': 'openid email', - 'state': self.state, - 'prompt': 'login' + "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", } - 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'], - "Found prompt=login, this leads to infinite login loop. See " - "https://github.com/juanifioren/django-oidc-provider/issues/197." + quote("prompt=login"), + response["Location"], + "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() + response = self._auth_request("get", data, is_user_authenticated=True) + self.assertIn(settings.get("OIDC_LOGIN_URL"), response["Location"]) + 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." + quote("prompt=login"), + response["Location"], + "Found prompt=login, this leads to infinite login loop.", ) def test_prompt_login_none_parameter(self): @@ -409,21 +443,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_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", } - 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 +465,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_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", } - 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 +487,121 @@ 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_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", } - 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"]) + + @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. """ # 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)) + + 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", + } - self.assertIn('prompt', strip_prompt_login(path3)) - self.assertIn('none', strip_prompt_login(path3)) - self.assertNotIn('login', strip_prompt_login(path3)) + 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): @@ -503,18 +610,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_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) - 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 +631,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_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, } - 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 +648,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_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, + "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 +678,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 +708,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_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, + "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.decode(fragment["id_token"][0], options={"verify_signature": False}) - self.assertIn('at_hash', id_token) + self.assertIn("at_hash", id_token) def test_idtoken_at_hash(self): """ @@ -626,74 +734,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.decode(fragment["id_token"][0], options={"verify_signature": False}) - 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 +810,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 +835,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 +895,18 @@ 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) + 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_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_end_session_endpoint.py b/oidc_provider/tests/cases/test_end_session_endpoint.py index f02596c2..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, override_settings - -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): @@ -41,24 +44,6 @@ def setUp(self): self.url = reverse("oidc_provider:end-session") self.url_prompt = reverse("oidc_provider:end-session-prompt") - @override_settings(OIDC_LOGOUT_URL='/post-logout/') - def test_redirects_when_aud_is_str(self): - 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. - self.assertEqual(response.headers["Location"], self.url_prompt) - - 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) - - query_params['id_token_hint'] = id_token - - response = self.client.get(self.url, query_params) - self.assertEqual(response.headers["Location"], self.url_logout) - 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. @@ -67,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 ): @@ -84,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 ): @@ -173,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. @@ -198,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. @@ -219,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..916c1d3b 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_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_token_endpoint.py b/oidc_provider/tests/cases/test_token_endpoint.py index 8990d3d2..c736c3fd 100644 --- a/oidc_provider/tests/cases/test_token_endpoint.py +++ b/oidc_provider/tests/cases/test_token_endpoint.py @@ -1,51 +1,52 @@ import json 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.test import ( - RequestFactory, - override_settings, -) + +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 +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 -from jwkest.jwt import JWT -from mock import patch +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 ( - 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 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 +from oidc_provider.views import TokenView +from oidc_provider.views import userinfo class TokenTestCase(TestCase): @@ -54,25 +55,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": FAKE_USER_PASSWORD, + "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 +82,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 +99,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 +125,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,150 +147,142 @@ 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 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') + 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 = 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) - 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): @@ -296,23 +291,22 @@ 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) 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 = 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) - 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 +317,43 @@ 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 = decode_id_token(response_dic["id_token"], self.client) - 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 +371,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,81 +381,78 @@ 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): - SIGKEYS = self._get_keys() - # Retrieve refresh token code = self._create_code() 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 = 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) - 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 = decode_id_token(response_dic2["id_token"], self.client) - 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 +465,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 +499,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 +528,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 +584,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.decode(response_dic["id_token"], options={"verify_signature": False}) - 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.decode(response_dic["id_token"], options={"verify_signature": False}) - self.assertEqual(id_token.get('nonce'), None) + self.assertEqual(id_token.get("nonce"), None) def test_id_token_contains_at_hash(self): """ @@ -617,10 +610,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.decode(response_dic["id_token"], options={"verify_signature": False}) - self.assertTrue(id_token.get('at_hash')) + self.assertTrue(id_token.get("at_hash")) def test_idtoken_sign_validation(self): """ @@ -628,40 +621,28 @@ 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) 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) + response_dic = json.loads(response.content.decode("utf-8")) - @override_settings( - 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. - """ - code = self._create_code() - - post_data = self._auth_code_post_data(code=code.code) - - response = self._post_request(post_data) + # This will raise an exception if verification fails + decode_id_token(response_dic["id_token"], self.client) - response_dic = json.loads(response.content.decode('utf-8')) - id_token = JWT().unpack(response_dic['id_token'].encode('utf-8')).payload() + def test_idtoken_sign_validation_fail(self): + bad_id_token = jwt.encode({"some": "payload"}, "wrong_key", algorithm="HS256") - self.assertEqual(id_token.get('sub'), self.user.email) + with self.assertRaises(expected_exception=jwt.InvalidTokenError): + decode_id_token(bad_id_token, self.client) @override_settings( - OIDC_IDTOKEN_PROCESSING_HOOK='oidc_provider.tests.app.utils.fake_idtoken_processing_hook') - def test_additional_idtoken_processing_hook(self): + 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_PROCESSING_HOOK. + Test custom function for setting OIDC_IDTOKEN_SUB_GENERATOR. """ code = self._create_code() @@ -669,18 +650,15 @@ 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.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) + 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_one_element_in_tuple(self): + def test_additional_idtoken_processing_hook(self): """ Test custom function for setting OIDC_IDTOKEN_PROCESSING_HOOK. """ @@ -690,15 +668,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.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) + 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 +689,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.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) + 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 +711,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.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) + 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 +736,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.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) + 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 +779,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.decode(response_dic["id_token"], options={"verify_signature": False}) return id_token def test_pkce_parameters(self): @@ -812,19 +788,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 +814,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 +843,449 @@ 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"]) + + +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/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/cases/test_utils.py b/oidc_provider/tests/cases/test_utils.py index 24c9ae65..788e633d 100644 --- a/oidc_provider/tests/cases/test_utils.py +++ b/oidc_provider/tests/cases/test_utils.py @@ -1,7 +1,8 @@ import time +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 @@ -10,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 @@ -116,17 +118,100 @@ 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") 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()) + + +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") 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 = [ diff --git a/oidc_provider/version.py b/oidc_provider/version.py index 0e691e95..c8c5f6a3 100644 --- a/oidc_provider/version.py +++ b/oidc_provider/version.py @@ -1 +1 @@ -__version__ = "0.8.3+orm.3" +__version__ = "0.9.0+orm.1" diff --git a/oidc_provider/views.py b/oidc_provider/views.py index 61317134..d9d539bc 100644 --- a/oidc_provider/views.py +++ b/oidc_provider/views.py @@ -4,11 +4,17 @@ 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 +import jwt.utils +from cryptography.hazmat.primitives import serialization from django.contrib.auth.views import redirect_to_login try: @@ -19,40 +25,40 @@ 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 jwkest import long_to_base64 +from django.views.generic import TemplateView +from django.views.generic import View -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,12 +309,11 @@ 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") - if settings.get('OIDC_SCOPES_SUPPORTED'): - dic['scopes_supported'] = settings.get('OIDC_SCOPES_SUPPORTED') - return dic def _build_cache_key(self, request): @@ -336,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/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/setup.py b/setup.py index f20b52b8..2531404f 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,42 @@ 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", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "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', + "PyJWT>=2.8.0", + "cryptography>=3.4.0", ], - install_requires=[ - 'pyjwkest>=1.3.0', + "PyJWT>=2.8.0", + "cryptography>=3.4.0", ], ) diff --git a/tox.ini b/tox.ini index 088c1390..e76ad56a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,26 +1,26 @@ [tox] 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}, - flake8 + 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] changedir= oidc_provider deps = - mock + django32: django>=3.2,<3.3 + django42: django>=4.2,<4.3 + django52: django>=5.2,<5.3 + freezegun 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 commands = pytest --cov=oidc_provider {posargs} @@ -37,13 +37,14 @@ 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 -python_files = test_*.py \ No newline at end of file +python_files = test_*.py