├── example ├── app │ ├── __init__.py │ ├── wsgi.py │ ├── templates │ │ ├── oidc_provider │ │ │ ├── error.html │ │ │ └── authorize.html │ │ ├── home.html │ │ ├── login.html │ │ └── base.html │ ├── urls.py │ └── settings.py ├── .gitignore ├── requirements.txt ├── manage.py ├── Dockerfile └── README.md ├── oidc_provider ├── __init__.py ├── lib │ ├── __init__.py │ ├── utils │ │ ├── __init__.py │ │ ├── authorize.py │ │ ├── sanitization.py │ │ ├── oauth2.py │ │ ├── common.py │ │ └── token.py │ ├── endpoints │ │ ├── __init__.py │ │ └── introspection.py │ ├── errors.py │ └── claims.py ├── tests │ ├── __init__.py │ ├── app │ │ ├── __init__.py │ │ ├── urls.py │ │ └── utils.py │ ├── templates │ │ └── accounts │ │ │ ├── logout.html │ │ │ └── login.html │ ├── cases │ │ ├── test_commands.py │ │ ├── test_settings.py │ │ ├── test_middleware.py │ │ ├── test_provider_info_endpoint.py │ │ ├── test_claims.py │ │ ├── test_admin.py │ │ ├── test_userinfo_endpoint.py │ │ └── test_introspection_endpoint.py │ └── settings.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── creatersakey.py ├── migrations │ ├── __init__.py │ ├── 0006_unique_user_client.py │ ├── 0004_remove_userinfo.py │ ├── 0003_code_nonce.py │ ├── 0027_alter_rsakey_options.py │ ├── 0005_token_refresh_token.py │ ├── 0010_code_is_authentication.py │ ├── 0012_auto_20160405_2041.py │ ├── 0019_auto_20161005_1552.py │ ├── 0021_refresh_token_not_unique.py │ ├── 0009_auto_20160202_1945.py │ ├── 0014_client_jwt_alg.py │ ├── 0024_auto_20180327_1959.py │ ├── 0013_auto_20160407_1912.py │ ├── 0020_client__post_logout_redirect_uris.py │ ├── 0008_rsakey.py │ ├── 0011_client_client_type.py │ ├── 0023_client_owner.py │ ├── 0025_user_field_codetoken.py │ ├── 0022_auto_20170331_1626.py │ ├── 0002_userconsent.py │ ├── 0007_auto_20160111_1844.py │ ├── 0018_hybridflow_and_clientattrs.py │ ├── 0017_auto_20160811_1954.py │ ├── 0015_change_client_code.py │ ├── 0026_client_multiple_response_types.py │ └── 0001_initial.py ├── version.py ├── templates │ └── oidc_provider │ │ ├── error.html │ │ ├── end_session_completed.html │ │ ├── end_session_failed.html │ │ ├── end_session_prompt.html │ │ ├── authorize.html │ │ ├── hidden_inputs.html │ │ └── check_session_iframe.html ├── locale │ ├── es │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── fr │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── pl │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── ru │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ └── zh_Hans │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── signals.py ├── compat.py ├── apps.py ├── middleware.py ├── urls.py ├── static │ └── oidc_provider │ │ └── js │ │ └── sha256.min.js ├── admin.py └── settings.py ├── docs ├── .gitignore ├── requirements.txt ├── images │ ├── add_rsa_key.png │ └── client_creation.png ├── sections │ ├── signals.rst │ ├── userconsent.rst │ ├── serverkeys.rst │ ├── installation.rst │ ├── contribute.rst │ ├── templates.rst │ ├── tokenintrospection.rst │ ├── oauth2.rst │ ├── accesstokens.rst │ ├── examples.rst │ ├── relyingparties.rst │ ├── sessionmanagement.rst │ └── scopesclaims.rst └── index.rst ├── .github ├── FUNDING.yml └── workflows │ └── main.yml ├── pyproject.toml ├── .gitignore ├── MANIFEST.in ├── .vscode └── settings.json ├── .readthedocs.yaml ├── tox.ini ├── LICENSE ├── README.md └── setup.py /example/app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /oidc_provider/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | _build/ 2 | 3 | -------------------------------------------------------------------------------- /oidc_provider/lib/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /oidc_provider/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /oidc_provider/lib/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /oidc_provider/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /oidc_provider/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /oidc_provider/tests/app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: juanifioren 2 | -------------------------------------------------------------------------------- /oidc_provider/lib/endpoints/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | sphinx_rtd_theme -------------------------------------------------------------------------------- /oidc_provider/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /oidc_provider/version.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.9.0" 2 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | *.sqlite3 2 | *.pem 3 | static/ 4 | -------------------------------------------------------------------------------- /oidc_provider/templates/oidc_provider/error.html: -------------------------------------------------------------------------------- 1 |

{{ error }}

2 |

{{ description }}

-------------------------------------------------------------------------------- /example/requirements.txt: -------------------------------------------------------------------------------- 1 | django==4.2 2 | https://github.com/juanifioren/django-oidc-provider/archive/master.zip 3 | -------------------------------------------------------------------------------- /docs/images/add_rsa_key.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juanifioren/django-oidc-provider/HEAD/docs/images/add_rsa_key.png -------------------------------------------------------------------------------- /docs/images/client_creation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juanifioren/django-oidc-provider/HEAD/docs/images/client_creation.png -------------------------------------------------------------------------------- /oidc_provider/templates/oidc_provider/end_session_completed.html: -------------------------------------------------------------------------------- 1 |

End Session Completed

2 | 3 |

You've been logged out.

-------------------------------------------------------------------------------- /oidc_provider/templates/oidc_provider/end_session_failed.html: -------------------------------------------------------------------------------- 1 |

End Session Failed

2 | 3 |

You can now close this window.

-------------------------------------------------------------------------------- /oidc_provider/tests/templates/accounts/logout.html: -------------------------------------------------------------------------------- 1 |

Bye!

2 |

Thanks for spending some quality time with the web site today.

-------------------------------------------------------------------------------- /oidc_provider/locale/es/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juanifioren/django-oidc-provider/HEAD/oidc_provider/locale/es/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /oidc_provider/locale/fr/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juanifioren/django-oidc-provider/HEAD/oidc_provider/locale/fr/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /oidc_provider/locale/pl/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juanifioren/django-oidc-provider/HEAD/oidc_provider/locale/pl/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /oidc_provider/locale/ru/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juanifioren/django-oidc-provider/HEAD/oidc_provider/locale/ru/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /oidc_provider/locale/zh_Hans/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/juanifioren/django-oidc-provider/HEAD/oidc_provider/locale/zh_Hans/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /oidc_provider/signals.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.dispatch import Signal 3 | 4 | user_accept_consent = Signal() 5 | user_decline_consent = Signal() 6 | -------------------------------------------------------------------------------- /oidc_provider/compat.py: -------------------------------------------------------------------------------- 1 | def get_attr_or_callable(obj, name): 2 | target = getattr(obj, name) 3 | if callable(target): 4 | return target() 5 | return target 6 | -------------------------------------------------------------------------------- /example/app/wsgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.wsgi import get_wsgi_application 4 | 5 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings") 6 | 7 | application = get_wsgi_application() 8 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.ruff] 2 | line-length = 100 3 | 4 | [tool.ruff.lint] 5 | select = [ 6 | # Pyflakes 7 | "F", 8 | # isort 9 | "I", 10 | ] 11 | 12 | [tool.ruff.lint.isort] 13 | force-single-line = true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | build/ 3 | dist/ 4 | *.py[cod] 5 | *.egg-info 6 | .ropeproject 7 | .tox 8 | .coverage 9 | src/ 10 | .venv 11 | .idea 12 | docs/_build/ 13 | .eggs/ 14 | .python-version 15 | .pytest_cache/ 16 | .coverage* 17 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | recursive-include oidc_provider/static * 4 | recursive-include oidc_provider/templates * 5 | recursive-include oidc_provider/tests/templates * 6 | recursive-include oidc_provider/locale * 7 | -------------------------------------------------------------------------------- /oidc_provider/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class OIDCProviderConfig(AppConfig): 6 | name = "oidc_provider" 7 | verbose_name = _("OpenID Connect Provider") 8 | -------------------------------------------------------------------------------- /oidc_provider/tests/templates/accounts/login.html: -------------------------------------------------------------------------------- 1 |
2 | {% csrf_token %} 3 | 4 | 5 | 6 | 7 |
-------------------------------------------------------------------------------- /example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /example/app/templates/oidc_provider/error.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block content %} 4 | 5 |
6 |
7 |

{{ error }}

8 |

{{ description }}

9 |
10 |
11 | 12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /oidc_provider/templates/oidc_provider/end_session_prompt.html: -------------------------------------------------------------------------------- 1 |

End Session

2 | 3 |

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

4 | 5 |
6 | 7 | {% csrf_token %} 8 | 9 | 10 | 11 | 12 |
13 | -------------------------------------------------------------------------------- /oidc_provider/migrations/0006_unique_user_client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("oidc_provider", "0005_token_refresh_token"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterUniqueTogether( 14 | name="userconsent", 15 | unique_together=set([("user", "client")]), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[python]": { 3 | "editor.formatOnSave": true, 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll.ruff": "explicit", 6 | "source.organizeImports.ruff": "explicit" 7 | }, 8 | "editor.defaultFormatter": "charliermarsh.ruff" 9 | }, 10 | "ruff.enable": true, 11 | "ruff.nativeServer": true, 12 | "python.analysis.ignore": ["*"], 13 | "python.analysis.autoImportCompletions": false, 14 | "pylint.enabled": false 15 | } -------------------------------------------------------------------------------- /oidc_provider/migrations/0004_remove_userinfo.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("oidc_provider", "0003_code_nonce"), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name="userinfo", 15 | name="user", 16 | ), 17 | migrations.DeleteModel( 18 | name="UserInfo", 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /oidc_provider/migrations/0003_code_nonce.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations 5 | from django.db import models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("oidc_provider", "0002_userconsent"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name="code", 16 | name="nonce", 17 | field=models.CharField(default=b"", max_length=255, blank=True), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /oidc_provider/migrations/0027_alter_rsakey_options.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2 on 2024-10-03 19:10 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("oidc_provider", "0026_client_multiple_response_types"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterModelOptions( 13 | name="rsakey", 14 | options={ 15 | "ordering": ["id"], 16 | "verbose_name": "RSA Key", 17 | "verbose_name_plural": "RSA Keys", 18 | }, 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /oidc_provider/migrations/0005_token_refresh_token.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations 5 | from django.db import models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("oidc_provider", "0004_remove_userinfo"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name="token", 16 | name="refresh_token", 17 | field=models.CharField(max_length=255, unique=True, null=True), 18 | preserve_default=True, 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /oidc_provider/migrations/0010_code_is_authentication.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9 on 2016-02-16 20:32 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | from django.db import models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("oidc_provider", "0009_auto_20160202_1945"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name="code", 17 | name="is_authentication", 18 | field=models.BooleanField(default=False), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /oidc_provider/tests/cases/test_commands.py: -------------------------------------------------------------------------------- 1 | from io import StringIO 2 | 3 | from django.core.management import call_command 4 | from django.test import TestCase 5 | 6 | 7 | class CommandsTest(TestCase): 8 | def test_creatersakey_output(self): 9 | out = StringIO() 10 | call_command("creatersakey", stdout=out) 11 | self.assertIn("RSA key successfully created", out.getvalue()) 12 | 13 | def test_makemigrations_output(self): 14 | out = StringIO() 15 | call_command("makemigrations", "oidc_provider", stdout=out) 16 | self.assertIn("No changes detected in app", out.getvalue()) 17 | -------------------------------------------------------------------------------- /oidc_provider/migrations/0012_auto_20160405_2041.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9 on 2016-04-05 20:41 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | from django.db import models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("oidc_provider", "0011_client_client_type"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name="client", 17 | name="client_secret", 18 | field=models.CharField(blank=True, default=b"", max_length=255), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /oidc_provider/templates/oidc_provider/authorize.html: -------------------------------------------------------------------------------- 1 |

Request for Permission

2 | 3 |

Client {{ client.name }} would like to access this information of you ...

4 | 5 |
6 | 7 | {% csrf_token %} 8 | 9 | {{ hidden_inputs }} 10 | 11 | 16 | 17 | 18 | 19 | 20 |
21 | -------------------------------------------------------------------------------- /oidc_provider/migrations/0019_auto_20161005_1552.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.2 on 2016-10-05 15:52 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | from django.db import models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("oidc_provider", "0018_hybridflow_and_clientattrs"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name="client", 17 | name="client_secret", 18 | field=models.CharField(blank=True, max_length=255, verbose_name="Client SECRET"), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file for Sphinx projects 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | # Required 5 | version: 2 6 | 7 | # Set the OS, Python version and other tools you might need 8 | build: 9 | os: ubuntu-22.04 10 | tools: 11 | python: "3.10" 12 | 13 | # Build documentation in the "docs/" directory with Sphinx 14 | sphinx: 15 | configuration: docs/conf.py 16 | 17 | # Optionally build your docs in additional formats such as PDF and ePub 18 | formats: 19 | - pdf 20 | 21 | # Python requirements required to build your documentation 22 | python: 23 | install: 24 | - requirements: docs/requirements.txt -------------------------------------------------------------------------------- /example/app/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.auth import views as auth_views 3 | from django.urls import include 4 | from django.urls import re_path 5 | from django.views.generic import TemplateView 6 | 7 | urlpatterns = [ 8 | re_path(r"^$", TemplateView.as_view(template_name="home.html"), name="home"), 9 | re_path( 10 | r"^accounts/login/$", auth_views.LoginView.as_view(template_name="login.html"), name="login" 11 | ), # noqa 12 | re_path(r"^accounts/logout/$", auth_views.LogoutView.as_view(next_page="/"), name="logout"), 13 | re_path(r"^", include("oidc_provider.urls", namespace="oidc_provider")), 14 | re_path(r"^admin/", admin.site.urls), 15 | ] 16 | -------------------------------------------------------------------------------- /example/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-slim 2 | 3 | WORKDIR /usr/src/app 4 | 5 | # Copy requirements and install dependencies 6 | COPY requirements.txt . 7 | RUN pip install --upgrade pip && \ 8 | pip install --no-cache-dir -r requirements.txt 9 | 10 | # Copy application code 11 | COPY . . 12 | 13 | RUN [ "python", "manage.py", "migrate" ] 14 | RUN [ "python", "manage.py", "creatersakey" ] 15 | 16 | # Create superuser with admin:admin credentials 17 | ENV DJANGO_SUPERUSER_USERNAME=admin 18 | ENV DJANGO_SUPERUSER_EMAIL=admin@example.com 19 | ENV DJANGO_SUPERUSER_PASSWORD=admin 20 | RUN [ "python", "manage.py", "createsuperuser", "--noinput" ] 21 | 22 | EXPOSE 8000 23 | CMD [ "python", "manage.py", "runserver", "0.0.0.0:8000" ] 24 | -------------------------------------------------------------------------------- /docs/sections/signals.rst: -------------------------------------------------------------------------------- 1 | .. _signals: 2 | 3 | Signals 4 | ####### 5 | 6 | Use signals in your application to get notified when some actions occur. 7 | 8 | For example:: 9 | 10 | from django.dispatch import receiver 11 | 12 | from oidc_provider.signals import user_decline_consent 13 | 14 | 15 | @receiver(user_decline_consent) 16 | def my_callback(sender, **kwargs): 17 | print(kwargs) 18 | print('Ups! Some user has declined the consent.') 19 | 20 | user_accept_consent 21 | =================== 22 | 23 | Sent when a user accept the authorization page for some client. 24 | 25 | user_decline_consent 26 | ==================== 27 | 28 | Sent when a user decline the authorization page for some client. 29 | -------------------------------------------------------------------------------- /oidc_provider/migrations/0021_refresh_token_not_unique.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10 on 2016-12-12 19:44 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | from django.db import models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("oidc_provider", "0020_client__post_logout_redirect_uris"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name="token", 17 | name="refresh_token", 18 | field=models.CharField( 19 | default="", max_length=255, unique=True, verbose_name="Refresh Token" 20 | ), 21 | preserve_default=False, 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /oidc_provider/migrations/0009_auto_20160202_1945.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9 on 2016-02-02 19:45 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | from django.db import models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("oidc_provider", "0008_rsakey"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterModelOptions( 16 | name="rsakey", 17 | options={"verbose_name": "RSA Key", "verbose_name_plural": "RSA Keys"}, 18 | ), 19 | migrations.AlterField( 20 | model_name="rsakey", 21 | name="key", 22 | field=models.TextField(help_text="Paste your private RSA Key here."), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /oidc_provider/templates/oidc_provider/hidden_inputs.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {% if params.nonce %}{% endif %} 7 | {% if params.code_challenge %}{% endif %} 8 | {% if params.code_challenge_method %}{% endif %} 9 | -------------------------------------------------------------------------------- /oidc_provider/migrations/0014_client_jwt_alg.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9 on 2016-04-25 18:02 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | from django.db import models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("oidc_provider", "0013_auto_20160407_1912"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name="client", 17 | name="jwt_alg", 18 | field=models.CharField( 19 | choices=[(b"HS256", b"HS256"), (b"RS256", b"RS256")], 20 | default=b"RS256", 21 | max_length=10, 22 | verbose_name="JWT Algorithm", 23 | ), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /oidc_provider/migrations/0024_auto_20180327_1959.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.3 on 2018-03-27 19:59 2 | 3 | from django.db import migrations 4 | from django.db import models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("oidc_provider", "0023_client_owner"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="client", 15 | name="reuse_consent", 16 | field=models.BooleanField( 17 | default=True, 18 | 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.", 19 | verbose_name="Reuse Consent?", 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /oidc_provider/migrations/0013_auto_20160407_1912.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9 on 2016-04-07 19:12 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | from django.db import models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("oidc_provider", "0012_auto_20160405_2041"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name="code", 17 | name="code_challenge", 18 | field=models.CharField(max_length=255, null=True), 19 | ), 20 | migrations.AddField( 21 | model_name="code", 22 | name="code_challenge_method", 23 | field=models.CharField(max_length=255, null=True), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /example/app/templates/home.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n static %} 3 | 4 | {% block content %} 5 | 6 |
7 |
8 |

{% trans 'Welcome' %}{% if user.is_authenticated %} {{ user.username }}{% endif %}!

9 |

{% trans 'This is an example of an OpenID Connect 1.0 Provider. Built with the Django Framework and django-oidc-provider package.' %}

10 |

{% trans 'Create your clients' %}

11 |
12 |
13 | 14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Example Project 2 | 3 | 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. 4 | 5 | ## Setup & running using Docker 6 | 7 | Build and run the container. 8 | 9 | ```bash 10 | $ docker build -t django-oidc-provider . 11 | $ docker run -p 8000:8000 --name django-oidc-provider-app django-oidc-provider 12 | ``` 13 | 14 | Go to http://localhost:8000/ and create your Client. 15 | 16 | ## Install package for development 17 | 18 | After you run `pip install -r requirements.txt`. 19 | ```bash 20 | # Remove pypi package. 21 | $ pip uninstall django-oidc-provider 22 | 23 | # Go back to django-oidc-provider/ folder and add the package on editable mode. 24 | $ cd .. 25 | $ pip install -e . 26 | ``` 27 | -------------------------------------------------------------------------------- /oidc_provider/migrations/0020_client__post_logout_redirect_uris.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.7 on 2016-11-01 14:59 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | from django.db import models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("oidc_provider", "0019_auto_20161005_1552"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name="client", 17 | name="_post_logout_redirect_uris", 18 | field=models.TextField( 19 | blank=True, 20 | default="", 21 | help_text="Enter each URI on a new line.", 22 | verbose_name="Post Logout Redirect URIs", 23 | ), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /oidc_provider/migrations/0008_rsakey.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9 on 2016-01-25 17:48 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | from django.db import models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("oidc_provider", "0007_auto_20160111_1844"), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="RSAKey", 17 | fields=[ 18 | ( 19 | "id", 20 | models.AutoField( 21 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 22 | ), 23 | ), 24 | ("key", models.TextField()), 25 | ], 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /docs/sections/userconsent.rst: -------------------------------------------------------------------------------- 1 | .. _userconsent: 2 | 3 | User Consent 4 | ############ 5 | 6 | The package store some information after the user grant access to some client. For example, you can use the ``UserConsent`` model to list applications that the user have authorized access. Like Google does `here `_. 7 | 8 | >>> from oidc_provider.models import UserConsent 9 | >>> UserConsent.objects.filter(user__email='some@email.com') 10 | [] 11 | 12 | Note: the ``UserConsent`` model is not included in the admin. 13 | 14 | 15 | Properties 16 | ========== 17 | 18 | * ``user``: Django user object. 19 | * ``client``: Relying Party object. 20 | * ``expires_at``: Expiration date of the consent. 21 | * ``scope``: Scopes authorized. 22 | * ``date_given``: Date of the authorization. 23 | -------------------------------------------------------------------------------- /oidc_provider/lib/utils/authorize.py: -------------------------------------------------------------------------------- 1 | try: 2 | from urllib import urlencode 3 | 4 | from urlparse import parse_qs 5 | from urlparse import urlsplit 6 | from urlparse import urlunsplit 7 | except ImportError: 8 | from urllib.parse import parse_qs 9 | from urllib.parse import urlencode 10 | from urllib.parse import urlsplit 11 | from urllib.parse import urlunsplit 12 | 13 | 14 | def strip_prompt_login(path): 15 | """ 16 | Strips 'login' from the 'prompt' query parameter. 17 | """ 18 | uri = urlsplit(path) 19 | query_params = parse_qs(uri.query) 20 | prompt_list = query_params.get("prompt", "")[0].split() 21 | if "login" in prompt_list: 22 | prompt_list.remove("login") 23 | query_params["prompt"] = " ".join(prompt_list) 24 | if not query_params["prompt"]: 25 | del query_params["prompt"] 26 | uri = uri._replace(query=urlencode(query_params, doseq=True)) 27 | return urlunsplit(uri) 28 | -------------------------------------------------------------------------------- /oidc_provider/middleware.py: -------------------------------------------------------------------------------- 1 | try: 2 | # https://docs.djangoproject.com/en/1.10/topics/http/middleware/#upgrading-pre-django-1-10-style-middleware 3 | from django.utils.deprecation import MiddlewareMixin 4 | except ImportError: 5 | MiddlewareMixin = object 6 | 7 | from oidc_provider import settings 8 | from oidc_provider.lib.utils.common import get_browser_state_or_default 9 | 10 | 11 | class SessionManagementMiddleware(MiddlewareMixin): 12 | """ 13 | Maintain a `op_browser_state` cookie along with the `sessionid` cookie that 14 | represents the End-User's login state at the OP. If the user is not logged 15 | in then use the value of settings.OIDC_UNAUTHENTICATED_SESSION_MANAGEMENT_KEY. 16 | """ 17 | 18 | def process_response(self, request, response): 19 | if settings.get("OIDC_SESSION_MANAGEMENT_ENABLE"): 20 | response.set_cookie("op_browser_state", get_browser_state_or_default(request)) 21 | return response 22 | -------------------------------------------------------------------------------- /oidc_provider/migrations/0011_client_client_type.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9 on 2016-04-04 19:56 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | from django.db import models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("oidc_provider", "0010_code_is_authentication"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name="client", 17 | name="client_type", 18 | field=models.CharField( 19 | choices=[(b"confidential", b"Confidential"), (b"public", b"Public")], 20 | default=b"confidential", 21 | help_text="Confidential clients are capable of maintaining the confidentiality of their " 22 | "credentials. Public clients are incapable.", 23 | max_length=30, 24 | ), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /oidc_provider/tests/app/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import views as auth_views 2 | 3 | try: 4 | from django.urls import include 5 | from django.urls import re_path 6 | except ImportError: 7 | from django.conf.urls import include 8 | from django.conf.urls import url as re_path 9 | from django.contrib import admin 10 | from django.views.generic import TemplateView 11 | 12 | urlpatterns = [ 13 | re_path(r"^$", TemplateView.as_view(template_name="home.html"), name="home"), 14 | re_path( 15 | r"^accounts/login/$", 16 | auth_views.LoginView.as_view(template_name="accounts/login.html"), 17 | name="login", 18 | ), 19 | re_path( 20 | r"^accounts/logout/$", 21 | auth_views.LogoutView.as_view(template_name="accounts/logout.html"), 22 | name="logout", 23 | ), 24 | re_path(r"^openid/", include("oidc_provider.urls", namespace="oidc_provider")), 25 | re_path(r"^admin/", admin.site.urls), 26 | ] 27 | -------------------------------------------------------------------------------- /oidc_provider/lib/utils/sanitization.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | def sanitize_client_id(client_id): 5 | """ 6 | Sanitize client_id according to OAuth 2.0 RFC 6749 specification. 7 | 8 | Removes control characters that can cause database errors while preserving 9 | all valid visible ASCII characters (VCHAR: 0x21-0x7E) as defined by the 10 | OAuth 2.0 specification. 11 | 12 | Args: 13 | client_id (str): The client_id parameter from the request 14 | 15 | Returns: 16 | str: Sanitized client_id with control characters removed 17 | 18 | Examples: 19 | >>> sanitize_client_id("Hello\\x00World") 20 | 'HelloWorld' 21 | >>> sanitize_client_id("valid-client-123") 22 | 'valid-client-123' 23 | >>> sanitize_client_id("") 24 | '' 25 | >>> sanitize_client_id(None) 26 | '' 27 | """ 28 | if not client_id: 29 | return "" 30 | 31 | return re.sub(r"[^\x21-\x7E]", "", client_id) 32 | -------------------------------------------------------------------------------- /oidc_provider/migrations/0023_client_owner.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11 on 2017-11-08 21:43 3 | from __future__ import unicode_literals 4 | 5 | import django.db.models.deletion 6 | from django.conf import settings 7 | from django.db import migrations 8 | from django.db import models 9 | 10 | 11 | class Migration(migrations.Migration): 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ("oidc_provider", "0022_auto_20170331_1626"), 15 | ] 16 | 17 | operations = [ 18 | migrations.AddField( 19 | model_name="client", 20 | name="owner", 21 | field=models.ForeignKey( 22 | blank=True, 23 | default=None, 24 | null=True, 25 | on_delete=django.db.models.deletion.SET_NULL, 26 | related_name="oidc_clients_set", 27 | to=settings.AUTH_USER_MODEL, 28 | verbose_name="Owner", 29 | ), 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /docs/sections/serverkeys.rst: -------------------------------------------------------------------------------- 1 | .. _serverkeys: 2 | 3 | Server Keys 4 | ########### 5 | 6 | Server RSA keys are used to sign/encrypt ID Tokens. These keys are stored in the ``RSAKey`` model. So the package will automatically generate public keys and expose them in the ``jwks_uri`` endpoint. 7 | 8 | You can easily create them with the admin: 9 | 10 | .. image:: ../images/add_rsa_key.png 11 | :align: center 12 | 13 | Or by using ``python manage.py creatersakey`` command. 14 | 15 | Here is an example response from the ``jwks_uri`` endpoint:: 16 | 17 | GET /openid/jwks HTTP/1.1 18 | Host: localhost:8000 19 | 20 | { 21 | "keys":[ 22 | { 23 | "use":"sig", 24 | "e":"AQAB", 25 | "kty":"RSA", 26 | "alg":"RS256", 27 | "n":"3Gm0pS7ij_SnY96wkbaki74MUYJrobXecO6xJhvmAEEhMHGpO0m4H2nbOWTf6Jc1FiiSvgvhObVk9xPOM6qMTQ5D5pfWZjNk99qDJXvAE4ImM8S0kCaBJGT6e8JbuDllCUq8aL71t67DhzbnoBsKCnVOE1GJffpMcDdBUYkAsx8", 28 | "kid":"a38ea7fbf944cc060eaf5acc1956b0e3" 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /example/app/templates/oidc_provider/authorize.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load i18n static %} 3 | 4 | {% block content %} 5 | 6 |
7 |
8 |

{% trans 'Request for Permission' %}

9 |

Client {{ client.name }} would like to access this information of you.

10 |
11 | {% csrf_token %} 12 | {{ hidden_inputs }} 13 |
    14 | {% for scope in scopes %} 15 |
  • {{ scope.name }}
    {{ scope.description }}
  • 16 | {% endfor %} 17 |
18 |
19 | 20 | 21 |
22 |
23 |
24 | 25 | {% endblock %} -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist= 3 | docs, 4 | py38-django{32,42}, 5 | py39-django{32,42}, 6 | py310-django{32,42,52}, 7 | py311-django{42,52}, 8 | py312-django{42,52}, 9 | py313-django{42,52}, 10 | ruff 11 | 12 | [testenv] 13 | changedir= 14 | oidc_provider 15 | deps = 16 | django32: django>=3.2,<3.3 17 | django42: django>=4.2,<4.3 18 | django52: django>=5.2,<5.3 19 | freezegun 20 | psycopg2-binary 21 | pytest 22 | pytest-django 23 | pytest-cov 24 | 25 | commands = 26 | pytest --cov=oidc_provider {posargs} 27 | 28 | [testenv:docs] 29 | basepython = python3.11 30 | changedir = docs 31 | allowlist_externals = 32 | mkdir 33 | deps = 34 | sphinx 35 | sphinx_rtd_theme 36 | commands = 37 | mkdir -p _static/ 38 | sphinx-build -v -W -b html -d {envtmpdir}/doctrees -D html_static_path="_static" . {envtmpdir}/html 39 | 40 | [testenv:ruff] 41 | basepython = python3.11 42 | deps = 43 | ruff 44 | commands = 45 | ruff check --diff 46 | ruff format --check --diff 47 | 48 | [pytest] 49 | DJANGO_SETTINGS_MODULE = oidc_provider.tests.settings 50 | python_files = test_*.py 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014-2024 Juan Ignacio Fiorentino 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /oidc_provider/migrations/0025_user_field_codetoken.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.3 on 2018-04-13 19:34 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations 6 | from django.db import models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("oidc_provider", "0024_auto_20180327_1959"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name="client", 17 | name="_scope", 18 | field=models.TextField( 19 | blank=True, 20 | default="", 21 | help_text="Specifies the authorized scope values for the client app.", 22 | verbose_name="Scopes", 23 | ), 24 | ), 25 | migrations.AlterField( 26 | model_name="token", 27 | name="user", 28 | field=models.ForeignKey( 29 | null=True, 30 | on_delete=django.db.models.deletion.CASCADE, 31 | to=settings.AUTH_USER_MODEL, 32 | verbose_name="User", 33 | ), 34 | ), 35 | ] 36 | -------------------------------------------------------------------------------- /docs/sections/installation.rst: -------------------------------------------------------------------------------- 1 | .. _installation: 2 | 3 | Installation 4 | ############ 5 | 6 | Requirements 7 | ============ 8 | 9 | * Python: ``3.8`` ``3.9`` ``3.10`` ``3.11`` ``3.12`` 10 | * Django: ``3.2`` ``4.2`` ``5.1`` 11 | 12 | Quick Installation 13 | ================== 14 | 15 | If you want to get started fast see our ``/example`` folder in your local installation. Or look at it `on github `_. 16 | 17 | Install the package using pip:: 18 | 19 | $ pip install django-oidc-provider 20 | 21 | Add it to your apps in your project's django settings:: 22 | 23 | INSTALLED_APPS = [ 24 | # ... 25 | 'oidc_provider', 26 | # ... 27 | ] 28 | 29 | Include our urls to your project's ``urls.py``:: 30 | 31 | urlpatterns = [ 32 | # ... 33 | path('openid/', include('oidc_provider.urls', namespace='oidc_provider')), 34 | # ... 35 | ] 36 | 37 | Run the migrations and generate a server RSA key:: 38 | 39 | $ python manage.py migrate 40 | $ python manage.py creatersakey 41 | 42 | Add this required variable to your project's django settings:: 43 | 44 | LOGIN_URL = '/accounts/login/' 45 | -------------------------------------------------------------------------------- /oidc_provider/tests/cases/test_settings.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.test import override_settings 3 | 4 | from oidc_provider import settings 5 | 6 | CUSTOM_TEMPLATES = {"authorize": "custom/authorize.html", "error": "custom/error.html"} 7 | 8 | 9 | class SettingsTest(TestCase): 10 | @override_settings(OIDC_TEMPLATES=CUSTOM_TEMPLATES) 11 | def test_override_templates(self): 12 | self.assertEqual(settings.get("OIDC_TEMPLATES"), CUSTOM_TEMPLATES) 13 | 14 | def test_unauthenticated_session_management_key_has_default(self): 15 | key = settings.get("OIDC_UNAUTHENTICATED_SESSION_MANAGEMENT_KEY") 16 | self.assertRegex(key, r"[a-zA-Z0-9]+") 17 | self.assertGreater(len(key), 50) 18 | 19 | def test_unauthenticated_session_management_key_has_constant_value(self): 20 | key1 = settings.get("OIDC_UNAUTHENTICATED_SESSION_MANAGEMENT_KEY") 21 | key2 = settings.get("OIDC_UNAUTHENTICATED_SESSION_MANAGEMENT_KEY") 22 | self.assertEqual(key1, key2) 23 | 24 | @override_settings(OIDC_INTROSPECTION_VALIDATE_AUDIENCE_SCOPE=False) 25 | def test_can_override_with_false_value(self): 26 | self.assertFalse(settings.get("OIDC_INTROSPECTION_VALIDATE_AUDIENCE_SCOPE")) 27 | -------------------------------------------------------------------------------- /example/app/templates/login.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load i18n %} 3 | 4 | {% block content %} 5 | 6 |
7 |
8 |
9 | {% csrf_token %} 10 | 11 | {% if form.errors %} 12 | 15 | {% endif %} 16 |
17 | 18 |
19 |
20 | 21 |
22 |
23 | 24 |
25 |
26 |
27 |
28 | 29 | {% endblock %} 30 | -------------------------------------------------------------------------------- /oidc_provider/migrations/0022_auto_20170331_1626.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.6 on 2017-03-31 16:26 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | from django.db import models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("oidc_provider", "0021_refresh_token_not_unique"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name="client", 17 | name="require_consent", 18 | field=models.BooleanField( 19 | default=True, 20 | help_text="If disabled, the Server will NEVER ask the user for consent.", 21 | verbose_name="Require Consent?", 22 | ), 23 | ), 24 | migrations.AddField( 25 | model_name="client", 26 | name="reuse_consent", 27 | field=models.BooleanField( 28 | default=True, 29 | help_text="If enabled, the Server will save the user consent given to a specific client," 30 | " so that user won't be prompted for the same authorization multiple times.", 31 | verbose_name="Reuse Consent?", 32 | ), 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /oidc_provider/management/commands/creatersakey.py: -------------------------------------------------------------------------------- 1 | from cryptography.hazmat.primitives import serialization 2 | from cryptography.hazmat.primitives.asymmetric import rsa 3 | from django.core.management.base import BaseCommand 4 | 5 | from oidc_provider.models import RSAKey 6 | 7 | 8 | class Command(BaseCommand): 9 | help = "Randomly generate a new RSA key for the OpenID server" 10 | 11 | def handle(self, *args, **options): 12 | try: 13 | # Generate a new RSA private key with 2048 bits 14 | private_key = rsa.generate_private_key( 15 | public_exponent=65537, 16 | key_size=2048, 17 | ) 18 | 19 | # Serialize the private key to PEM format 20 | key_pem = private_key.private_bytes( 21 | encoding=serialization.Encoding.PEM, 22 | format=serialization.PrivateFormat.PKCS8, 23 | encryption_algorithm=serialization.NoEncryption(), 24 | ).decode("utf-8") 25 | 26 | rsakey = RSAKey(key=key_pem) 27 | rsakey.save() 28 | self.stdout.write("RSA key successfully created with kid: {0}".format(rsakey.kid)) 29 | except Exception as e: 30 | self.stdout.write("Something goes wrong: {0}".format(e)) 31 | -------------------------------------------------------------------------------- /oidc_provider/migrations/0002_userconsent.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.conf import settings 5 | from django.db import migrations 6 | from django.db import models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ("oidc_provider", "0001_initial"), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="UserConsent", 18 | fields=[ 19 | ( 20 | "id", 21 | models.AutoField( 22 | verbose_name="ID", serialize=False, auto_created=True, primary_key=True 23 | ), 24 | ), 25 | ("expires_at", models.DateTimeField()), 26 | ("_scope", models.TextField(default=b"")), 27 | ("client", models.ForeignKey(to="oidc_provider.Client", on_delete=models.CASCADE)), 28 | ("user", models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), 29 | ], 30 | options={ 31 | "abstract": False, 32 | }, 33 | bases=(models.Model,), 34 | ), 35 | ] 36 | -------------------------------------------------------------------------------- /oidc_provider/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import re_path 2 | from django.views.decorators.csrf import csrf_exempt 3 | 4 | from oidc_provider import settings 5 | from oidc_provider import views 6 | 7 | app_name = "oidc_provider" 8 | urlpatterns = [ 9 | re_path(r"^authorize/?$", views.AuthorizeView.as_view(), name="authorize"), 10 | re_path(r"^token/?$", csrf_exempt(views.TokenView.as_view()), name="token"), 11 | re_path(r"^userinfo/?$", csrf_exempt(views.userinfo), name="userinfo"), 12 | re_path(r"^end-session/?$", views.EndSessionView.as_view(), name="end-session"), 13 | re_path( 14 | r"^end-session-prompt/?$", views.EndSessionPromptView.as_view(), name="end-session-prompt" 15 | ), 16 | re_path( 17 | r"^\.well-known/openid-configuration/?$", 18 | views.ProviderInfoView.as_view(), 19 | name="provider-info", 20 | ), 21 | re_path(r"^introspect/?$", views.TokenIntrospectionView.as_view(), name="token-introspection"), 22 | re_path(r"^jwks/?$", views.JwksView.as_view(), name="jwks"), 23 | ] 24 | 25 | if settings.get("OIDC_SESSION_MANAGEMENT_ENABLE"): 26 | urlpatterns += [ 27 | re_path( 28 | r"^check-session-iframe/?$", 29 | views.CheckSessionIframeView.as_view(), 30 | name="check-session-iframe", 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /docs/sections/contribute.rst: -------------------------------------------------------------------------------- 1 | .. _contribute: 2 | 3 | Contribute 4 | ########## 5 | 6 | We love contributions, so please feel free to fix bugs, improve things, provide documentation. These are the steps: 7 | 8 | * Create an issue and explain your feature/bugfix. 9 | * Wait collaborators comments. 10 | * Fork the project and create new branch from ``develop``. 11 | * Make your feature addition or bug fix. 12 | * Add tests and documentation if needed. 13 | * Create pull request for the issue to the ``develop`` branch. 14 | * Wait collaborators reviews. 15 | 16 | Running Tests 17 | ============= 18 | 19 | Use `tox `_ for running tests in each of the environments, also to run coverage and flake8 among:: 20 | 21 | # Run all tests. 22 | $ tox 23 | 24 | # Run with Python 3.11 and Django 4.2. 25 | $ tox -e py311-django42 26 | 27 | # Run a single test method. 28 | $ tox -e py311-django42 -- tests/cases/test_authorize_endpoint.py::TestClass::test_some_method 29 | 30 | We use `Github Actions `_ to automatically test every commit to the project. 31 | 32 | Improve Documentation 33 | ===================== 34 | 35 | We use `Sphinx `_ to generate this documentation. If you want to add or modify something just: 36 | 37 | * Install Sphinx and the auto-build tool (``pip install sphinx sphinx_rtd_theme sphinx-autobuild``). 38 | * Move inside the docs folder. ``cd docs/`` 39 | * Generate and watch docs by running ``sphinx-autobuild . _build/``. 40 | * Open ``http://127.0.0.1:8000`` in a browser. 41 | -------------------------------------------------------------------------------- /oidc_provider/tests/cases/test_middleware.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from django.test import TestCase 4 | from django.test import override_settings 5 | from django.urls import re_path 6 | from django.views.generic import View 7 | 8 | 9 | class StubbedViews: 10 | class SampleView(View): 11 | pass 12 | 13 | urlpatterns = [re_path("^test/", SampleView.as_view())] 14 | 15 | 16 | MW_CLASSES = ( 17 | "django.contrib.sessions.middleware.SessionMiddleware", 18 | "oidc_provider.middleware.SessionManagementMiddleware", 19 | ) 20 | 21 | 22 | @override_settings( 23 | ROOT_URLCONF=StubbedViews, 24 | MIDDLEWARE=MW_CLASSES, 25 | MIDDLEWARE_CLASSES=MW_CLASSES, 26 | OIDC_SESSION_MANAGEMENT_ENABLE=True, 27 | ) 28 | class MiddlewareTestCase(TestCase): 29 | def setUp(self): 30 | patcher = patch("oidc_provider.middleware.get_browser_state_or_default") 31 | self.mock_get_state = patcher.start() 32 | 33 | def test_session_management_middleware_sets_cookie_on_response(self): 34 | response = self.client.get("/test/") 35 | 36 | self.assertIn("op_browser_state", response.cookies) 37 | self.assertEqual( 38 | response.cookies["op_browser_state"].value, str(self.mock_get_state.return_value) 39 | ) 40 | self.mock_get_state.assert_called_once_with(response.wsgi_request) 41 | 42 | @override_settings(OIDC_SESSION_MANAGEMENT_ENABLE=False) 43 | def test_session_management_middleware_does_not_set_cookie_if_session_management_disabled(self): 44 | response = self.client.get("/test/") 45 | 46 | self.assertNotIn("op_browser_state", response.cookies) 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django OpenID Connect Provider 2 | 3 | [![Python Versions](https://img.shields.io/pypi/pyversions/django-oidc-provider.svg)](https://pypi.python.org/pypi/django-oidc-provider) 4 | [![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) 5 | [![PyPI Versions](https://img.shields.io/pypi/v/django-oidc-provider.svg)](https://pypi.python.org/pypi/django-oidc-provider) 6 | [![Documentation Status](https://readthedocs.org/projects/django-oidc-provider/badge/?version=master)](http://django-oidc-provider.readthedocs.io/) 7 | 8 | ## About OpenID 9 | 10 | OpenID Connect is a simple identity layer on top of the OAuth 2.0 protocol, which allows computing clients to verify the identity of an end-user based on the authentication performed by an authorization server, as well as to obtain basic profile information about the end-user in an interoperable and REST-like manner. Like [Google](https://developers.google.com/identity/protocols/OpenIDConnect) for example. 11 | 12 | ## About the package 13 | 14 | `django-oidc-provider` can help you providing out of the box all the endpoints, data and logic needed to add OpenID Connect (and OAuth2) capabilities to your Django projects. 15 | 16 | Support for Python 3 and latest versions of django. 17 | 18 | [Read documentation for more info.](http://django-oidc-provider.readthedocs.org/) 19 | 20 | [Do you want to contribute? Please read this.](http://django-oidc-provider.readthedocs.io/en/master/sections/contribute.html) 21 | 22 | ## Thanks to our sponsors 23 | 24 | [![Agilentia](https://avatars.githubusercontent.com/u/1707212?s=60&v=4)](https://github.com/agilentia) 25 | -------------------------------------------------------------------------------- /oidc_provider/migrations/0007_auto_20160111_1844.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9 on 2016-01-11 18:44 3 | from __future__ import unicode_literals 4 | 5 | import datetime 6 | 7 | from django.db import migrations 8 | from django.db import models 9 | 10 | 11 | class Migration(migrations.Migration): 12 | dependencies = [ 13 | ("oidc_provider", "0006_unique_user_client"), 14 | ] 15 | 16 | operations = [ 17 | migrations.AlterModelOptions( 18 | name="client", 19 | options={"verbose_name": "Client", "verbose_name_plural": "Clients"}, 20 | ), 21 | migrations.AlterModelOptions( 22 | name="code", 23 | options={ 24 | "verbose_name": "Authorization Code", 25 | "verbose_name_plural": "Authorization Codes", 26 | }, 27 | ), 28 | migrations.AlterModelOptions( 29 | name="token", 30 | options={"verbose_name": "Token", "verbose_name_plural": "Tokens"}, 31 | ), 32 | migrations.AddField( 33 | model_name="client", 34 | name="date_created", 35 | field=models.DateField( 36 | auto_now_add=True, 37 | default=datetime.datetime( 38 | 2016, 1, 11, 18, 44, 32, 192477, tzinfo=datetime.timezone.utc 39 | ), 40 | ), 41 | preserve_default=False, 42 | ), 43 | migrations.AlterField( 44 | model_name="client", 45 | name="_redirect_uris", 46 | field=models.TextField( 47 | default=b"", help_text="Enter each URI on a new line.", verbose_name="Redirect URI" 48 | ), 49 | ), 50 | ] 51 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from setuptools import find_packages 4 | from setuptools import setup 5 | 6 | version = {} 7 | with open("./oidc_provider/version.py") as fp: 8 | exec(fp.read(), version) 9 | 10 | # allow setup.py to be run from any path 11 | os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) 12 | 13 | setup( 14 | name="django-oidc-provider", 15 | version=version["__version__"], 16 | packages=find_packages(), 17 | include_package_data=True, 18 | license="MIT License", 19 | description="OpenID Connect Provider implementation for Django.", 20 | long_description="http://github.com/juanifioren/django-oidc-provider", 21 | url="http://github.com/juanifioren/django-oidc-provider", 22 | author="Juan Ignacio Fiorentino", 23 | author_email="juanifioren@gmail.com", 24 | zip_safe=False, 25 | classifiers=[ 26 | "Development Status :: 5 - Production/Stable", 27 | "Environment :: Web Environment", 28 | "Framework :: Django", 29 | "Intended Audience :: Developers", 30 | "License :: OSI Approved :: MIT License", 31 | "Operating System :: OS Independent", 32 | "Programming Language :: Python", 33 | "Programming Language :: Python :: 3", 34 | "Programming Language :: Python :: 3.8", 35 | "Programming Language :: Python :: 3.9", 36 | "Programming Language :: Python :: 3.10", 37 | "Programming Language :: Python :: 3.11", 38 | "Programming Language :: Python :: 3.12", 39 | "Programming Language :: Python :: 3.13", 40 | "Topic :: Internet :: WWW/HTTP", 41 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 42 | ], 43 | test_suite="runtests.runtests", 44 | tests_require=[ 45 | "PyJWT>=2.8.0", 46 | "cryptography>=3.4.0", 47 | ], 48 | install_requires=[ 49 | "PyJWT>=2.8.0", 50 | "cryptography>=3.4.0", 51 | ], 52 | ) 53 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: "CI" 2 | on: 3 | push: 4 | branches: ["master", "develop"] 5 | pull_request: 6 | 7 | concurrency: 8 | group: check-${{ github.ref }} 9 | cancel-in-progress: true 10 | 11 | jobs: 12 | formatting: 13 | name: "Check Code Formatting" 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: astral-sh/ruff-action@v3 18 | with: 19 | args: "--version" 20 | - run: "ruff format --check --diff" 21 | 22 | linting: 23 | name: "Check Code Linting" 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v4 27 | - uses: astral-sh/ruff-action@v3 28 | with: 29 | args: "--version" 30 | - run: "ruff check --diff" 31 | 32 | test_matrix_prep: 33 | name: "Prepare Test Matrix" 34 | runs-on: ubuntu-latest 35 | outputs: 36 | matrix: "${{ steps.set-matrix.outputs.matrix }}" 37 | steps: 38 | - uses: actions/checkout@v4 39 | - uses: astral-sh/setup-uv@v3 40 | - run: uv tool install tox 41 | - id: set-matrix 42 | run: | 43 | 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: .}') 44 | echo "matrix=$matrix" >> $GITHUB_OUTPUT 45 | 46 | test: 47 | name: "Test Django ${{ matrix.django }} | Python ${{ matrix.python }}" 48 | needs: test_matrix_prep 49 | runs-on: ubuntu-latest 50 | strategy: 51 | fail-fast: false 52 | matrix: ${{ fromJson(needs.test_matrix_prep.outputs.matrix) }} 53 | steps: 54 | - uses: actions/checkout@v4 55 | - uses: astral-sh/setup-uv@v3 56 | - run: uv tool install tox 57 | - uses: actions/setup-python@v4 58 | with: 59 | python-version: ${{ matrix.python }} 60 | - name: Run tox 61 | run: tox run --skip-missing-interpreters=false -e py$(echo "${{ matrix.python }}" | tr -d '.')-django$(echo "${{ matrix.django }}" | tr -d '.') 62 | -------------------------------------------------------------------------------- /oidc_provider/templates/oidc_provider/check_session_iframe.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | OP Iframe 7 | 8 | 49 | 50 | 51 | OpenID Connect Session Management OP Iframe. 52 | 53 | 54 | -------------------------------------------------------------------------------- /oidc_provider/tests/settings.py: -------------------------------------------------------------------------------- 1 | DEBUG = False 2 | 3 | SECRET_KEY = "this-should-be-top-secret" 4 | 5 | DATABASES = { 6 | "default": { 7 | "ENGINE": "django.db.backends.sqlite3", 8 | "NAME": ":memory:", 9 | } 10 | } 11 | 12 | SITE_ID = 1 13 | 14 | MIDDLEWARE_CLASSES = [ 15 | "django.middleware.common.CommonMiddleware", 16 | "django.contrib.sessions.middleware.SessionMiddleware", 17 | "django.contrib.auth.middleware.AuthenticationMiddleware", 18 | ] 19 | 20 | MIDDLEWARE = [ 21 | "django.middleware.common.CommonMiddleware", 22 | "django.contrib.sessions.middleware.SessionMiddleware", 23 | "django.contrib.auth.middleware.AuthenticationMiddleware", 24 | ] 25 | 26 | TEMPLATES = [ 27 | { 28 | "BACKEND": "django.template.backends.django.DjangoTemplates", 29 | "DIRS": [], 30 | "APP_DIRS": True, 31 | "OPTIONS": { 32 | "context_processors": [ 33 | "django.template.context_processors.debug", 34 | "django.template.context_processors.request", 35 | "django.contrib.auth.context_processors.auth", 36 | "django.contrib.messages.context_processors.messages", 37 | ], 38 | }, 39 | }, 40 | ] 41 | 42 | INSTALLED_APPS = [ 43 | "django.contrib.auth", 44 | "django.contrib.contenttypes", 45 | "django.contrib.sessions", 46 | "django.contrib.sites", 47 | "django.contrib.messages", 48 | "django.contrib.admin", 49 | "oidc_provider", 50 | ] 51 | 52 | ROOT_URLCONF = "oidc_provider.tests.app.urls" 53 | 54 | TEMPLATE_DIRS = [ 55 | "oidc_provider/tests/templates", 56 | ] 57 | 58 | USE_TZ = True 59 | 60 | LOGGING = { 61 | "version": 1, 62 | "disable_existing_loggers": False, 63 | "handlers": { 64 | "console": { 65 | "class": "logging.StreamHandler", 66 | }, 67 | }, 68 | "loggers": { 69 | "oidc_provider": { 70 | "handlers": ["console"], 71 | "level": "DEBUG", 72 | }, 73 | }, 74 | } 75 | 76 | # OIDC Provider settings. 77 | 78 | SITE_URL = "http://localhost:8000" 79 | OIDC_USERINFO = "oidc_provider.tests.app.utils.userinfo" 80 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to Django OIDC Provider Documentation! 2 | ============================================== 3 | 4 | This tiny (but powerful!) package can help you to provide out of the box all the endpoints, data and logic needed to add OpenID Connect capabilities to your Django projects. And as a side effect a fair implementation of OAuth2.0 too. Covers Authorization Code, Implicit and Hybrid flows. 5 | 6 | Also implements the following specifications: 7 | 8 | * `OpenID Connect Discovery 1.0 `_ 9 | * `OpenID Connect Session Management 1.0 `_ 10 | * `OAuth 2.0 for Native Apps `_ 11 | * `OAuth 2.0 Resource Owner Password Credentials Grant `_ 12 | * `Proof Key for Code Exchange by OAuth Public Clients `_ 13 | 14 | -------------------------------------------------------------------------------- 15 | 16 | Before getting started there are some important things that you should know: 17 | 18 | * Despite that implementation MUST support TLS, you *can* make request without using SSL. There is no control on that. 19 | * Supports only requesting Claims using Scope values, so you cannot request individual Claims. 20 | * If you enable the Resource Owner Password Credentials Grant, you MUST implement protection against brute force attacks on the token endpoint 21 | 22 | -------------------------------------------------------------------------------- 23 | 24 | Contents: 25 | 26 | .. toctree:: 27 | :maxdepth: 2 28 | 29 | sections/installation 30 | sections/relyingparties 31 | sections/serverkeys 32 | sections/templates 33 | sections/scopesclaims 34 | sections/userconsent 35 | sections/oauth2 36 | sections/accesstokens 37 | sections/sessionmanagement 38 | sections/tokenintrospection 39 | sections/settings 40 | sections/signals 41 | sections/examples 42 | sections/contribute 43 | sections/changelog 44 | .. 45 | 46 | Indices and tables 47 | ================== 48 | 49 | * :ref:`genindex` 50 | * :ref:`modindex` 51 | * :ref:`search` 52 | -------------------------------------------------------------------------------- /docs/sections/templates.rst: -------------------------------------------------------------------------------- 1 | .. _templates: 2 | 3 | Templates 4 | ######### 5 | 6 | Add your own templates files inside a folder named ``templates/oidc_provider/``. 7 | You can copy the sample html files here and customize them with your own style. 8 | 9 | authorize.html 10 | ============== 11 | :: 12 | 13 |

Request for Permission

14 | 15 |

Client {{ client.name }} would like to access this information of you ...

16 | 17 |
18 | 19 | {% csrf_token %} 20 | 21 | {{ hidden_inputs }} 22 | 23 |
    24 | {% for scope in scopes %} 25 |
  • {{ scope.name }}
    {{ scope.description }}
  • 26 | {% endfor %} 27 |
28 | 29 | 30 | 31 | 32 |
33 | 34 | error.html 35 | ========== 36 | :: 37 | 38 |

{{ error }}

39 |

{{ description }}

40 | 41 | You can also customize paths to your custom templates by putting them in ``OIDC_TEMPLATES`` in the settings. 42 | 43 | The following contexts will be passed to the ``authorize`` and ``error`` templates respectively:: 44 | 45 | # For authorize template 46 | { 47 | 'client': 'an instance of Client for the auth request', 48 | 'hidden_inputs': 'a rendered html with all the hidden inputs needed for AuthorizeEndpoint', 49 | 'params': 'a dict containing the params in the auth request', 50 | 'scopes': 'a list of scopes' 51 | } 52 | 53 | # For error template 54 | { 55 | 'error': 'string stating the error', 56 | 'description': 'string stating description of the error' 57 | } 58 | 59 | end_session_prompt.html 60 | ======================= 61 | 62 | Read more at :doc:`Session Management > Logout consent prompt ` section. 63 | 64 | end_session_completed.html 65 | ========================== 66 | 67 | Read more at :doc:`Session Management > Other scenarios <../sections/sessionmanagement>` section. 68 | 69 | end_session_failed.html 70 | ======================= 71 | 72 | Read more at :doc:`Session Management > Other scenarios <../sections/sessionmanagement>` section. 73 | -------------------------------------------------------------------------------- /oidc_provider/tests/cases/test_provider_info_endpoint.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from django.core.cache import cache 4 | from django.test import RequestFactory 5 | from django.test import TestCase 6 | from django.test import override_settings 7 | 8 | try: 9 | from django.urls import reverse 10 | except ImportError: 11 | from django.core.urlresolvers import reverse 12 | 13 | from oidc_provider.views import ProviderInfoView 14 | 15 | 16 | class ProviderInfoTestCase(TestCase): 17 | def setUp(self): 18 | self.factory = RequestFactory() 19 | 20 | def tearDown(self): 21 | cache.clear() 22 | 23 | @patch("oidc_provider.views.ProviderInfoView._build_cache_key") 24 | def test_response(self, build_cache_key): 25 | """ 26 | See if the endpoint is returning the corresponding 27 | server information by checking status, content type, etc. 28 | """ 29 | url = reverse("oidc_provider:provider-info") 30 | 31 | request = self.factory.get(url) 32 | 33 | response = ProviderInfoView.as_view()(request) 34 | 35 | # Caching not available by default. 36 | build_cache_key.assert_not_called() 37 | 38 | self.assertEqual(response.status_code, 200) 39 | self.assertEqual(response["Content-Type"] == "application/json", True) 40 | self.assertEqual(bool(response.content), True) 41 | 42 | @override_settings(OIDC_DISCOVERY_CACHE_ENABLE=True) 43 | @patch("oidc_provider.views.ProviderInfoView._build_cache_key") 44 | def test_response_with_cache_enabled(self, build_cache_key): 45 | """ 46 | Enable caching on the discovery endpoint and ensure data is being saved on cache. 47 | """ 48 | build_cache_key.return_value = "key" 49 | 50 | url = reverse("oidc_provider:provider-info") 51 | 52 | request = self.factory.get(url) 53 | 54 | response = ProviderInfoView.as_view()(request) 55 | self.assertEqual(response.status_code, 200) 56 | build_cache_key.assert_called_once() 57 | 58 | assert "authorization_endpoint" in cache.get("key") 59 | 60 | response = ProviderInfoView.as_view()(request) 61 | self.assertEqual(response.status_code, 200) 62 | self.assertEqual(response["Content-Type"] == "application/json", True) 63 | self.assertEqual(bool(response.content), True) 64 | -------------------------------------------------------------------------------- /docs/sections/tokenintrospection.rst: -------------------------------------------------------------------------------- 1 | .. _tokenintrospection: 2 | 3 | Token Introspection 4 | ################### 5 | 6 | The `OAuth 2.0 Authorization Framework `_ extends its scope with many other speficications. One of these is the `OAuth 2.0 Token Introspection (RFC 7662) `_ which defines a protocol that allows authorized protected resources to query the authorization server to determine the set of metadata for a given token that was presented to them by an OAuth 2.0 client. 7 | 8 | Client Setup 9 | ============ 10 | In order to enable this feature, some configurations must be performed in the ``Client``. 11 | 12 | - The scope key:``token_introspection`` must be added to the client's scope. 13 | 14 | If ``OIDC_INTROSPECTION_VALIDATE_AUDIENCE_SCOPE`` is set to ``True`` then: 15 | 16 | - The ``client_id`` must be added to the client's scope. 17 | 18 | Introspection Endpoint 19 | ====================== 20 | The introspection endpoint ``(/introspect)`` is an OAuth 2.0 endpoint that takes a parameter representing an OAuth 2.0 token and returns a JSON document representing the meta information surrounding the token. 21 | 22 | The introspection endpoint its called using an HTTP POST request with parameters sent as *"application/x-www-form-urlencoded"* and **Basic authentication** (``base64(client_id:client_secret``). 23 | 24 | Parameters: 25 | 26 | * ``token`` 27 | REQUIRED. The string value of an ``access_token`` previously issued. 28 | 29 | Example request:: 30 | 31 | curl -X POST \ 32 | http://localhost:8000/introspect \ 33 | -H 'Authorization: Basic NDgwNTQ2OmIxOGIyODVmY2E5N2Fm' \ 34 | -H 'Content-Type: application/x-www-form-urlencoded' \ 35 | -d token=6dd4b859706944848183d26f2fcb99c6 36 | 37 | Example Response:: 38 | 39 | { 40 | "aud": "480546", 41 | "sub": "1", 42 | "exp": 1538971676, 43 | "iat": 1538971076, 44 | "iss": "http://localhost:8000", 45 | "active": true, 46 | "client_id": "480546" 47 | } 48 | 49 | Introspection Endpoint Errors 50 | ============================= 51 | In case of error, the Introspection Endpoint will return a JSON document with the key ``active: false`` 52 | 53 | Example Error Response:: 54 | 55 | { 56 | "active": "false" 57 | } 58 | -------------------------------------------------------------------------------- /docs/sections/oauth2.rst: -------------------------------------------------------------------------------- 1 | .. _oauth2: 2 | 3 | OAuth2 Server 4 | ############# 5 | 6 | Because OIDC is a layer on top of the OAuth 2.0 protocol, this package also gives you a simple but effective OAuth2 server that you can use not only for logging in your users on multiple platforms, but also to protect other resources you want to expose. 7 | 8 | Protecting Views 9 | ================ 10 | 11 | Here we are going to protect a view with a scope called ``read_books``:: 12 | 13 | from django.http import JsonResponse 14 | from django.views.decorators.http import require_http_methods 15 | 16 | from oidc_provider.lib.utils.oauth2 import protected_resource_view 17 | 18 | 19 | @require_http_methods(['GET']) 20 | @protected_resource_view(['read_books']) 21 | def protected_api(request, *args, **kwargs): 22 | 23 | dic = { 24 | 'protected': 'information', 25 | } 26 | 27 | return JsonResponse(dic, status=200) 28 | 29 | Client Credentials Grant 30 | ======================== 31 | 32 | The client can request an access token using only its client credentials (ID and SECRET) when the client is requesting access to the protected resources under its control, that have been previously arranged with the authorization server using the ``client.scope`` field. 33 | 34 | .. note:: 35 | You can use Django admin to manually set the client scope or programmatically:: 36 | 37 | client.scope = ['read_books', 'add_books'] 38 | client.save() 39 | 40 | This is how the request should look like:: 41 | 42 | POST /token HTTP/1.1 43 | Host: localhost:8000 44 | Authorization: Basic eWZ3a3c0cWxtaHY0cToyVWE0QjVzRlhmZ3pNeXR5d1FqT01jNUsxYmpWeXhXeXRySVdsTmpQbld3\ 45 | Content-Type: application/x-www-form-urlencoded 46 | 47 | grant_type=client_credentials 48 | 49 | A successful access token response will like this:: 50 | 51 | HTTP/1.1 200 OK 52 | Content-Type: application/json 53 | Cache-Control: no-store 54 | Pragma: no-cache 55 | 56 | { 57 | "token_type" : "Bearer", 58 | "access_token" : "eyJhbGciOiJSUzI1NiIsImtpZCI6IjEifQ.eyJzY3AiOlsib3BlbmlkIiw...", 59 | "expires_in" : 3600, 60 | "scope" : "read_books add_books" 61 | } 62 | 63 | Token introspection can be used to validate access tokens requested with client credentials if the ``OIDC_INTROSPECTION_VALIDATE_AUDIENCE_SCOPE`` setting is ``False``. 64 | -------------------------------------------------------------------------------- /example/app/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load i18n static %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | {% trans 'OpenID Provider Example' %} 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 37 | 38 |
39 | {% block content %}{% endblock %} 40 |
41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /example/app/settings.py: -------------------------------------------------------------------------------- 1 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 2 | import os 3 | 4 | BASE_DIR = os.path.dirname(os.path.dirname(__file__)) 5 | 6 | 7 | SECRET_KEY = "c14d549c574e4d8cf162404ef0b04598" 8 | 9 | DEBUG = True 10 | 11 | TEMPLATE_DEBUG = False 12 | 13 | ALLOWED_HOSTS = ["*"] 14 | 15 | # Application definition 16 | 17 | INSTALLED_APPS = [ 18 | "django.contrib.admin", 19 | "django.contrib.auth", 20 | "django.contrib.contenttypes", 21 | "django.contrib.sessions", 22 | "django.contrib.messages", 23 | "django.contrib.staticfiles", 24 | "app", 25 | "oidc_provider", 26 | ] 27 | 28 | MIDDLEWARE_CLASSES = [ 29 | "django.contrib.sessions.middleware.SessionMiddleware", 30 | "django.middleware.common.CommonMiddleware", 31 | "django.middleware.csrf.CsrfViewMiddleware", 32 | "django.contrib.auth.middleware.AuthenticationMiddleware", 33 | "django.contrib.messages.middleware.MessageMiddleware", 34 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 35 | "oidc_provider.middleware.SessionManagementMiddleware", 36 | ] 37 | MIDDLEWARE = MIDDLEWARE_CLASSES 38 | 39 | TEMPLATES = [ 40 | { 41 | "BACKEND": "django.template.backends.django.DjangoTemplates", 42 | "DIRS": [], 43 | "APP_DIRS": True, 44 | "OPTIONS": { 45 | "context_processors": [ 46 | "django.template.context_processors.debug", 47 | "django.template.context_processors.request", 48 | "django.contrib.auth.context_processors.auth", 49 | "django.contrib.messages.context_processors.messages", 50 | ], 51 | }, 52 | }, 53 | ] 54 | 55 | ROOT_URLCONF = "app.urls" 56 | 57 | WSGI_APPLICATION = "app.wsgi.application" 58 | 59 | # Database 60 | 61 | DATABASES = { 62 | "default": { 63 | "ENGINE": "django.db.backends.sqlite3", 64 | "NAME": os.path.join(BASE_DIR, "DATABASE.sqlite3"), 65 | } 66 | } 67 | 68 | # Internationalization 69 | 70 | LANGUAGE_CODE = "en-us" 71 | 72 | TIME_ZONE = "UTC" 73 | 74 | USE_I18N = True 75 | 76 | USE_L10N = True 77 | 78 | USE_TZ = True 79 | 80 | # Static files (CSS, JavaScript, Images) 81 | 82 | STATIC_URL = "/static/" 83 | STATIC_ROOT = os.path.join(BASE_DIR, "static/") 84 | 85 | # Custom settings 86 | 87 | LOGIN_REDIRECT_URL = "/" 88 | 89 | # OIDC Provider settings 90 | 91 | SITE_URL = "http://localhost:8000" 92 | OIDC_SESSION_MANAGEMENT_ENABLE = True 93 | -------------------------------------------------------------------------------- /oidc_provider/migrations/0018_hybridflow_and_clientattrs.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.7 on 2016-09-12 14:08 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | from django.db import models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("oidc_provider", "0017_auto_20160811_1954"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name="client", 17 | name="contact_email", 18 | field=models.CharField( 19 | blank=True, default="", max_length=255, verbose_name="Contact Email" 20 | ), 21 | ), 22 | migrations.AddField( 23 | model_name="client", 24 | name="logo", 25 | field=models.FileField( 26 | blank=True, default="", upload_to="oidc_provider/clients", verbose_name="Logo Image" 27 | ), 28 | ), 29 | migrations.AddField( 30 | model_name="client", 31 | name="terms_url", 32 | field=models.CharField( 33 | blank=True, 34 | default="", 35 | help_text="External reference to the privacy policy of the client.", 36 | max_length=255, 37 | verbose_name="Terms URL", 38 | ), 39 | ), 40 | migrations.AddField( 41 | model_name="client", 42 | name="website_url", 43 | field=models.CharField( 44 | blank=True, default="", max_length=255, verbose_name="Website URL" 45 | ), 46 | ), 47 | migrations.AlterField( 48 | model_name="client", 49 | name="jwt_alg", 50 | field=models.CharField( 51 | choices=[("HS256", "HS256"), ("RS256", "RS256")], 52 | default="RS256", 53 | help_text="Algorithm used to encode ID Tokens.", 54 | max_length=10, 55 | verbose_name="JWT Algorithm", 56 | ), 57 | ), 58 | migrations.AlterField( 59 | model_name="client", 60 | name="response_type", 61 | field=models.CharField( 62 | choices=[ 63 | ("code", "code (Authorization Code Flow)"), 64 | ("id_token", "id_token (Implicit Flow)"), 65 | ("id_token token", "id_token token (Implicit Flow)"), 66 | ("code token", "code token (Hybrid Flow)"), 67 | ("code id_token", "code id_token (Hybrid Flow)"), 68 | ("code id_token token", "code id_token token (Hybrid Flow)"), 69 | ], 70 | max_length=30, 71 | verbose_name="Response Type", 72 | ), 73 | ), 74 | ] 75 | -------------------------------------------------------------------------------- /docs/sections/accesstokens.rst: -------------------------------------------------------------------------------- 1 | .. _accesstokens: 2 | 3 | Access Tokens 4 | ############# 5 | 6 | At the end of the login process, an access token is generated. This access token is the thing that is passed along with every API call to the openid connect server (e.g. userinfo endpoint) as proof that the call was made by a specific person from a specific app. 7 | 8 | Access tokens generally have a lifetime of only a couple of hours. You can use ``OIDC_TOKEN_EXPIRE`` to set a custom expiration time that suits your needs. 9 | 10 | Obtaining an Access Token 11 | ========================= 12 | 13 | Go to the admin site and create a confidential client with ``response_types = code`` and ``redirect_uri = http://example.org/``. 14 | 15 | Open your browser and accept consent at:: 16 | 17 | http://localhost:8000/authorize?client_id=651462&redirect_uri=http://example.org/&response_type=code&scope=openid email profile&state=123123 18 | 19 | In the redirected URL you should have a ``code`` parameter included as query string:: 20 | 21 | http://example.org/?code=b9cedb346ee04f15ab1d3ac13da92002&state=123123 22 | 23 | We use the ``code`` value to obtain ``access_token`` and ``refresh_token``:: 24 | 25 | curl -X POST \ 26 | -H "Cache-Control: no-cache" \ 27 | -H "Content-Type: application/x-www-form-urlencoded" \ 28 | "http://localhost:8000/token/" \ 29 | -d "client_id=651462" \ 30 | -d "client_secret=37b1c4ff826f8d78bd45e25bad75a2c0" \ 31 | -d "code=b9cedb346ee04f15ab1d3ac13da92002" \ 32 | -d "redirect_uri=http://example.org/" \ 33 | -d "grant_type=authorization_code" 34 | 35 | Example response:: 36 | 37 | { 38 | "access_token": "82b35f3d810f4cf49dd7a52d4b22a594", 39 | "token_type": "bearer", 40 | "expires_in": 3600, 41 | "refresh_token": "0bac2d80d75d46658b0b31d3778039bb", 42 | "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6..." 43 | } 44 | 45 | Then you can grab the access token and ask for user data by doing a GET request to the ``/userinfo`` endpoint:: 46 | 47 | curl -X GET \ 48 | -H "Cache-Control: no-cache" \ 49 | "http://localhost:8000/userinfo/?access_token=82b35f3d810f4cf49dd7a52d4b22a594" 50 | 51 | Expiration and Refresh of Access Tokens 52 | ======================================= 53 | 54 | If you receive a ``401 Unauthorized`` status when using the access token, this probably means that your access token has expired. 55 | 56 | The RP application can request a new access token by using the refresh token. Send a POST request to the ``/token`` endpoint with the following request parameters:: 57 | 58 | curl -X POST \ 59 | -H "Cache-Control: no-cache" \ 60 | -H "Content-Type: application/x-www-form-urlencoded" \ 61 | "http://localhost:8000/token/" \ 62 | -d "client_id=651462" \ 63 | -d "client_secret=37b1c4ff826f8d78bd45e25bad75a2c0" \ 64 | -d "grant_type=refresh_token" \ 65 | -d "refresh_token=0bac2d80d75d46658b0b31d3778039bb" 66 | -------------------------------------------------------------------------------- /oidc_provider/migrations/0017_auto_20160811_1954.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.7 on 2016-08-11 19:54 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | from django.db import models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("oidc_provider", "0016_userconsent_and_verbosenames"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name="client", 17 | name="_redirect_uris", 18 | field=models.TextField( 19 | default="", help_text="Enter each URI on a new line.", verbose_name="Redirect URIs" 20 | ), 21 | ), 22 | migrations.AlterField( 23 | model_name="client", 24 | name="client_secret", 25 | field=models.CharField( 26 | blank=True, default="", max_length=255, verbose_name="Client SECRET" 27 | ), 28 | ), 29 | migrations.AlterField( 30 | model_name="client", 31 | name="client_type", 32 | field=models.CharField( 33 | choices=[("confidential", "Confidential"), ("public", "Public")], 34 | default="confidential", 35 | help_text="Confidential clients are capable of maintaining the confidentiality of their " 36 | "credentials. Public clients are incapable.", 37 | max_length=30, 38 | verbose_name="Client Type", 39 | ), 40 | ), 41 | migrations.AlterField( 42 | model_name="client", 43 | name="name", 44 | field=models.CharField(default="", max_length=100, verbose_name="Name"), 45 | ), 46 | migrations.AlterField( 47 | model_name="client", 48 | name="response_type", 49 | field=models.CharField( 50 | choices=[ 51 | ("code", "code (Authorization Code Flow)"), 52 | ("id_token", "id_token (Implicit Flow)"), 53 | ("id_token token", "id_token token (Implicit Flow)"), 54 | ], 55 | max_length=30, 56 | verbose_name="Response Type", 57 | ), 58 | ), 59 | migrations.AlterField( 60 | model_name="code", 61 | name="_scope", 62 | field=models.TextField(default="", verbose_name="Scopes"), 63 | ), 64 | migrations.AlterField( 65 | model_name="code", 66 | name="nonce", 67 | field=models.CharField(blank=True, default="", max_length=255, verbose_name="Nonce"), 68 | ), 69 | migrations.AlterField( 70 | model_name="token", 71 | name="_scope", 72 | field=models.TextField(default="", verbose_name="Scopes"), 73 | ), 74 | migrations.AlterField( 75 | model_name="userconsent", 76 | name="_scope", 77 | field=models.TextField(default="", verbose_name="Scopes"), 78 | ), 79 | ] 80 | -------------------------------------------------------------------------------- /oidc_provider/migrations/0015_change_client_code.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.7 on 2016-06-10 13:55 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | from django.db import models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("oidc_provider", "0014_client_jwt_alg"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name="client", 17 | name="_redirect_uris", 18 | field=models.TextField( 19 | default="", help_text="Enter each URI on a new line.", verbose_name="Redirect URI" 20 | ), 21 | ), 22 | migrations.AlterField( 23 | model_name="client", 24 | name="client_secret", 25 | field=models.CharField(blank=True, default="", max_length=255), 26 | ), 27 | migrations.AlterField( 28 | model_name="client", 29 | name="client_type", 30 | field=models.CharField( 31 | choices=[("confidential", "Confidential"), ("public", "Public")], 32 | default="confidential", 33 | help_text="Confidential clients are capable of maintaining the confidentiality of their" 34 | " credentials. Public clients are incapable.", 35 | max_length=30, 36 | ), 37 | ), 38 | migrations.AlterField( 39 | model_name="client", 40 | name="jwt_alg", 41 | field=models.CharField( 42 | choices=[("HS256", "HS256"), ("RS256", "RS256")], 43 | default="RS256", 44 | max_length=10, 45 | verbose_name="JWT Algorithm", 46 | ), 47 | ), 48 | migrations.AlterField( 49 | model_name="client", 50 | name="name", 51 | field=models.CharField(default="", max_length=100), 52 | ), 53 | migrations.AlterField( 54 | model_name="client", 55 | name="response_type", 56 | field=models.CharField( 57 | choices=[ 58 | ("code", "code (Authorization Code Flow)"), 59 | ("id_token", "id_token (Implicit Flow)"), 60 | ("id_token token", "id_token token (Implicit Flow)"), 61 | ], 62 | max_length=30, 63 | ), 64 | ), 65 | migrations.AlterField( 66 | model_name="code", 67 | name="_scope", 68 | field=models.TextField(default=""), 69 | ), 70 | migrations.AlterField( 71 | model_name="code", 72 | name="nonce", 73 | field=models.CharField(blank=True, default="", max_length=255), 74 | ), 75 | migrations.AlterField( 76 | model_name="token", 77 | name="_scope", 78 | field=models.TextField(default=""), 79 | ), 80 | migrations.AlterField( 81 | model_name="userconsent", 82 | name="_scope", 83 | field=models.TextField(default=""), 84 | ), 85 | ] 86 | -------------------------------------------------------------------------------- /oidc_provider/migrations/0026_client_multiple_response_types.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.7 on 2018-08-15 20:44 2 | 3 | from django.db import migrations 4 | from django.db import models 5 | 6 | 7 | def migrate_response_type(apps, schema_editor): 8 | RESPONSE_TYPES = [ 9 | ("code", "code (Authorization Code Flow)"), 10 | ("id_token", "id_token (Implicit Flow)"), 11 | ("id_token token", "id_token token (Implicit Flow)"), 12 | ("code token", "code token (Hybrid Flow)"), 13 | ("code id_token", "code id_token (Hybrid Flow)"), 14 | ("code id_token token", "code id_token token (Hybrid Flow)"), 15 | ] 16 | # ensure we get proper, versioned model with the deleted response_type field; 17 | # importing directly yields the latest without response_type 18 | ResponseType = apps.get_model("oidc_provider", "ResponseType") 19 | Client = apps.get_model("oidc_provider", "Client") 20 | db = schema_editor.connection.alias 21 | for value, description in RESPONSE_TYPES: 22 | ResponseType.objects.using(db).create(value=value, description=description) 23 | for client in Client.objects.using(db).all(): 24 | client.response_types.add(ResponseType.objects.using(db).get(value=client.response_type)) 25 | 26 | 27 | class Migration(migrations.Migration): 28 | dependencies = [ 29 | ("oidc_provider", "0025_user_field_codetoken"), 30 | ] 31 | 32 | operations = [ 33 | migrations.CreateModel( 34 | name="ResponseType", 35 | fields=[ 36 | ( 37 | "id", 38 | models.AutoField( 39 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 40 | ), 41 | ), 42 | ( 43 | "value", 44 | models.CharField( 45 | choices=[ 46 | ("code", "code (Authorization Code Flow)"), 47 | ("id_token", "id_token (Implicit Flow)"), 48 | ("id_token token", "id_token token (Implicit Flow)"), 49 | ("code token", "code token (Hybrid Flow)"), 50 | ("code id_token", "code id_token (Hybrid Flow)"), 51 | ("code id_token token", "code id_token token (Hybrid Flow)"), 52 | ], 53 | max_length=30, 54 | unique=True, 55 | verbose_name="Response Type Value", 56 | ), 57 | ), 58 | ("description", models.CharField(max_length=50)), 59 | ], 60 | ), 61 | migrations.AddField( 62 | model_name="client", 63 | name="response_types", 64 | field=models.ManyToManyField(to="oidc_provider.ResponseType"), 65 | ), 66 | # omitting reverse migrate_response_type since removing response_type is irreversible (nonnull and no default) 67 | migrations.RunPython(migrate_response_type), 68 | migrations.RemoveField( 69 | model_name="client", 70 | name="response_type", 71 | ), 72 | ] 73 | -------------------------------------------------------------------------------- /oidc_provider/tests/cases/test_claims.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.test import TestCase 4 | from django.utils.translation import override as override_language 5 | from six import text_type 6 | 7 | from oidc_provider.lib.claims import STANDARD_CLAIMS 8 | from oidc_provider.lib.claims import ScopeClaims 9 | from oidc_provider.lib.claims import StandardScopeClaims 10 | from oidc_provider.tests.app.utils import create_fake_client 11 | from oidc_provider.tests.app.utils import create_fake_token 12 | from oidc_provider.tests.app.utils import create_fake_user 13 | 14 | 15 | class ClaimsTestCase(TestCase): 16 | def setUp(self): 17 | self.user = create_fake_user() 18 | self.scopes = ["openid", "address", "email", "phone", "profile", "foo"] 19 | self.client = create_fake_client("code") 20 | self.token = create_fake_token(self.user, self.scopes, self.client) 21 | self.scopeClaims = ScopeClaims(self.token) 22 | 23 | def test_empty_standard_claims(self): 24 | for v in [v for k, v in STANDARD_CLAIMS.items() if k != "address"]: 25 | self.assertEqual(v, "") 26 | 27 | for v in STANDARD_CLAIMS["address"].values(): 28 | self.assertEqual(v, "") 29 | 30 | def test_clean_dic(self): 31 | """assert that _clean_dic function returns a clean dictionnary 32 | (no empty claims)""" 33 | dict_to_clean = { 34 | "phone_number_verified": "", 35 | "middle_name": "", 36 | "name": "John Doe", 37 | "website": "", 38 | "profile": "", 39 | "family_name": "Doe", 40 | "birthdate": "", 41 | "preferred_username": "", 42 | "picture": "", 43 | "zoneinfo": "", 44 | "locale": "", 45 | "gender": "", 46 | "updated_at": "", 47 | "address": {}, 48 | "given_name": "John", 49 | "email_verified": "", 50 | "nickname": "", 51 | "email": "johndoe@example.com", 52 | "phone_number": "", 53 | } 54 | clean_dict = self.scopeClaims._clean_dic(dict_to_clean) 55 | self.assertEqual( 56 | clean_dict, 57 | { 58 | "family_name": "Doe", 59 | "given_name": "John", 60 | "name": "John Doe", 61 | "email": "johndoe@example.com", 62 | }, 63 | ) 64 | 65 | def test_locale(self): 66 | with override_language("fr"): 67 | self.assertEqual(text_type(StandardScopeClaims.info_profile[0]), "Profil de base") 68 | 69 | def test_scopeclaims_class_inheritance(self): 70 | # Generate example class that will be used for `OIDC_EXTRA_SCOPE_CLAIMS` setting. 71 | class CustomScopeClaims(ScopeClaims): 72 | info_foo = ("Title", "Description") 73 | 74 | def scope_foo(self): 75 | dic = {"test": self.user.id} 76 | return dic 77 | 78 | info_notadd = ("Title", "Description") 79 | 80 | def scope_notadd(self): 81 | dic = {"test": self.user.id} 82 | return dic 83 | 84 | claims = CustomScopeClaims(self.token) 85 | response = claims.create_response_dic() 86 | 87 | self.assertTrue("test" in response.keys()) 88 | self.assertFalse("notadd" in response.keys()) 89 | -------------------------------------------------------------------------------- /docs/sections/examples.rst: -------------------------------------------------------------------------------- 1 | .. _examples: 2 | 3 | Examples 4 | ######## 5 | 6 | Pure JS client using Implicit Flow 7 | ================================== 8 | 9 | Testing OpenID Connect flow can be as simple as putting one file with a few functions on the client and calling the provider. Let me show. 10 | 11 | **01. Setup the provider** 12 | 13 | You can use the example project code to run your OIDC Provider at ``localhost:8000``. 14 | 15 | Go to the admin site and create a public client with a response_type ``id_token token`` and a redirect_uri ``http://localhost:3000``. 16 | 17 | .. note:: 18 | Remember to create at least one **RSA Key** for the server with ``python manage.py creatersakey`` 19 | 20 | **02. Create the client** 21 | 22 | As relying party we are going to use a JS library created by Nat Sakimura. `Here is the article `_. 23 | 24 | **index.html**:: 25 | 26 | 27 | 28 | 29 | 30 | OIDC RP 31 | 32 | 33 | 34 | 35 |
36 |

OpenID Connect RP Example

37 | 38 |
39 | 40 | 41 | 42 | 43 | 79 | 80 | 81 | 82 | 83 | .. note:: 84 | Remember that you must set your client_id (line 21). 85 | 86 | **03. Make an authorization request** 87 | 88 | By clicking the login button an authorization request has been made to the provider. After you accept it, the provider will redirect back to your previously registered ``redirect_uri`` with all the tokens requested. 89 | 90 | **04. Requesting user information** 91 | 92 | Now having the access_token in your hands you can request the user information by making a request to the ``/userinfo`` endpoint of the provider. 93 | 94 | In this example we display information in the alert box. 95 | -------------------------------------------------------------------------------- /oidc_provider/lib/utils/oauth2.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | from base64 import b64decode 4 | 5 | from django.http import HttpResponse 6 | 7 | from oidc_provider.lib.errors import BearerTokenError 8 | from oidc_provider.models import Token 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | def extract_access_token(request): 14 | """ 15 | Get the access token using Authorization Request Header Field method. 16 | Or try getting via GET. 17 | See: http://tools.ietf.org/html/rfc6750#section-2.1 18 | 19 | Return a string. 20 | """ 21 | auth_header = request.META.get("HTTP_AUTHORIZATION", "") 22 | 23 | if re.compile(r"^[Bb]earer\s{1}.+$").match(auth_header): 24 | access_token = auth_header.split()[1] 25 | else: 26 | access_token = request.GET.get("access_token", "") 27 | 28 | return access_token 29 | 30 | 31 | def extract_client_auth(request): 32 | """ 33 | Get client credentials using HTTP Basic Authentication method. 34 | Or try getting parameters via POST. 35 | See: http://tools.ietf.org/html/rfc6750#section-2.1 36 | 37 | Return a tuple `(client_id, client_secret)`. 38 | """ 39 | auth_header = request.META.get("HTTP_AUTHORIZATION", "") 40 | 41 | if re.compile(r"^Basic\s{1}.+$").match(auth_header): 42 | b64_user_pass = auth_header.split()[1] 43 | try: 44 | user_pass = b64decode(b64_user_pass).decode("utf-8").split(":") 45 | client_id, client_secret = tuple(user_pass) 46 | except Exception: 47 | client_id = client_secret = "" 48 | else: 49 | client_id = request.POST.get("client_id", "") 50 | client_secret = request.POST.get("client_secret", "") 51 | 52 | return (client_id, client_secret) 53 | 54 | 55 | def protected_resource_view(scopes=None): 56 | """ 57 | View decorator. The client accesses protected resources by presenting the 58 | access token to the resource server. 59 | https://tools.ietf.org/html/rfc6749#section-7 60 | """ 61 | if scopes is None: 62 | scopes = [] 63 | 64 | def wrapper(view): 65 | def view_wrapper(request, *args, **kwargs): 66 | access_token = extract_access_token(request) 67 | 68 | try: 69 | try: 70 | kwargs["token"] = Token.objects.get(access_token=access_token) 71 | except Token.DoesNotExist: 72 | logger.debug("[UserInfo] Token does not exist: %s", access_token) 73 | raise BearerTokenError("invalid_token") 74 | 75 | if kwargs["token"].has_expired(): 76 | logger.debug("[UserInfo] Token has expired: %s", access_token) 77 | raise BearerTokenError("invalid_token") 78 | 79 | if not set(scopes).issubset(set(kwargs["token"].scope)): 80 | logger.debug("[UserInfo] Missing openid scope.") 81 | raise BearerTokenError("insufficient_scope") 82 | except BearerTokenError as error: 83 | response = HttpResponse(status=error.status) 84 | response["WWW-Authenticate"] = 'error="{0}", error_description="{1}"'.format( 85 | error.code, error.description 86 | ) 87 | return response 88 | 89 | return view(request, *args, **kwargs) 90 | 91 | return view_wrapper 92 | 93 | return wrapper 94 | -------------------------------------------------------------------------------- /docs/sections/relyingparties.rst: -------------------------------------------------------------------------------- 1 | .. _relyingparties: 2 | 3 | Relying Parties 4 | ############### 5 | 6 | Relying Parties (RP) creation is up to you. This is because is out of the scope in the core implementation of OIDC. 7 | So, there are different ways to create your Clients (RP). By displaying a HTML form or maybe if you have internal trusted Clients you can create them programatically. 8 | Out of the box, django-oidc-provider enables you to create them by hand in the django admin. 9 | 10 | OAuth defines two client types, based on their ability to maintain the confidentiality of their client credentials: 11 | 12 | * ``confidential``: Clients capable of maintaining the confidentiality of their credentials (e.g., client implemented on a secure server with restricted access to the client credentials). 13 | * ``public``: Clients incapable of maintaining the confidentiality of their credentials (e.g., clients executing on the device used by the resource owner, such as an installed native application or a web browser-based application), and incapable of secure client authentication via any other means. 14 | 15 | Properties 16 | ========== 17 | 18 | * ``name``: Human-readable name for your client. 19 | * ``client_type``: Values are ``confidential`` and ``public``. 20 | * ``client_id``: Client unique identifier. 21 | * ``client_secret``: Client secret for confidential applications. 22 | * ``response_types``: The flows and associated ``response_type`` values that can be used by the client. 23 | * ``jwt_alg``: Clients can choose which algorithm will be used to sign id_tokens. Values are ``HS256`` and ``RS256``. 24 | * ``date_created``: Date automatically added when created. 25 | * ``redirect_uris``: List of redirect URIs. 26 | * ``require_consent``: If checked, the Server will never ask for consent (only applies to confidential clients). 27 | * ``reuse_consent``: 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. 28 | 29 | Optional information: 30 | 31 | * ``website_url``: Website URL of your client. 32 | * ``terms_url``: External reference to the privacy policy of the client. 33 | * ``contact_email``: Contact email. 34 | * ``logo``: Logo image. 35 | 36 | Using the admin 37 | =============== 38 | 39 | We suggest you to use Django admin to easily manage your clients: 40 | 41 | .. image:: ../images/client_creation.png 42 | :align: center 43 | 44 | For re-generating ``client_secret``, when you are in the Client editing view, select "Client type" to be ``public``. Then after saving, select back to be ``confidential`` and save again. 45 | 46 | Custom view 47 | =========== 48 | 49 | If for some reason you need to create your own view to manage them, you can grab the form class that the admin makes use of. Located in ``oidc_provider.admin.ClientForm``. 50 | 51 | Some built-in logic that comes with it: 52 | 53 | * Automatic ``client_id`` and ``client_secret`` generation. 54 | * Empty ``client_secret`` when ``client_type`` is equal to ``public``. 55 | 56 | Programmatically 57 | ================ 58 | 59 | You can create a Client programmatically with Django shell ``python manage.py shell``:: 60 | 61 | >>> from oidc_provider.models import Client, ResponseType 62 | >>> c = Client(name='Some Client', client_id='123', client_secret='456', redirect_uris=['http://example.com/']) 63 | >>> c.save() 64 | >>> c.response_types.add(ResponseType.objects.get(value='code')) 65 | 66 | `Read more about client creation in the OAuth2 spec `_ 67 | -------------------------------------------------------------------------------- /oidc_provider/locale/es/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2016-07-26 17:16-0300\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | 21 | #: lib/claims.py:92 22 | msgid "Basic profile" 23 | msgstr "" 24 | 25 | #: lib/claims.py:93 26 | msgid "" 27 | "Access to your basic information. Includes names, gender, birthdate and " 28 | "other information." 29 | msgstr "" 30 | 31 | #: lib/claims.py:116 32 | msgid "Email" 33 | msgstr "" 34 | 35 | #: lib/claims.py:117 36 | msgid "Access to your email address." 37 | msgstr "" 38 | 39 | #: lib/claims.py:128 40 | msgid "Phone number" 41 | msgstr "" 42 | 43 | #: lib/claims.py:129 44 | msgid "Access to your phone number." 45 | msgstr "" 46 | 47 | #: lib/claims.py:140 48 | msgid "Address information" 49 | msgstr "" 50 | 51 | #: lib/claims.py:141 52 | msgid "" 53 | "Access to your address. Includes country, locality, street and other " 54 | "information." 55 | msgstr "" 56 | 57 | #: models.py:29 58 | msgid "Name" 59 | msgstr "" 60 | 61 | #: models.py:30 62 | msgid "Client Type" 63 | msgstr "" 64 | 65 | #: models.py:30 66 | msgid "" 67 | "Confidential clients are capable of maintaining the confidentiality " 68 | "of their credentials. Public clients are incapable." 69 | msgstr "" 70 | 71 | #: models.py:31 72 | msgid "Client ID" 73 | msgstr "" 74 | 75 | #: models.py:32 76 | msgid "Client SECRET" 77 | msgstr "" 78 | 79 | #: models.py:33 80 | msgid "Response Type" 81 | msgstr "" 82 | 83 | #: models.py:34 84 | msgid "JWT Algorithm" 85 | msgstr "" 86 | 87 | #: models.py:35 88 | msgid "Date Created" 89 | msgstr "" 90 | 91 | #: models.py:37 92 | msgid "Redirect URIs" 93 | msgstr "" 94 | 95 | #: models.py:37 96 | msgid "Enter each URI on a new line." 97 | msgstr "" 98 | 99 | #: models.py:40 models.py:65 100 | msgid "Client" 101 | msgstr "" 102 | 103 | #: models.py:41 104 | msgid "Clients" 105 | msgstr "" 106 | 107 | #: models.py:64 108 | msgid "User" 109 | msgstr "" 110 | 111 | #: models.py:66 112 | msgid "Expiration Date" 113 | msgstr "" 114 | 115 | #: models.py:67 116 | msgid "Scopes" 117 | msgstr "" 118 | 119 | #: models.py:92 120 | msgid "Code" 121 | msgstr "" 122 | 123 | #: models.py:93 124 | msgid "Nonce" 125 | msgstr "" 126 | 127 | #: models.py:94 128 | msgid "Is Authentication?" 129 | msgstr "" 130 | 131 | #: models.py:95 132 | msgid "Code Challenge" 133 | msgstr "" 134 | 135 | #: models.py:96 136 | msgid "Code Challenge Method" 137 | msgstr "" 138 | 139 | #: models.py:99 140 | msgid "Authorization Code" 141 | msgstr "" 142 | 143 | #: models.py:100 144 | msgid "Authorization Codes" 145 | msgstr "" 146 | 147 | #: models.py:105 148 | msgid "Access Token" 149 | msgstr "" 150 | 151 | #: models.py:106 152 | msgid "Refresh Token" 153 | msgstr "" 154 | 155 | #: models.py:107 156 | msgid "ID Token" 157 | msgstr "" 158 | 159 | #: models.py:117 160 | msgid "Token" 161 | msgstr "" 162 | 163 | #: models.py:118 164 | msgid "Tokens" 165 | msgstr "" 166 | 167 | #: models.py:123 168 | msgid "Date Given" 169 | msgstr "" 170 | 171 | #: models.py:131 172 | msgid "Key" 173 | msgstr "" 174 | 175 | #: models.py:131 176 | msgid "Paste your private RSA Key here." 177 | msgstr "" 178 | 179 | #: models.py:134 180 | msgid "RSA Key" 181 | msgstr "" 182 | 183 | #: models.py:135 184 | msgid "RSA Keys" 185 | msgstr "" 186 | -------------------------------------------------------------------------------- /oidc_provider/static/oidc_provider/js/sha256.min.js: -------------------------------------------------------------------------------- 1 | /** 2 | * [js-sha256]{@link https://github.com/emn178/js-sha256} 3 | * 4 | * @version 0.3.2 5 | * @author Chen, Yi-Cyuan [emn178@gmail.com] 6 | * @copyright Chen, Yi-Cyuan 2014-2016 7 | * @license MIT 8 | */ 9 | (function(I){"object"==typeof process&&process.versions&&process.versions.node&&(I=global);var a="0123456789abcdef".split(""),Q=[-2147483648,8388608,32768,128],C=[24,16,8,0],L=[1116352408,1899447441,3049323471,3921009573,961987163,1508970993,2453635748,2870763221,3624381080,310598401,607225278,1426881987,1925078388,2162078206,2614888103,3248222580,3835390401,4022224774,264347078,604807628,770255983,1249150122,1555081692,1996064986,2554220882,2821834349,2952996808,3210313671,3336571891,3584528711, 10 | 113926993,338241895,666307205,773529912,1294757372,1396182291,1695183700,1986661051,2177026350,2456956037,2730485921,2820302411,3259730800,3345764771,3516065817,3600352804,4094571909,275423344,430227734,506948616,659060556,883997877,958139571,1322822218,1537002063,1747873779,1955562222,2024104815,2227730452,2361852424,2428436474,2756734187,3204031479,3329325298],c=[],J=function(a){return A(a,!0)},A=function(D,A){var K="string"!=typeof D;K&&D.constructor==I.ArrayBuffer&&(D=new Uint8Array(D));var m, 11 | n,p,q,r,t,u,v,e,J=!0,O=!1,b,B=0,M=0,P=0,N=D.length,h,d,E,F,G,H;A?(m=3238371032,n=914150663,p=812702999,q=4144912697,r=4290775857,t=1750603025,u=1694076839,v=3204075428):(m=1779033703,n=3144134277,p=1013904242,q=2773480762,r=1359893119,t=2600822924,u=528734635,v=1541459225);e=0;do{c[0]=e;c[16]=c[1]=c[2]=c[3]=c[4]=c[5]=c[6]=c[7]=c[8]=c[9]=c[10]=c[11]=c[12]=c[13]=c[14]=c[15]=0;if(K)for(b=M;Bb;++B)c[b>>2]|=D[B]<b;++B)e=D.charCodeAt(B),128>e?c[b>>2]|=e< 12 | e?c[b>>2]|=(192|e>>6)<e||57344<=e?c[b>>2]|=(224|e>>12)<>2]|=(240|e>>18)<>2]|=(128|e>>12&63)<>2]|=(128|e>>6&63)<>2]|=(128|e&63)<>2]|=Q[b&3],++B);e=c[16];B>N&&56>b&&(c[15]=P<<3,O=!0);var w=m,k=n,l=p,f=q,x=r,y=t,z=u,g=v;for(b=16;64>b;++b)d=c[b-15],h=(d>>>7|d<<25)^(d>>>18|d<<14)^d>>>3,d=c[b-2],d=(d>>>17|d<<15)^(d>>>19|d<<13)^d>>>10,c[b]=c[b-16]+ 13 | h+c[b-7]+d<<0;H=k&l;for(b=0;64>b;b+=4)J?(A?(G=300032,d=c[0]-1413257819,g=d-150054599<<0,f=d+24177077<<0):(G=704751109,d=c[0]-210244248,g=d-1521486534<<0,f=d+143694565<<0),J=!1):(h=(w>>>2|w<<30)^(w>>>13|w<<19)^(w>>>22|w<<10),d=(x>>>6|x<<26)^(x>>>11|x<<21)^(x>>>25|x<<7),G=w&k,E=G^w&l^H,F=x&y^~x&z,d=g+d+F+L[b]+c[b],h+=E,g=f+d<<0,f=d+h<<0),h=(f>>>2|f<<30)^(f>>>13|f<<19)^(f>>>22|f<<10),d=(g>>>6|g<<26)^(g>>>11|g<<21)^(g>>>25|g<<7),H=f&w,E=H^f&k^G,F=g&x^~g&y,d=z+d+F+L[b+1]+c[b+1],h+=E,z=l+d<<0,l=d+h<<0, 14 | h=(l>>>2|l<<30)^(l>>>13|l<<19)^(l>>>22|l<<10),d=(z>>>6|z<<26)^(z>>>11|z<<21)^(z>>>25|z<<7),G=l&f,E=G^l&w^H,F=z&g^~z&x,d=y+d+F+L[b+2]+c[b+2],h+=E,y=k+d<<0,k=d+h<<0,h=(k>>>2|k<<30)^(k>>>13|k<<19)^(k>>>22|k<<10),d=(y>>>6|y<<26)^(y>>>11|y<<21)^(y>>>25|y<<7),H=k&l,E=H^k&f^G,F=y&z^~y&g,d=x+d+F+L[b+3]+c[b+3],h+=E,x=w+d<<0,w=d+h<<0;m=m+w<<0;n=n+k<<0;p=p+l<<0;q=q+f<<0;r=r+x<<0;t=t+y<<0;u=u+z<<0;v=v+g<<0}while(!O);K=a[m>>28&15]+a[m>>24&15]+a[m>>20&15]+a[m>>16&15]+a[m>>12&15]+a[m>>8&15]+a[m>>4&15]+a[m&15]+a[n>> 15 | 28&15]+a[n>>24&15]+a[n>>20&15]+a[n>>16&15]+a[n>>12&15]+a[n>>8&15]+a[n>>4&15]+a[n&15]+a[p>>28&15]+a[p>>24&15]+a[p>>20&15]+a[p>>16&15]+a[p>>12&15]+a[p>>8&15]+a[p>>4&15]+a[p&15]+a[q>>28&15]+a[q>>24&15]+a[q>>20&15]+a[q>>16&15]+a[q>>12&15]+a[q>>8&15]+a[q>>4&15]+a[q&15]+a[r>>28&15]+a[r>>24&15]+a[r>>20&15]+a[r>>16&15]+a[r>>12&15]+a[r>>8&15]+a[r>>4&15]+a[r&15]+a[t>>28&15]+a[t>>24&15]+a[t>>20&15]+a[t>>16&15]+a[t>>12&15]+a[t>>8&15]+a[t>>4&15]+a[t&15]+a[u>>28&15]+a[u>>24&15]+a[u>>20&15]+a[u>>16&15]+a[u>>12& 16 | 15]+a[u>>8&15]+a[u>>4&15]+a[u&15];A||(K+=a[v>>28&15]+a[v>>24&15]+a[v>>20&15]+a[v>>16&15]+a[v>>12&15]+a[v>>8&15]+a[v>>4&15]+a[v&15]);return K};!I.JS_SHA256_TEST&&"object"==typeof module&&module.exports?(A.sha256=A,A.sha224=J,module.exports=A):I&&(I.sha256=A,I.sha224=J)})(this); 17 | -------------------------------------------------------------------------------- /oidc_provider/admin.py: -------------------------------------------------------------------------------- 1 | from hashlib import sha224 2 | from random import randint 3 | from uuid import uuid4 4 | 5 | from django.contrib import admin 6 | from django.forms import ModelForm 7 | from django.utils.translation import gettext_lazy as _ 8 | 9 | from oidc_provider.lib.utils.sanitization import sanitize_client_id 10 | from oidc_provider.models import Client 11 | from oidc_provider.models import Code 12 | from oidc_provider.models import RSAKey 13 | from oidc_provider.models import Token 14 | 15 | 16 | class ClientForm(ModelForm): 17 | class Meta: 18 | model = Client 19 | exclude = [] 20 | 21 | def __init__(self, *args, **kwargs): 22 | super(ClientForm, self).__init__(*args, **kwargs) 23 | self.fields["client_id"].required = False 24 | self.fields["client_id"].widget.attrs["disabled"] = "true" 25 | self.fields["client_secret"].required = False 26 | self.fields["client_secret"].widget.attrs["disabled"] = "true" 27 | self.fields["jwt_alg"].required = False 28 | 29 | def clean_client_id(self): 30 | instance = getattr(self, "instance", None) 31 | if instance and instance.pk: 32 | # Sanitize existing client_id to remove any problematic characters 33 | return sanitize_client_id(instance.client_id) 34 | else: 35 | # Generate new client_id (digits only) 36 | return str(randint(1, 999999)).zfill(6) 37 | 38 | def clean_client_secret(self): 39 | instance = getattr(self, "instance", None) 40 | 41 | secret = "" 42 | 43 | if instance and instance.pk: 44 | if (self.cleaned_data["client_type"] == "confidential") and not instance.client_secret: 45 | secret = sha224(uuid4().hex.encode()).hexdigest() 46 | elif (self.cleaned_data["client_type"] == "confidential") and instance.client_secret: 47 | secret = instance.client_secret 48 | else: 49 | if self.cleaned_data["client_type"] == "confidential": 50 | secret = sha224(uuid4().hex.encode()).hexdigest() 51 | 52 | return secret 53 | 54 | 55 | @admin.register(Client) 56 | class ClientAdmin(admin.ModelAdmin): 57 | fieldsets = [ 58 | [ 59 | _(""), 60 | { 61 | "fields": ( 62 | "name", 63 | "owner", 64 | "client_type", 65 | "response_types", 66 | "_redirect_uris", 67 | "jwt_alg", 68 | "require_consent", 69 | "reuse_consent", 70 | ), 71 | }, 72 | ], 73 | [ 74 | _("Credentials"), 75 | { 76 | "fields": ("client_id", "client_secret", "_scope"), 77 | }, 78 | ], 79 | [ 80 | _("Information"), 81 | { 82 | "fields": ("contact_email", "website_url", "terms_url", "logo", "date_created"), 83 | }, 84 | ], 85 | [ 86 | _("Session Management"), 87 | { 88 | "fields": ("_post_logout_redirect_uris",), 89 | }, 90 | ], 91 | ] 92 | form = ClientForm 93 | list_display = ["name", "client_id", "response_type_descriptions", "date_created"] 94 | readonly_fields = ["date_created"] 95 | search_fields = ["name"] 96 | raw_id_fields = ["owner"] 97 | 98 | 99 | @admin.register(Code) 100 | class CodeAdmin(admin.ModelAdmin): 101 | raw_id_fields = ["user"] 102 | 103 | def has_add_permission(self, request): 104 | return False 105 | 106 | 107 | @admin.register(Token) 108 | class TokenAdmin(admin.ModelAdmin): 109 | raw_id_fields = ["user"] 110 | 111 | def has_add_permission(self, request): 112 | return False 113 | 114 | 115 | @admin.register(RSAKey) 116 | class RSAKeyAdmin(admin.ModelAdmin): 117 | readonly_fields = ["kid"] 118 | -------------------------------------------------------------------------------- /oidc_provider/tests/cases/test_admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.test import TestCase 3 | 4 | from oidc_provider.admin import ClientForm 5 | from oidc_provider.models import Client 6 | from oidc_provider.models import ResponseType 7 | from oidc_provider.tests.app.utils import create_fake_user 8 | 9 | User = get_user_model() 10 | 11 | 12 | class ClientFormTest(TestCase): 13 | """ 14 | Test cases for ClientForm in admin. 15 | """ 16 | 17 | def setUp(self): 18 | self.user = create_fake_user() 19 | self.code_response_type, _ = ResponseType.objects.get_or_create( 20 | value="code", defaults={"description": "code (Authorization Code Flow)"} 21 | ) 22 | 23 | def test_creates_client_without_client_id_generates_random_one(self): 24 | """Test that creating a client without client_id generates a random 6-digit one.""" 25 | form_data = { 26 | "name": "Test Client", 27 | "owner": self.user.pk, 28 | "client_type": "public", 29 | "response_types": [self.code_response_type.pk], 30 | "_redirect_uris": "http://example.com/callback", 31 | } 32 | 33 | form = ClientForm(data=form_data) 34 | self.assertTrue(form.is_valid(), f"Form errors: {form.errors}") 35 | 36 | # The form should generate a client_id 37 | client_id = form.clean_client_id() 38 | self.assertIsNotNone(client_id) 39 | self.assertEqual(len(client_id), 6) 40 | self.assertTrue(client_id.isdigit()) 41 | self.assertTrue(1 <= int(client_id) <= 999999) 42 | 43 | def test_creates_client_with_custom_client_id_preserves_it(self): 44 | """Test that providing a custom client_id preserves it for new clients.""" 45 | # Create and save a client first 46 | client = Client.objects.create( 47 | name="Existing Client", 48 | owner=self.user, 49 | client_type="public", 50 | client_id="custom-client-123", 51 | ) 52 | client.response_types.add(self.code_response_type) 53 | 54 | form_data = { 55 | "name": "Existing Client Updated", 56 | "owner": self.user.pk, 57 | "client_type": "public", 58 | "response_types": [self.code_response_type.pk], 59 | "_redirect_uris": "http://example.com/callback", 60 | "client_id": "custom-client-123", 61 | } 62 | 63 | # Test updating existing client 64 | form = ClientForm(data=form_data, instance=client) 65 | self.assertTrue(form.is_valid(), f"Form errors: {form.errors}") 66 | 67 | # Should return the sanitized version of existing client_id 68 | client_id = form.clean_client_id() 69 | self.assertEqual(client_id, "custom-client-123") 70 | 71 | def test_sanitizes_existing_client_id_with_control_characters(self): 72 | """Test that existing client_id with control characters gets sanitized.""" 73 | # Create a client with problematic client_id 74 | client = Client.objects.create( 75 | name="Problematic Client", 76 | owner=self.user, 77 | client_type="public", 78 | client_id="normalclient", # Start with normal client_id 79 | ) 80 | client.response_types.add(self.code_response_type) 81 | 82 | # Manually set problematic client_id to test sanitization 83 | client.client_id = "client\x00\x01test" # Contains null byte and control char 84 | 85 | form_data = { 86 | "name": "Problematic Client", 87 | "owner": self.user.pk, 88 | "client_type": "public", 89 | "response_types": [self.code_response_type.pk], 90 | "_redirect_uris": "http://example.com/callback", 91 | } 92 | 93 | form = ClientForm(data=form_data, instance=client) 94 | self.assertTrue(form.is_valid(), f"Form errors: {form.errors}") 95 | 96 | # Should return sanitized client_id 97 | client_id = form.clean_client_id() 98 | self.assertEqual(client_id, "clienttest") # Control characters removed 99 | -------------------------------------------------------------------------------- /oidc_provider/locale/ru/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # Sinyawskiy Aleksey , 2025. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2025-02-20 13:01+0200\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: Sinyawskiy Aleksey \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 " 20 | "|| n%100>=20) ? 1 : 2);\n" 21 | 22 | #: lib/claims.py:95 23 | msgid "Basic profile" 24 | msgstr "Базовый профиль" 25 | 26 | #: lib/claims.py:96 27 | msgid "" 28 | "Access to your basic information. Includes names, gender, birthdate and " 29 | "other information." 30 | msgstr "" 31 | "Доступ к персональным данным. Включающим имя, пол, дата рождения " 32 | "и другую информацию." 33 | 34 | #: lib/claims.py:119 35 | msgid "Email" 36 | msgstr "" 37 | 38 | #: lib/claims.py:120 39 | msgid "Access to your email address." 40 | msgstr "Доступ к вашему Email адресу." 41 | 42 | #: lib/claims.py:131 43 | msgid "Phone number" 44 | msgstr "Номер телефона" 45 | 46 | #: lib/claims.py:132 47 | msgid "Access to your phone number." 48 | msgstr "Доступ к вашему номеру телефона." 49 | 50 | #: lib/claims.py:143 51 | msgid "Address information" 52 | msgstr "Адресная информация." 53 | 54 | #: lib/claims.py:144 55 | msgid "" 56 | "Access to your address. Includes country, locality, street and other " 57 | "information." 58 | msgstr "" 59 | "Доступ к вашему адресу. Включая страну, город, улицу и другие " 60 | "данные." 61 | 62 | #: models.py:32 63 | msgid "Name" 64 | msgstr "Название" 65 | 66 | #: models.py:33 67 | msgid "Client Type" 68 | msgstr "Тип клиента" 69 | 70 | #: models.py:33 71 | msgid "" 72 | "Confidential clients are capable of maintaining the confidentiality " 73 | "of their credentials. Public clients are incapable." 74 | msgstr "" 75 | "Конфиденциальные клиенты способны сохранить конфиденциальность " 76 | "своих учетных данных. Публичные не способны." 77 | 78 | #: models.py:34 79 | msgid "Client ID" 80 | msgstr "" 81 | 82 | #: models.py:35 83 | msgid "Client SECRET" 84 | msgstr "" 85 | 86 | #: models.py:36 87 | msgid "Response Type" 88 | msgstr "" 89 | 90 | #: models.py:37 91 | msgid "JWT Algorithm" 92 | msgstr "" 93 | 94 | #: models.py:38 95 | msgid "Date Created" 96 | msgstr "Дата создания" 97 | 98 | #: models.py:40 99 | msgid "Redirect URIs" 100 | msgstr "Список доверенных Redirect URI" 101 | 102 | #: models.py:40 103 | msgid "Enter each URI on a new line." 104 | msgstr "Каждый URI с новой строки." 105 | 106 | #: models.py:43 models.py:70 107 | msgid "Client" 108 | msgstr "Клиентская ИС" 109 | 110 | #: models.py:44 111 | msgid "Clients" 112 | msgstr "КИС" 113 | 114 | #: models.py:69 115 | msgid "User" 116 | msgstr "Пользователь" 117 | 118 | #: models.py:71 119 | msgid "Expiration Date" 120 | msgstr "Срок действия" 121 | 122 | #: models.py:72 123 | msgid "Scopes" 124 | msgstr "Области данных" 125 | 126 | #: models.py:99 127 | msgid "Code" 128 | msgstr "" 129 | 130 | #: models.py:100 131 | msgid "Nonce" 132 | msgstr "" 133 | 134 | #: models.py:101 135 | msgid "Is Authentication?" 136 | msgstr "Это аутентификация?" 137 | 138 | #: models.py:102 139 | msgid "Code Challenge" 140 | msgstr "" 141 | 142 | #: models.py:103 143 | msgid "Code Challenge Method" 144 | msgstr "" 145 | 146 | #: models.py:106 147 | msgid "Authorization Code" 148 | msgstr "Код авторизации" 149 | 150 | #: models.py:107 151 | msgid "Authorization Codes" 152 | msgstr "Коды авторизации" 153 | 154 | #: models.py:112 155 | msgid "Access Token" 156 | msgstr "" 157 | 158 | #: models.py:113 159 | msgid "Refresh Token" 160 | msgstr "" 161 | 162 | #: models.py:114 163 | msgid "ID Token" 164 | msgstr "" 165 | 166 | #: models.py:128 167 | msgid "Token" 168 | msgstr "" 169 | 170 | #: models.py:129 171 | msgid "Tokens" 172 | msgstr "Токены" 173 | 174 | #: models.py:146 175 | msgid "Date Given" 176 | msgstr "Дата выдачи" 177 | 178 | #: models.py:154 179 | msgid "Key" 180 | msgstr "Ключ" 181 | 182 | #: models.py:154 183 | msgid "Paste your private RSA Key here." 184 | msgstr "Добавь сюда приватный ключ RSA." 185 | 186 | #: models.py:157 187 | msgid "RSA Key" 188 | msgstr "Ключ RSA" 189 | 190 | #: models.py:158 191 | msgid "RSA Keys" 192 | msgstr "Ключи RSA" 193 | -------------------------------------------------------------------------------- /oidc_provider/locale/pl/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # Wojciech Bartosiak , 2016. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2016-09-06 13:01+0200\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: Wojciech Bartosiak \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 " 20 | "|| n%100>=20) ? 1 : 2);\n" 21 | 22 | #: lib/claims.py:95 23 | msgid "Basic profile" 24 | msgstr "Profil podstawowy" 25 | 26 | #: lib/claims.py:96 27 | msgid "" 28 | "Access to your basic information. Includes names, gender, birthdate and " 29 | "other information." 30 | msgstr "" 31 | "Dostęp do Twoich podstawowych danych. Zawiera nazwy, płeć, datę urodzenia " 32 | "i inne informacje." 33 | 34 | #: lib/claims.py:119 35 | msgid "Email" 36 | msgstr "" 37 | 38 | #: lib/claims.py:120 39 | msgid "Access to your email address." 40 | msgstr "Dostęp do Twojego adresu email." 41 | 42 | #: lib/claims.py:131 43 | msgid "Phone number" 44 | msgstr "Numer telefonu" 45 | 46 | #: lib/claims.py:132 47 | msgid "Access to your phone number." 48 | msgstr "Dostęp do Twojego numeru telefonu." 49 | 50 | #: lib/claims.py:143 51 | msgid "Address information" 52 | msgstr "Informacje dot. adresu." 53 | 54 | #: lib/claims.py:144 55 | msgid "" 56 | "Access to your address. Includes country, locality, street and other " 57 | "information." 58 | msgstr "" 59 | "Dostęp do Twojego adresu. Zaweira kraj, miejscowość, nazwę ulicy wraz z numerem " 60 | "oraz inne informacje." 61 | 62 | #: models.py:32 63 | msgid "Name" 64 | msgstr "Nazwa" 65 | 66 | #: models.py:33 67 | msgid "Client Type" 68 | msgstr "Typ klienta" 69 | 70 | #: models.py:33 71 | msgid "" 72 | "Confidential clients are capable of maintaining the confidentiality " 73 | "of their credentials. Public clients are incapable." 74 | msgstr "" 75 | "Confidential, klienci tego typu są zdolni do zachowania poufności " 76 | "swoich danych dostępowych. Klienci typu Public nie są w stanie." 77 | 78 | #: models.py:34 79 | msgid "Client ID" 80 | msgstr "" 81 | 82 | #: models.py:35 83 | msgid "Client SECRET" 84 | msgstr "" 85 | 86 | #: models.py:36 87 | msgid "Response Type" 88 | msgstr "" 89 | 90 | #: models.py:37 91 | msgid "JWT Algorithm" 92 | msgstr "" 93 | 94 | #: models.py:38 95 | msgid "Date Created" 96 | msgstr "Data utworzenia" 97 | 98 | #: models.py:40 99 | msgid "Redirect URIs" 100 | msgstr "Lista akceptowalnych przekierowań - URI" 101 | 102 | #: models.py:40 103 | msgid "Enter each URI on a new line." 104 | msgstr "Wprowadź każde URI w osobnej linii." 105 | 106 | #: models.py:43 models.py:70 107 | msgid "Client" 108 | msgstr "Klient" 109 | 110 | #: models.py:44 111 | msgid "Clients" 112 | msgstr "Klienci" 113 | 114 | #: models.py:69 115 | msgid "User" 116 | msgstr "Użytkownik" 117 | 118 | #: models.py:71 119 | msgid "Expiration Date" 120 | msgstr "Data ważności" 121 | 122 | #: models.py:72 123 | msgid "Scopes" 124 | msgstr "" 125 | 126 | #: models.py:99 127 | msgid "Code" 128 | msgstr "" 129 | 130 | #: models.py:100 131 | msgid "Nonce" 132 | msgstr "" 133 | 134 | #: models.py:101 135 | msgid "Is Authentication?" 136 | msgstr "Czy autentykacja?" 137 | 138 | #: models.py:102 139 | msgid "Code Challenge" 140 | msgstr "" 141 | 142 | #: models.py:103 143 | msgid "Code Challenge Method" 144 | msgstr "" 145 | 146 | #: models.py:106 147 | msgid "Authorization Code" 148 | msgstr "Kod autoryzacyjny" 149 | 150 | #: models.py:107 151 | msgid "Authorization Codes" 152 | msgstr "Kody autoryzacyjne" 153 | 154 | #: models.py:112 155 | msgid "Access Token" 156 | msgstr "" 157 | 158 | #: models.py:113 159 | msgid "Refresh Token" 160 | msgstr "" 161 | 162 | #: models.py:114 163 | msgid "ID Token" 164 | msgstr "" 165 | 166 | #: models.py:128 167 | msgid "Token" 168 | msgstr "" 169 | 170 | #: models.py:129 171 | msgid "Tokens" 172 | msgstr "Tokeny" 173 | 174 | #: models.py:146 175 | msgid "Date Given" 176 | msgstr "Data podania" 177 | 178 | #: models.py:154 179 | msgid "Key" 180 | msgstr "Klucz" 181 | 182 | #: models.py:154 183 | msgid "Paste your private RSA Key here." 184 | msgstr "Wklej prywatny klucz RSA tutaj." 185 | 186 | #: models.py:157 187 | msgid "RSA Key" 188 | msgstr "Klucz RSA" 189 | 190 | #: models.py:158 191 | msgid "RSA Keys" 192 | msgstr "Klucze RSA" 193 | -------------------------------------------------------------------------------- /oidc_provider/locale/fr/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2016-07-26 17:15-0300\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n > 1);\n" 20 | 21 | #: lib/claims.py:92 22 | msgid "Basic profile" 23 | msgstr "Profil de base" 24 | 25 | #: lib/claims.py:93 26 | msgid "" 27 | "Access to your basic information. Includes names, gender, birthdate and " 28 | "other information." 29 | msgstr "Accès à vos informations de base. Comprend vos noms, genre, " 30 | "date de naissance et d'autres informations" 31 | 32 | #: lib/claims.py:116 33 | msgid "Email" 34 | msgstr "Courriel" 35 | 36 | #: lib/claims.py:117 37 | msgid "Access to your email address." 38 | msgstr "Accès à votre adresse email" 39 | 40 | #: lib/claims.py:128 41 | msgid "Phone number" 42 | msgstr "Numéro de téléphone" 43 | 44 | #: lib/claims.py:129 45 | msgid "Access to your phone number." 46 | msgstr "Accès à votre numéro de téléphone" 47 | 48 | #: lib/claims.py:140 49 | msgid "Address information" 50 | msgstr "Informations liées à votre adresse" 51 | 52 | #: lib/claims.py:141 53 | msgid "" 54 | "Access to your address. Includes country, locality, street and other " 55 | "information." 56 | msgstr "Accès à votre adresse. Comprend le pays, la ville, la rue " 57 | "et d'autres informations" 58 | 59 | #: models.py:29 60 | msgid "Name" 61 | msgstr "Nom" 62 | 63 | #: models.py:30 64 | msgid "Client Type" 65 | msgstr "Type de client" 66 | 67 | #: models.py:30 68 | msgid "" 69 | "Confidential clients are capable of maintaining the confidentiality " 70 | "of their credentials. Public clients are incapable." 71 | msgstr "" 72 | "Confidentiel les clients sont capable de maintenir la confidentialité " 73 | " des paramètres de connexion. Public les clients n'en sont pas capable" 74 | 75 | #: models.py:31 76 | msgid "Client ID" 77 | msgstr "Identifiant client" 78 | 79 | #: models.py:32 80 | msgid "Client SECRET" 81 | msgstr "Code secret client" 82 | 83 | #: models.py:33 84 | msgid "Response Type" 85 | msgstr "Type de réponse" 86 | 87 | #: models.py:34 88 | msgid "JWT Algorithm" 89 | msgstr "Algorythme JWT" 90 | 91 | #: models.py:35 92 | msgid "Date Created" 93 | msgstr "Date de création" 94 | 95 | #: models.py:37 96 | msgid "Redirect URIs" 97 | msgstr "URIs utilisée pour la redirection" 98 | 99 | #: models.py:37 100 | msgid "Enter each URI on a new line." 101 | msgstr "Entrez chaque URI à la ligne" 102 | 103 | #: models.py:40 models.py:65 104 | msgid "Client" 105 | msgstr "Client" 106 | 107 | #: models.py:41 108 | msgid "Clients" 109 | msgstr "Clients" 110 | 111 | #: models.py:64 112 | msgid "User" 113 | msgstr "Utilisateur" 114 | 115 | #: models.py:66 116 | msgid "Expiration Date" 117 | msgstr "Date d'expiration" 118 | 119 | #: models.py:67 120 | msgid "Scopes" 121 | msgstr "Portées" 122 | 123 | #: models.py:92 124 | msgid "Code" 125 | msgstr "Code" 126 | 127 | #: models.py:93 128 | msgid "Nonce" 129 | msgstr "Valeur de circonstance" 130 | 131 | #: models.py:94 132 | msgid "Is Authentication?" 133 | msgstr "Est authentifié ?" 134 | 135 | #: models.py:95 136 | msgid "Code Challenge" 137 | msgstr "" 138 | 139 | #: models.py:96 140 | msgid "Code Challenge Method" 141 | msgstr "" 142 | 143 | #: models.py:99 144 | msgid "Authorization Code" 145 | msgstr "Code d'authorisation" 146 | 147 | #: models.py:100 148 | msgid "Authorization Codes" 149 | msgstr "Codes d'authorisation" 150 | 151 | #: models.py:105 152 | msgid "Access Token" 153 | msgstr "Jeton d'accès" 154 | 155 | #: models.py:106 156 | msgid "Refresh Token" 157 | msgstr "Jeton de rafraichissement" 158 | 159 | #: models.py:107 160 | msgid "ID Token" 161 | msgstr "Identifiant du jeton" 162 | 163 | #: models.py:117 164 | msgid "Token" 165 | msgstr "Jeton" 166 | 167 | #: models.py:118 168 | msgid "Tokens" 169 | msgstr "Jetons" 170 | 171 | #: models.py:123 172 | msgid "Date Given" 173 | msgstr "Date donnée" 174 | 175 | #: models.py:131 176 | msgid "Key" 177 | msgstr "Clé" 178 | 179 | #: models.py:131 180 | msgid "Paste your private RSA Key here." 181 | msgstr "Collez votre clé privée RSA ici." 182 | 183 | #: models.py:134 184 | msgid "RSA Key" 185 | msgstr "Clé RSA" 186 | 187 | #: models.py:135 188 | msgid "RSA Keys" 189 | msgstr "Clés RSA" 190 | -------------------------------------------------------------------------------- /oidc_provider/lib/endpoints/introspection.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.http import JsonResponse 4 | 5 | from oidc_provider import settings 6 | from oidc_provider.lib.errors import TokenIntrospectionError 7 | from oidc_provider.lib.utils.common import run_processing_hook 8 | from oidc_provider.lib.utils.oauth2 import extract_client_auth 9 | from oidc_provider.lib.utils.sanitization import sanitize_client_id 10 | from oidc_provider.models import Client 11 | from oidc_provider.models import Token 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | INTROSPECTION_SCOPE = "token_introspection" 16 | 17 | 18 | class TokenIntrospectionEndpoint(object): 19 | def __init__(self, request): 20 | self.request = request 21 | self.params = {} 22 | self.token = None 23 | self.id_token = None 24 | self.client = None 25 | self._extract_params() 26 | 27 | def _extract_params(self): 28 | # Introspection only supports POST requests 29 | self.params["token"] = self.request.POST.get("token") 30 | client_id, client_secret = extract_client_auth(self.request) 31 | self.params["client_id"] = sanitize_client_id(client_id) 32 | self.params["client_secret"] = client_secret 33 | 34 | def validate_params(self): 35 | if not (self.params["client_id"] and self.params["client_secret"]): 36 | logger.debug("[Introspection] No client credentials provided") 37 | raise TokenIntrospectionError() 38 | if not self.params["token"]: 39 | logger.debug("[Introspection] No token provided") 40 | raise TokenIntrospectionError() 41 | try: 42 | self.token = Token.objects.get(access_token=self.params["token"]) 43 | except Token.DoesNotExist: 44 | logger.debug("[Introspection] Token does not exist: %s", self.params["token"]) 45 | raise TokenIntrospectionError() 46 | if self.token.has_expired(): 47 | logger.debug("[Introspection] Token is not valid: %s", self.params["token"]) 48 | raise TokenIntrospectionError() 49 | 50 | try: 51 | self.client = Client.objects.get( 52 | client_id=self.params["client_id"], client_secret=self.params["client_secret"] 53 | ) 54 | except Client.DoesNotExist: 55 | logger.debug("[Introspection] No valid client for id: %s", self.params["client_id"]) 56 | raise TokenIntrospectionError() 57 | if INTROSPECTION_SCOPE not in self.client.scope: 58 | logger.debug( 59 | "[Introspection] Client %s does not have introspection scope", 60 | self.params["client_id"], 61 | ) 62 | raise TokenIntrospectionError() 63 | 64 | self.id_token = self.token.id_token 65 | 66 | if settings.get("OIDC_INTROSPECTION_VALIDATE_AUDIENCE_SCOPE"): 67 | if not self.token.id_token: 68 | logger.debug( 69 | "[Introspection] Token not an authentication token: %s", self.params["token"] 70 | ) 71 | raise TokenIntrospectionError() 72 | 73 | audience = self.token.id_token.get("aud") 74 | if not audience: 75 | logger.debug( 76 | "[Introspection] No audience found for token: %s", self.params["token"] 77 | ) 78 | raise TokenIntrospectionError() 79 | 80 | if audience not in self.client.scope: 81 | logger.debug( 82 | "[Introspection] Client %s does not audience scope %s", 83 | self.params["client_id"], 84 | audience, 85 | ) 86 | raise TokenIntrospectionError() 87 | 88 | def create_response_dic(self): 89 | response_dic = {} 90 | if self.id_token: 91 | for k in ("aud", "sub", "exp", "iat", "iss"): 92 | response_dic[k] = self.id_token[k] 93 | response_dic["active"] = True 94 | response_dic["client_id"] = self.token.client.client_id 95 | if settings.get("OIDC_INTROSPECTION_RESPONSE_SCOPE_ENABLE"): 96 | response_dic["scope"] = " ".join(self.token.scope) 97 | response_dic = run_processing_hook( 98 | response_dic, 99 | "OIDC_INTROSPECTION_PROCESSING_HOOK", 100 | client=self.client, 101 | id_token=self.id_token, 102 | ) 103 | 104 | return response_dic 105 | 106 | @classmethod 107 | def response(cls, dic, status=200): 108 | """ 109 | Create and return a response object. 110 | """ 111 | response = JsonResponse(dic, status=status) 112 | response["Cache-Control"] = "no-store" 113 | response["Pragma"] = "no-cache" 114 | 115 | return response 116 | -------------------------------------------------------------------------------- /docs/sections/sessionmanagement.rst: -------------------------------------------------------------------------------- 1 | .. _sessionmanagement: 2 | 3 | Session Management 4 | ################## 5 | 6 | The `OpenID Connect Session Management 1.0 `_ specification complements the core specification by defining how to monitor the End-User's login status at the OpenID Provider on an ongoing basis so that the Relying Party can log out an End-User who has logged out of the OpenID Provider. 7 | 8 | 9 | Setup 10 | ===== 11 | 12 | Somewhere in your Django ``settings.py``:: 13 | 14 | MIDDLEWARE_CLASSES = [ 15 | ... 16 | 'oidc_provider.middleware.SessionManagementMiddleware', 17 | ] 18 | 19 | OIDC_SESSION_MANAGEMENT_ENABLE = True 20 | 21 | 22 | If you're in a multi-server setup, you might also want to add ``OIDC_UNAUTHENTICATED_SESSION_MANAGEMENT_KEY`` to your settings and set it to some random but fixed string. While authenticated clients have a session that can be used to calculate the browser state, there is no such thing for unauthenticated clients. Hence this value. By default a value is generated randomly on startup, so this will be different on each server. To get a consistent value across all servers you should set this yourself. 23 | 24 | 25 | RP-Initiated Logout 26 | =================== 27 | 28 | An RP can notify the OP that the End-User has logged out of the site, and might want to log out of the OP as well. In this case, the RP, after having logged the End-User out of the RP, redirects the End-User's User Agent to the OP's logout endpoint URL. 29 | 30 | This URL is normally obtained via the ``end_session_endpoint`` element of the OP's Discovery response. 31 | 32 | Parameters that are passed as query parameters in the logout request: 33 | 34 | * ``id_token_hint`` 35 | RECOMMENDED. Previously issued ID Token passed to the logout endpoint as a hint about the End-User's current authenticated session with the Client. 36 | * ``post_logout_redirect_uri`` 37 | OPTIONAL. URL to which the RP is requesting that the End-User's User Agent be redirected after a logout has been performed. 38 | 39 | The value must be a valid, encoded URL that has been registered in the list of "Post Logout Redirect URIs" in your Client (RP) page. 40 | * ``state`` 41 | OPTIONAL. Opaque value used by the RP to maintain state between the logout request and the callback to the endpoint specified by the ``post_logout_redirect_uri`` query parameter. 42 | 43 | Example redirect:: 44 | 45 | http://localhost:8000/end-session/?id_token_hint=eyJhbGciOiJSUzI1NiIsImtpZCI6ImQwM...&post_logout_redirect_uri=http%3A%2F%2Frp.example.com%2Flogged-out%2F&state=c91c03ea6c46a86 46 | 47 | **Logout consent prompt** 48 | 49 | The standard defines that the logout flow should be interrupted to prompt the user for consent if the OpenID provider cannot verify that the request was made by the user. 50 | 51 | We enforce this behavior by displaying a logout consent prompt if it detects any of the following conditions: 52 | 53 | * If ``id_token_hint`` is not present or is invalid (we could not validate the client from it). 54 | * If ``post_logout_redirect_uri`` is not registered in the list of "Post Logout Redirect URIs". 55 | 56 | If the user confirms the logout request, we continue the logout flow. To modify the logout consent template create your own ``oidc_provider/end_session_prompt.html``. 57 | 58 | **Other scenarios** 59 | 60 | In some cases, there may be no valid redirect URI for the user after logging out (e.g., the OP could not find a post-logout URI). If the user ends up being logged out, the system will render the ``oidc_provider/end_session_completed.html`` template. 61 | 62 | On the other hand, if the session remains active for any reason, the ``oidc_provider/end_session_failed.html`` template will be used. 63 | 64 | Both templates will receive the ``{{ client }}`` variable in their context. 65 | 66 | Example RP iframe 67 | ================= 68 | 69 | :: 70 | 71 | 72 | 73 | 74 | 75 | RP Iframe 76 | 77 | 78 | 79 | 80 | 112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /oidc_provider/locale/zh_Hans/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #: admin.py:53 7 | #, fuzzy 8 | msgid "" 9 | msgstr "" 10 | "Project-Id-Version: PACKAGE VERSION\n" 11 | "Report-Msgid-Bugs-To: \n" 12 | "POT-Creation-Date: 2025-02-17 17:14+0800\n" 13 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 14 | "Last-Translator: FULL NAME \n" 15 | "Language-Team: LANGUAGE \n" 16 | "Language: \n" 17 | "MIME-Version: 1.0\n" 18 | "Content-Type: text/plain; charset=UTF-8\n" 19 | "Content-Transfer-Encoding: 8bit\n" 20 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 21 | 22 | #: admin.py:58 23 | msgid "Credentials" 24 | msgstr "凭证" 25 | 26 | #: admin.py:61 27 | msgid "Information" 28 | msgstr "信息" 29 | 30 | #: admin.py:64 31 | msgid "Session Management" 32 | msgstr "会话管理" 33 | 34 | #: apps.py:8 35 | msgid "OpenID Connect Provider" 36 | msgstr "OIDC提供方" 37 | 38 | #: lib/claims.py:122 39 | msgid "Basic profile" 40 | msgstr "基础配置" 41 | 42 | #: lib/claims.py:123 43 | msgid "" 44 | "Access to your basic information. Includes names, gender, birthdate and " 45 | "other information." 46 | msgstr "获取你的基础信息,包含用户名、性别、生日和其他信息" 47 | 48 | #: lib/claims.py:150 49 | msgid "Email" 50 | msgstr "电子邮件" 51 | 52 | #: lib/claims.py:151 53 | msgid "Access to your email address." 54 | msgstr "获取你的电子邮件地址" 55 | 56 | #: lib/claims.py:163 57 | msgid "Phone number" 58 | msgstr "联系电话" 59 | 60 | #: lib/claims.py:164 61 | msgid "Access to your phone number." 62 | msgstr "获取你的联系电话" 63 | 64 | #: lib/claims.py:176 65 | msgid "Address information" 66 | msgstr "联系地址" 67 | 68 | #: lib/claims.py:177 69 | msgid "" 70 | "Access to your address. Includes country, locality, street and other " 71 | "information." 72 | msgstr "获取你的联系地址,包含国家、地理位置、街道和其他信息" 73 | 74 | #: models.py:44 75 | msgid "Response Type Value" 76 | msgstr "响应类型值" 77 | 78 | #: models.py:58 79 | msgid "Name" 80 | msgstr "名称" 81 | 82 | #: models.py:60 83 | msgid "Owner" 84 | msgstr "拥有者" 85 | 86 | #: models.py:66 87 | msgid "Client Type" 88 | msgstr "客户端类型" 89 | 90 | #: models.py:67 91 | msgid "" 92 | "Confidential clients are capable of maintaining the confidentiality " 93 | "of their credentials. Public clients are incapable." 94 | msgstr "" 95 | "机密 客户端能够保证凭证信息的机密性 公共 客户端无法保证凭证信息" 96 | "的机密性" 97 | 98 | #: models.py:69 99 | msgid "Client ID" 100 | msgstr "客户端ID" 101 | 102 | #: models.py:70 103 | msgid "Client SECRET" 104 | msgstr "客户端密钥" 105 | 106 | #: models.py:76 107 | msgid "JWT Algorithm" 108 | msgstr "JWT算法" 109 | 110 | #: models.py:77 111 | msgid "Algorithm used to encode ID Tokens." 112 | msgstr "用于加密身份令牌的算法" 113 | 114 | #: models.py:78 115 | msgid "Date Created" 116 | msgstr "创建日期" 117 | 118 | #: models.py:80 119 | msgid "Website URL" 120 | msgstr "网页URL" 121 | 122 | #: models.py:85 123 | msgid "Terms URL" 124 | msgstr "团队URL" 125 | 126 | #: models.py:86 127 | msgid "External reference to the privacy policy of the client." 128 | msgstr "额外的客户端隐私政策" 129 | 130 | #: models.py:88 131 | msgid "Contact Email" 132 | msgstr "联系邮件" 133 | 134 | #: models.py:90 135 | msgid "Logo Image" 136 | msgstr "Logo" 137 | 138 | #: models.py:93 139 | msgid "Reuse Consent?" 140 | msgstr "复用授权" 141 | 142 | #: models.py:94 143 | msgid "" 144 | "If enabled, server will save the user consent given to a specific client, so " 145 | "that user won't be prompted for the same authorization multiple times." 146 | msgstr "如果启用,服务器会记录用户对客户端的授权信息,用户不需要每次都授权" 147 | 148 | #: models.py:98 149 | msgid "Require Consent?" 150 | msgstr "需要用户授权?" 151 | 152 | #: models.py:99 153 | msgid "If disabled, the Server will NEVER ask the user for consent." 154 | msgstr "如果关闭,服务器将不会需要用户授权" 155 | 156 | #: models.py:101 157 | msgid "Redirect URIs" 158 | msgstr "重定向URI" 159 | 160 | #: models.py:102 models.py:107 161 | msgid "Enter each URI on a new line." 162 | msgstr "一行一个URI" 163 | 164 | #: models.py:106 165 | msgid "Post Logout Redirect URIs" 166 | msgstr "登出后的重定向URI" 167 | 168 | #: models.py:111 models.py:164 169 | msgid "Scopes" 170 | msgstr "授权范围" 171 | 172 | #: models.py:112 173 | msgid "Specifies the authorized scope values for the client app." 174 | msgstr "指定客户端的可用授权范围" 175 | 176 | #: models.py:115 models.py:162 177 | msgid "Client" 178 | msgstr "客户端" 179 | 180 | #: models.py:116 181 | msgid "Clients" 182 | msgstr "客户端" 183 | 184 | #: models.py:163 185 | msgid "Expiration Date" 186 | msgstr "失效日期" 187 | 188 | #: models.py:187 models.py:206 models.py:242 189 | msgid "User" 190 | msgstr "用户" 191 | 192 | #: models.py:188 193 | msgid "Code" 194 | msgstr "授权码" 195 | 196 | #: models.py:189 197 | msgid "Nonce" 198 | msgstr "随机字符串" 199 | 200 | #: models.py:190 201 | msgid "Is Authentication?" 202 | msgstr "是否为认证?" 203 | 204 | #: models.py:191 205 | msgid "Code Challenge" 206 | msgstr "授权码验证" 207 | 208 | #: models.py:193 209 | msgid "Code Challenge Method" 210 | msgstr "授权码验证方式" 211 | 212 | #: models.py:196 213 | msgid "Authorization Code" 214 | msgstr "授权码" 215 | 216 | #: models.py:197 217 | msgid "Authorization Codes" 218 | msgstr "授权码" 219 | 220 | #: models.py:207 221 | msgid "Access Token" 222 | msgstr "访问令牌" 223 | 224 | #: models.py:208 225 | msgid "Refresh Token" 226 | msgstr "刷新令牌" 227 | 228 | #: models.py:209 229 | msgid "ID Token" 230 | msgstr "身份令牌" 231 | 232 | #: models.py:212 233 | msgid "Token" 234 | msgstr "令牌" 235 | 236 | #: models.py:213 237 | msgid "Tokens" 238 | msgstr "令牌" 239 | 240 | #: models.py:243 241 | msgid "Date Given" 242 | msgstr "授予日期" 243 | 244 | #: models.py:252 245 | msgid "Key" 246 | msgstr "私钥" 247 | 248 | #: models.py:252 249 | msgid "Paste your private RSA Key here." 250 | msgstr "在此粘贴你的RSA私钥" 251 | 252 | #: models.py:256 253 | msgid "RSA Key" 254 | msgstr "RSA密钥" 255 | 256 | #: models.py:257 257 | msgid "RSA Keys" 258 | msgstr "RSA密钥" 259 | -------------------------------------------------------------------------------- /oidc_provider/tests/app/utils.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | 4 | import django 5 | from django.contrib.auth.backends import ModelBackend 6 | 7 | try: 8 | from urlparse import parse_qs 9 | from urlparse import urlsplit 10 | except ImportError: 11 | from urllib.parse import parse_qs 12 | from urllib.parse import urlsplit 13 | 14 | from django.contrib.auth.models import User 15 | from django.utils import timezone 16 | 17 | from oidc_provider.lib.claims import ScopeClaims 18 | from oidc_provider.models import Client 19 | from oidc_provider.models import Code 20 | from oidc_provider.models import ResponseType 21 | from oidc_provider.models import Token 22 | 23 | FAKE_NONCE = "cb584e44c43ed6bd0bc2d9c7e242837d" 24 | FAKE_RANDOM_STRING = "".join( 25 | random.choice(string.ascii_uppercase + string.digits) for _ in range(32) 26 | ) 27 | FAKE_CODE_CHALLENGE = "YlYXEqXuRm-Xgi2BOUiK50JW1KsGTX6F1TDnZSC8VTg" 28 | FAKE_CODE_VERIFIER = "SmxGa0XueyNh5bDgTcSrqzAh2_FmXEqU8kDT6CuXicw" 29 | FAKE_USER_PASSWORD = "1234" 30 | 31 | 32 | def create_fake_user(): 33 | """ 34 | Create a test user. 35 | 36 | Return a User object. 37 | """ 38 | user = User() 39 | user.username = "johndoe" 40 | user.email = "johndoe@example.com" 41 | user.first_name = "John" 42 | user.last_name = "Doe" 43 | user.set_password(FAKE_USER_PASSWORD) 44 | 45 | user.save() 46 | 47 | return user 48 | 49 | 50 | def create_fake_client(response_type, is_public=False, require_consent=True): 51 | """ 52 | Create a test client, response_type argument MUST be: 53 | 'code', 'id_token' or 'id_token token'. 54 | 55 | Return a Client object. 56 | """ 57 | client = Client() 58 | client.name = "Some Client" 59 | client.client_id = str(random.randint(1, 999999)).zfill(6) 60 | if is_public: 61 | client.client_type = "public" 62 | client.client_secret = "" 63 | else: 64 | client.client_secret = str(random.randint(1, 999999)).zfill(6) 65 | client.redirect_uris = ["http://example.com/"] 66 | client.require_consent = require_consent 67 | client.scope = ["openid", "email"] 68 | client.save() 69 | 70 | # check if response_type is a string in a python 2 and 3 compatible way 71 | if isinstance(response_type, ("".__class__, "".__class__)): 72 | response_type = (response_type,) 73 | for value in response_type: 74 | client.response_types.add(ResponseType.objects.get(value=value)) 75 | 76 | return client 77 | 78 | 79 | def create_fake_token(user, scopes, client): 80 | expires_at = timezone.now() + timezone.timedelta(seconds=60) 81 | token = Token(user=user, client=client, expires_at=expires_at) 82 | token.scope = scopes 83 | 84 | token.save() 85 | 86 | return token 87 | 88 | 89 | def is_code_valid(url, user, client): 90 | """ 91 | Check if the code inside the url is valid. Supporting both query string and fragment. 92 | """ 93 | try: 94 | parsed = urlsplit(url) 95 | params = parse_qs(parsed.query or parsed.fragment) 96 | code = params["code"][0] 97 | code = Code.objects.get(code=code) 98 | is_code_ok = (code.client == client) and (code.user == user) 99 | except Exception: 100 | is_code_ok = False 101 | 102 | return is_code_ok 103 | 104 | 105 | def userinfo(claims, user): 106 | """ 107 | Fake function for setting OIDC_USERINFO. 108 | """ 109 | claims["given_name"] = "John" 110 | claims["family_name"] = "Doe" 111 | claims["name"] = "{0} {1}".format(claims["given_name"], claims["family_name"]) 112 | claims["email"] = user.email 113 | claims["email_verified"] = True 114 | claims["address"]["country"] = "Argentina" 115 | return claims 116 | 117 | 118 | class FakeScopeClaims(ScopeClaims): 119 | info_pizza = ( 120 | "Pizza", 121 | "Some description for the scope.", 122 | ) 123 | 124 | def scope_pizza(self): 125 | dic = { 126 | "pizza": "Margherita", 127 | } 128 | return dic 129 | 130 | 131 | def fake_sub_generator(user): 132 | """ 133 | Fake function for setting OIDC_IDTOKEN_SUB_GENERATOR. 134 | """ 135 | return user.email 136 | 137 | 138 | def fake_idtoken_processing_hook(id_token, user, **kwargs): 139 | """ 140 | Fake function for inserting some keys into token. Testing OIDC_IDTOKEN_PROCESSING_HOOK. 141 | """ 142 | id_token["test_idtoken_processing_hook"] = FAKE_RANDOM_STRING 143 | id_token["test_idtoken_processing_hook_user_email"] = user.email 144 | return id_token 145 | 146 | 147 | def fake_idtoken_processing_hook2(id_token, user, **kwargs): 148 | """ 149 | Fake function for inserting some keys into token. 150 | Testing OIDC_IDTOKEN_PROCESSING_HOOK - tuple or list as param 151 | """ 152 | id_token["test_idtoken_processing_hook2"] = FAKE_RANDOM_STRING 153 | id_token["test_idtoken_processing_hook_user_email2"] = user.email 154 | return id_token 155 | 156 | 157 | def fake_idtoken_processing_hook3(id_token, user, token, **kwargs): 158 | """ 159 | Fake function for checking scope is passed to processing hook. 160 | """ 161 | id_token["scope_of_token_passed_to_processing_hook"] = token.scope 162 | return id_token 163 | 164 | 165 | def fake_idtoken_processing_hook4(id_token, user, **kwargs): 166 | """ 167 | Fake function for checking kwargs passed to processing hook. 168 | """ 169 | id_token["kwargs_passed_to_processing_hook"] = { 170 | key: repr(value) for (key, value) in kwargs.items() 171 | } 172 | return id_token 173 | 174 | 175 | def fake_introspection_processing_hook(response_dict, client, id_token): 176 | response_dict["test_introspection_processing_hook"] = FAKE_RANDOM_STRING 177 | return response_dict 178 | 179 | 180 | class TestAuthBackend: 181 | def authenticate(self, *args, **kwargs): 182 | if django.VERSION[0] >= 2 or (django.VERSION[0] == 1 and django.VERSION[1] >= 11): 183 | assert len(args) > 0 and args[0] 184 | return ModelBackend().authenticate(*args, **kwargs) 185 | -------------------------------------------------------------------------------- /oidc_provider/tests/cases/test_userinfo_endpoint.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import timedelta 3 | 4 | try: 5 | from urllib.parse import urlencode 6 | except ImportError: 7 | from urllib import urlencode 8 | 9 | try: 10 | from django.urls import reverse 11 | except ImportError: 12 | from django.core.urlresolvers import reverse 13 | from django.test import RequestFactory 14 | from django.test import TestCase 15 | from django.utils import timezone 16 | 17 | from oidc_provider.lib.utils.token import create_id_token 18 | from oidc_provider.lib.utils.token import create_token 19 | from oidc_provider.tests.app.utils import FAKE_NONCE 20 | from oidc_provider.tests.app.utils import create_fake_client 21 | from oidc_provider.tests.app.utils import create_fake_user 22 | from oidc_provider.views import userinfo 23 | 24 | 25 | class UserInfoTestCase(TestCase): 26 | def setUp(self): 27 | self.factory = RequestFactory() 28 | self.user = create_fake_user() 29 | self.client = create_fake_client(response_type="code") 30 | 31 | def _create_token(self, extra_scope=None): 32 | """ 33 | Generate a valid token. 34 | """ 35 | if extra_scope is None: 36 | extra_scope = [] 37 | scope = ["openid", "email"] + extra_scope 38 | 39 | token = create_token(user=self.user, client=self.client, scope=scope) 40 | 41 | id_token_dic = create_id_token( 42 | token=token, 43 | user=self.user, 44 | aud=self.client.client_id, 45 | nonce=FAKE_NONCE, 46 | scope=scope, 47 | ) 48 | 49 | token.id_token = id_token_dic 50 | token.save() 51 | 52 | return token 53 | 54 | def _post_request(self, access_token, schema="Bearer"): 55 | """ 56 | Makes a request to the userinfo endpoint by sending the 57 | `post_data` parameters using the 'multipart/form-data' 58 | format. 59 | """ 60 | url = reverse("oidc_provider:userinfo") 61 | 62 | request = self.factory.post(url, data={}, content_type="multipart/form-data") 63 | 64 | request.META["HTTP_AUTHORIZATION"] = schema + " " + access_token 65 | 66 | response = userinfo(request) 67 | 68 | return response 69 | 70 | def test_response_with_valid_token(self): 71 | token = self._create_token() 72 | 73 | # Test a valid request to the userinfo endpoint. 74 | response = self._post_request(token.access_token) 75 | 76 | self.assertEqual(response.status_code, 200) 77 | self.assertEqual(bool(response.content), True) 78 | 79 | def test_response_with_valid_token_lowercase_bearer(self): 80 | """ 81 | Some clients expect to be able to pass the token_type value from the token endpoint 82 | ("bearer") back to the identity provider unchanged. 83 | """ 84 | token = self._create_token() 85 | 86 | response = self._post_request(token.access_token, schema="bearer") 87 | 88 | self.assertEqual(response.status_code, 200) 89 | self.assertEqual(bool(response.content), True) 90 | 91 | def test_response_with_expired_token(self): 92 | token = self._create_token() 93 | 94 | # Make token expired. 95 | token.expires_at = timezone.now() - timedelta(hours=1) 96 | token.save() 97 | 98 | response = self._post_request(token.access_token) 99 | 100 | self.assertEqual(response.status_code, 401) 101 | 102 | try: 103 | is_header_field_ok = "invalid_token" in response["WWW-Authenticate"] 104 | except KeyError: 105 | is_header_field_ok = False 106 | self.assertEqual(is_header_field_ok, True) 107 | 108 | def test_response_with_invalid_scope(self): 109 | token = self._create_token() 110 | 111 | token.scope = ["otherone"] 112 | token.save() 113 | 114 | response = self._post_request(token.access_token) 115 | 116 | self.assertEqual(response.status_code, 403) 117 | 118 | try: 119 | is_header_field_ok = "insufficient_scope" in response["WWW-Authenticate"] 120 | except KeyError: 121 | is_header_field_ok = False 122 | self.assertEqual(is_header_field_ok, True) 123 | 124 | def test_accesstoken_query_string_parameter(self): 125 | """ 126 | Make a GET request to the UserInfo Endpoint by sending access_token 127 | as query string parameter. 128 | """ 129 | token = self._create_token() 130 | 131 | url = ( 132 | reverse("oidc_provider:userinfo") 133 | + "?" 134 | + urlencode( 135 | { 136 | "access_token": token.access_token, 137 | } 138 | ) 139 | ) 140 | 141 | request = self.factory.get(url) 142 | response = userinfo(request) 143 | 144 | self.assertEqual(response.status_code, 200) 145 | self.assertEqual(bool(response.content), True) 146 | 147 | def test_user_claims_in_response(self): 148 | token = self._create_token(extra_scope=["profile"]) 149 | response = self._post_request(token.access_token) 150 | response_dic = json.loads(response.content.decode("utf-8")) 151 | 152 | self.assertEqual(response.status_code, 200) 153 | self.assertEqual(bool(response.content), True) 154 | self.assertIn("given_name", response_dic, msg='"given_name" claim should be in response.') 155 | self.assertNotIn("profile", response_dic, msg='"profile" claim should not be in response.') 156 | 157 | # Now adding `address` scope. 158 | token = self._create_token(extra_scope=["profile", "address"]) 159 | response = self._post_request(token.access_token) 160 | response_dic = json.loads(response.content.decode("utf-8")) 161 | 162 | self.assertIn("address", response_dic, msg='"address" claim should be in response.') 163 | self.assertIn( 164 | "country", response_dic["address"], msg='"country" claim should be in response.' 165 | ) 166 | -------------------------------------------------------------------------------- /oidc_provider/lib/utils/common.py: -------------------------------------------------------------------------------- 1 | from hashlib import sha224 2 | 3 | import django 4 | from django.http import HttpResponse 5 | from django.utils.cache import patch_vary_headers 6 | 7 | from oidc_provider import settings 8 | 9 | if django.VERSION >= (1, 11): 10 | from django.urls import reverse 11 | else: 12 | from django.core.urlresolvers import reverse 13 | 14 | 15 | def redirect(uri): 16 | """ 17 | Custom Response object for redirecting to a Non-HTTP url scheme. 18 | """ 19 | response = HttpResponse("", status=302) 20 | response["Location"] = uri 21 | return response 22 | 23 | 24 | def get_site_url(site_url=None, request=None): 25 | """ 26 | Construct the site url. 27 | 28 | Orders to decide site url: 29 | 1. valid `site_url` parameter 30 | 2. valid `SITE_URL` in settings 31 | 3. construct from `request` object 32 | """ 33 | site_url = site_url or settings.get("SITE_URL") 34 | if site_url: 35 | return site_url 36 | elif request: 37 | return "{}://{}".format(request.scheme, request.get_host()) 38 | else: 39 | raise Exception( 40 | "Either pass `site_url`, or set `SITE_URL` in settings, or pass `request` object." 41 | ) 42 | 43 | 44 | def get_issuer(site_url=None, request=None): 45 | """ 46 | Construct the issuer full url. Basically is the site url with some path 47 | appended. 48 | """ 49 | site_url = get_site_url(site_url=site_url, request=request) 50 | path = reverse("oidc_provider:provider-info").split("/.well-known/openid-configuration")[0] 51 | issuer = site_url + path 52 | 53 | return str(issuer) 54 | 55 | 56 | def default_userinfo(claims, user): 57 | """ 58 | Default function for setting OIDC_USERINFO. 59 | `claims` is a dict that contains all the OIDC standard claims. 60 | """ 61 | return claims 62 | 63 | 64 | def default_sub_generator(user): 65 | """ 66 | Default function for setting OIDC_IDTOKEN_SUB_GENERATOR. 67 | """ 68 | return str(user.id) 69 | 70 | 71 | def default_after_userlogin_hook(request, user, client): 72 | """ 73 | Default function for setting OIDC_AFTER_USERLOGIN_HOOK. 74 | """ 75 | return None 76 | 77 | 78 | def default_after_end_session_hook( 79 | request, id_token=None, post_logout_redirect_uri=None, state=None, client=None, next_page=None 80 | ): 81 | """ 82 | Default function for setting OIDC_AFTER_END_SESSION_HOOK. 83 | 84 | :param request: Django request object 85 | :type request: django.http.HttpRequest 86 | 87 | :param id_token: token passed by `id_token_hint` url query param. 88 | Do NOT trust this param or validate token 89 | :type id_token: str 90 | 91 | :param post_logout_redirect_uri: redirect url from url query param. 92 | Do NOT trust this param 93 | :type post_logout_redirect_uri: str 94 | 95 | :param state: state param from url query params 96 | :type state: str 97 | 98 | :param client: If id_token has `aud` param and associated Client exists, 99 | this is an instance of it - do NOT trust this param 100 | :type client: oidc_provider.models.Client 101 | 102 | :param next_page: calculated next_page redirection target 103 | :type next_page: str 104 | :return: 105 | """ 106 | return None 107 | 108 | 109 | def default_idtoken_processing_hook(id_token, user, token, request, **kwargs): 110 | """ 111 | Hook to perform some additional actions to `id_token` dictionary just before serialization. 112 | 113 | :param id_token: dictionary contains values that going to be serialized into `id_token` 114 | :type id_token: dict 115 | 116 | :param user: user for whom id_token is generated 117 | :type user: User 118 | 119 | :param token: the Token object created for the authentication request 120 | :type token: oidc_provider.models.Token 121 | 122 | :param request: the request initiating this ID token processing 123 | :type request: django.http.HttpRequest 124 | 125 | :return: custom modified dictionary of values for `id_token` 126 | :rtype: dict 127 | """ 128 | return id_token 129 | 130 | 131 | def default_introspection_processing_hook(introspection_response, client, id_token): 132 | """ 133 | Hook to customise the returned data from the token introspection endpoint 134 | :param introspection_response: 135 | :param client: 136 | :param id_token: 137 | :return: 138 | """ 139 | return introspection_response 140 | 141 | 142 | def get_browser_state_or_default(request): 143 | """ 144 | Determine value to use as session state. 145 | """ 146 | key = request.session.session_key or settings.get("OIDC_UNAUTHENTICATED_SESSION_MANAGEMENT_KEY") 147 | return sha224(key.encode("utf-8")).hexdigest() 148 | 149 | 150 | def run_processing_hook(subject, hook_settings_name, **kwargs): 151 | processing_hooks = settings.get(hook_settings_name) 152 | if not isinstance(processing_hooks, (list, tuple)): 153 | processing_hooks = [processing_hooks] 154 | 155 | for hook_string in processing_hooks: 156 | hook = settings.import_from_str(hook_string) 157 | subject = hook(subject, **kwargs) 158 | 159 | return subject 160 | 161 | 162 | def cors_allow_any(request, response): 163 | """ 164 | Add headers to permit CORS requests from any origin, with or without credentials, 165 | with any headers. 166 | """ 167 | origin = request.META.get("HTTP_ORIGIN") 168 | if not origin: 169 | return response 170 | 171 | # From the CORS spec: The string "*" cannot be used for a resource that supports credentials. 172 | response["Access-Control-Allow-Origin"] = origin 173 | patch_vary_headers(response, ["Origin"]) 174 | response["Access-Control-Allow-Credentials"] = "true" 175 | 176 | if request.method == "OPTIONS": 177 | if "HTTP_ACCESS_CONTROL_REQUEST_HEADERS" in request.META: 178 | response["Access-Control-Allow-Headers"] = request.META[ 179 | "HTTP_ACCESS_CONTROL_REQUEST_HEADERS" 180 | ] 181 | response["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS" 182 | 183 | return response 184 | -------------------------------------------------------------------------------- /oidc_provider/tests/cases/test_introspection_endpoint.py: -------------------------------------------------------------------------------- 1 | import random 2 | import time 3 | from unittest.mock import patch 4 | 5 | try: 6 | from urllib.parse import urlencode 7 | except ImportError: 8 | from urllib import urlencode 9 | 10 | from django.core.management import call_command 11 | from django.test import RequestFactory 12 | from django.test import TestCase 13 | from django.test import override_settings 14 | from django.utils import timezone 15 | from django.utils.encoding import force_str 16 | 17 | try: 18 | from django.urls import reverse 19 | except ImportError: 20 | from django.core.urlresolvers import reverse 21 | 22 | from oidc_provider.lib.utils.token import create_id_token 23 | from oidc_provider.tests.app.utils import FAKE_RANDOM_STRING 24 | from oidc_provider.tests.app.utils import create_fake_client 25 | from oidc_provider.tests.app.utils import create_fake_token 26 | from oidc_provider.tests.app.utils import create_fake_user 27 | from oidc_provider.views import TokenIntrospectionView 28 | 29 | 30 | class IntrospectionTestCase(TestCase): 31 | def setUp(self): 32 | call_command("creatersakey") 33 | self.factory = RequestFactory() 34 | self.user = create_fake_user() 35 | self.aud = "testaudience" 36 | self.client = create_fake_client(response_type="id_token token") 37 | self.resource = create_fake_client(response_type="id_token token") 38 | self.resource.scope = ["token_introspection", self.aud] 39 | self.resource.save() 40 | self.token = create_fake_token(self.user, self.client.scope, self.client) 41 | self.token.access_token = str(random.randint(1, 999999)).zfill(6) 42 | self.now = time.time() 43 | with patch("oidc_provider.lib.utils.token.time.time") as time_func: 44 | time_func.return_value = self.now 45 | self.token.id_token = create_id_token(self.token, self.user, self.aud) 46 | self.token.save() 47 | 48 | def _assert_inactive(self, response): 49 | self.assertEqual(response.status_code, 200) 50 | self.assertJSONEqual(force_str(response.content), {"active": False}) 51 | 52 | def _assert_active(self, response, **kwargs): 53 | self.assertEqual(response.status_code, 200) 54 | expected_content = { 55 | "active": True, 56 | "aud": self.aud, 57 | "client_id": self.client.client_id, 58 | "sub": str(self.user.pk), 59 | "iat": int(self.now), 60 | "exp": int(self.now + 600), 61 | "iss": "http://localhost:8000/openid", 62 | } 63 | expected_content.update(kwargs) 64 | self.assertJSONEqual(force_str(response.content), expected_content) 65 | 66 | def _make_request(self, **kwargs): 67 | url = reverse("oidc_provider:token-introspection") 68 | data = { 69 | "client_id": kwargs.get("client_id", self.resource.client_id), 70 | "client_secret": kwargs.get("client_secret", self.resource.client_secret), 71 | "token": kwargs.get("access_token", self.token.access_token), 72 | } 73 | 74 | request = self.factory.post( 75 | url, data=urlencode(data), content_type="application/x-www-form-urlencoded" 76 | ) 77 | 78 | return TokenIntrospectionView.as_view()(request) 79 | 80 | def test_no_client_params_returns_inactive(self): 81 | response = self._make_request(client_id="") 82 | self._assert_inactive(response) 83 | 84 | def test_no_client_secret_returns_inactive(self): 85 | response = self._make_request(client_secret="") 86 | self._assert_inactive(response) 87 | 88 | def test_invalid_client_returns_inactive(self): 89 | response = self._make_request(client_id="invalid") 90 | self._assert_inactive(response) 91 | 92 | def test_token_not_found_returns_inactive(self): 93 | response = self._make_request(access_token="invalid") 94 | self._assert_inactive(response) 95 | 96 | def test_scope_no_audience_returns_inactive(self): 97 | self.resource.scope = ["token_introspection"] 98 | self.resource.save() 99 | response = self._make_request() 100 | self._assert_inactive(response) 101 | 102 | def test_token_expired_returns_inactive(self): 103 | self.token.expires_at = timezone.now() - timezone.timedelta(seconds=60) 104 | self.token.save() 105 | response = self._make_request() 106 | self._assert_inactive(response) 107 | 108 | def test_valid_request_returns_default_properties(self): 109 | response = self._make_request() 110 | self._assert_active(response) 111 | 112 | @override_settings( 113 | OIDC_INTROSPECTION_PROCESSING_HOOK="oidc_provider.tests.app.utils.fake_introspection_processing_hook" # noqa 114 | ) 115 | def test_custom_introspection_hook_called_on_valid_request(self): 116 | response = self._make_request() 117 | self._assert_active(response, test_introspection_processing_hook=FAKE_RANDOM_STRING) 118 | 119 | @override_settings(OIDC_INTROSPECTION_VALIDATE_AUDIENCE_SCOPE=False) 120 | def test_disable_audience_validation(self): 121 | self.resource.scope = ["token_introspection"] 122 | self.resource.save() 123 | response = self._make_request() 124 | self._assert_active(response) 125 | 126 | @override_settings(OIDC_INTROSPECTION_VALIDATE_AUDIENCE_SCOPE=False) 127 | def test_valid_client_grant_token_without_aud_validation(self): 128 | self.token.id_token = None # client_credentials tokens do not have id_token 129 | self.token.save() 130 | self.resource.scope = ["token_introspection"] 131 | self.resource.save() 132 | response = self._make_request() 133 | self.assertEqual(response.status_code, 200) 134 | self.assertJSONEqual( 135 | force_str(response.content), 136 | { 137 | "active": True, 138 | "client_id": self.client.client_id, 139 | }, 140 | ) 141 | 142 | @override_settings(OIDC_INTROSPECTION_RESPONSE_SCOPE_ENABLE=True) 143 | def test_enable_scope(self): 144 | response = self._make_request() 145 | self._assert_active(response, scope="openid email") 146 | -------------------------------------------------------------------------------- /oidc_provider/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.conf import settings 5 | from django.db import migrations 6 | from django.db import models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("auth", "0001_initial"), 12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="Client", 18 | fields=[ 19 | ( 20 | "id", 21 | models.AutoField( 22 | verbose_name="ID", serialize=False, auto_created=True, primary_key=True 23 | ), 24 | ), 25 | ("name", models.CharField(default=b"", max_length=100)), 26 | ("client_id", models.CharField(unique=True, max_length=255)), 27 | ("client_secret", models.CharField(unique=True, max_length=255)), 28 | ( 29 | "response_type", 30 | models.CharField( 31 | max_length=30, 32 | choices=[ 33 | (b"code", b"code (Authorization Code Flow)"), 34 | (b"id_token", b"id_token (Implicit Flow)"), 35 | (b"id_token token", b"id_token token (Implicit Flow)"), 36 | ], 37 | ), 38 | ), 39 | ("_redirect_uris", models.TextField(default=b"")), 40 | ], 41 | options={}, 42 | bases=(models.Model,), 43 | ), 44 | migrations.CreateModel( 45 | name="Code", 46 | fields=[ 47 | ( 48 | "id", 49 | models.AutoField( 50 | verbose_name="ID", serialize=False, auto_created=True, primary_key=True 51 | ), 52 | ), 53 | ("expires_at", models.DateTimeField()), 54 | ("_scope", models.TextField(default=b"")), 55 | ("code", models.CharField(unique=True, max_length=255)), 56 | ("client", models.ForeignKey(to="oidc_provider.Client", on_delete=models.CASCADE)), 57 | ], 58 | options={ 59 | "abstract": False, 60 | }, 61 | bases=(models.Model,), 62 | ), 63 | migrations.CreateModel( 64 | name="Token", 65 | fields=[ 66 | ( 67 | "id", 68 | models.AutoField( 69 | verbose_name="ID", serialize=False, auto_created=True, primary_key=True 70 | ), 71 | ), 72 | ("expires_at", models.DateTimeField()), 73 | ("_scope", models.TextField(default=b"")), 74 | ("access_token", models.CharField(unique=True, max_length=255)), 75 | ("_id_token", models.TextField()), 76 | ("client", models.ForeignKey(to="oidc_provider.Client", on_delete=models.CASCADE)), 77 | ], 78 | options={ 79 | "abstract": False, 80 | }, 81 | bases=(models.Model,), 82 | ), 83 | migrations.CreateModel( 84 | name="UserInfo", 85 | fields=[ 86 | ( 87 | "user", 88 | models.OneToOneField( 89 | primary_key=True, 90 | serialize=False, 91 | to=settings.AUTH_USER_MODEL, 92 | on_delete=models.CASCADE, 93 | ), 94 | ), 95 | ("given_name", models.CharField(max_length=255, null=True, blank=True)), 96 | ("family_name", models.CharField(max_length=255, null=True, blank=True)), 97 | ("middle_name", models.CharField(max_length=255, null=True, blank=True)), 98 | ("nickname", models.CharField(max_length=255, null=True, blank=True)), 99 | ( 100 | "gender", 101 | models.CharField( 102 | max_length=100, null=True, choices=[(b"F", b"Female"), (b"M", b"Male")] 103 | ), 104 | ), 105 | ("birthdate", models.DateField(null=True)), 106 | ("zoneinfo", models.CharField(default=b"", max_length=100, null=True, blank=True)), 107 | ("preferred_username", models.CharField(max_length=255, null=True, blank=True)), 108 | ("profile", models.URLField(default=b"", null=True, blank=True)), 109 | ("picture", models.URLField(default=b"", null=True, blank=True)), 110 | ("website", models.URLField(default=b"", null=True, blank=True)), 111 | ("email_verified", models.NullBooleanField(default=False)), 112 | ("locale", models.CharField(max_length=100, null=True, blank=True)), 113 | ("phone_number", models.CharField(max_length=255, null=True, blank=True)), 114 | ("phone_number_verified", models.NullBooleanField(default=False)), 115 | ("address_street_address", models.CharField(max_length=255, null=True, blank=True)), 116 | ("address_locality", models.CharField(max_length=255, null=True, blank=True)), 117 | ("address_region", models.CharField(max_length=255, null=True, blank=True)), 118 | ("address_postal_code", models.CharField(max_length=255, null=True, blank=True)), 119 | ("address_country", models.CharField(max_length=255, null=True, blank=True)), 120 | ("updated_at", models.DateTimeField(auto_now=True, null=True)), 121 | ], 122 | options={}, 123 | bases=(models.Model,), 124 | ), 125 | migrations.AddField( 126 | model_name="token", 127 | name="user", 128 | field=models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE), 129 | preserve_default=True, 130 | ), 131 | migrations.AddField( 132 | model_name="code", 133 | name="user", 134 | field=models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE), 135 | preserve_default=True, 136 | ), 137 | ] 138 | -------------------------------------------------------------------------------- /oidc_provider/lib/errors.py: -------------------------------------------------------------------------------- 1 | try: 2 | from urllib.parse import quote 3 | except ImportError: 4 | from urllib import quote 5 | 6 | 7 | class RedirectUriError(Exception): 8 | error = "Redirect URI Error" 9 | description = ( 10 | "The request fails due to a missing, invalid, or mismatching" 11 | " redirection URI (redirect_uri)." 12 | ) 13 | 14 | 15 | class ClientIdError(Exception): 16 | error = "Client ID Error" 17 | description = "The client identifier (client_id) is missing or invalid." 18 | 19 | 20 | class UserAuthError(Exception): 21 | """ 22 | Specific to the Resource Owner Password Credentials flow when 23 | the Resource Owners credentials are not valid. 24 | """ 25 | 26 | error = "access_denied" 27 | description = "The resource owner or authorization server denied the request." 28 | 29 | def create_dict(self): 30 | return { 31 | "error": self.error, 32 | "error_description": self.description, 33 | } 34 | 35 | 36 | class TokenIntrospectionError(Exception): 37 | """ 38 | Specific to the introspection endpoint. This error will be converted 39 | to an "active: false" response, as per the spec. 40 | See https://tools.ietf.org/html/rfc7662 41 | """ 42 | 43 | pass 44 | 45 | 46 | class AuthorizeError(Exception): 47 | _errors = { 48 | # Oauth2 errors. 49 | # https://tools.ietf.org/html/rfc6749#section-4.1.2.1 50 | "invalid_request": "The request is otherwise malformed", 51 | "unauthorized_client": "The client is not authorized to request an " 52 | "authorization code using this method", 53 | "access_denied": "The resource owner or authorization server denied the request", 54 | "unsupported_response_type": "The authorization server does not " 55 | "support obtaining an authorization code " 56 | "using this method", 57 | "invalid_scope": "The requested scope is invalid, unknown, or malformed", 58 | "server_error": "The authorization server encountered an error", 59 | "temporarily_unavailable": "The authorization server is currently " 60 | "unable to handle the request due to a " 61 | "temporary overloading or maintenance of " 62 | "the server", 63 | # OpenID errors. 64 | # http://openid.net/specs/openid-connect-core-1_0.html#AuthError 65 | "interaction_required": "The Authorization Server requires End-User " 66 | "interaction of some form to proceed", 67 | "login_required": "The Authorization Server requires End-User authentication", 68 | "account_selection_required": "The End-User is required to select a " 69 | "session at the Authorization Server", 70 | "consent_required": "The Authorization Server requires End-Userconsent", 71 | "invalid_request_uri": "The request_uri in the Authorization Request " 72 | "returns an error or contains invalid data", 73 | "invalid_request_object": "The request parameter contains an invalid Request Object", 74 | "request_not_supported": "The provider does not support use of the request parameter", 75 | "request_uri_not_supported": "The provider does not support use of the " 76 | "request_uri parameter", 77 | "registration_not_supported": "The provider does not support use of " 78 | "the registration parameter", 79 | } 80 | 81 | def __init__(self, redirect_uri, error, grant_type): 82 | self.error = error 83 | self.description = self._errors.get(error) 84 | self.redirect_uri = redirect_uri 85 | self.grant_type = grant_type 86 | 87 | def create_uri(self, redirect_uri, state): 88 | description = quote(self.description) 89 | 90 | # See: 91 | # http://openid.net/specs/openid-connect-core-1_0.html#ImplicitAuthError 92 | hash_or_question = "#" if self.grant_type == "implicit" else "?" 93 | 94 | uri = "{0}{1}error={2}&error_description={3}".format( 95 | redirect_uri, hash_or_question, self.error, description 96 | ) 97 | 98 | # Add state if present. 99 | uri = uri + ("&state={0}".format(state) if state else "") 100 | 101 | return uri 102 | 103 | 104 | class TokenError(Exception): 105 | """ 106 | OAuth2 token endpoint errors. 107 | https://tools.ietf.org/html/rfc6749#section-5.2 108 | """ 109 | 110 | _errors = { 111 | "invalid_request": "The request is otherwise malformed", 112 | "invalid_client": "Client authentication failed (e.g., unknown client, " 113 | "no client authentication included, or unsupported " 114 | "authentication method)", 115 | "invalid_grant": "The provided authorization grant or refresh token is " 116 | "invalid, expired, revoked, does not match the " 117 | "redirection URI used in the authorization request, " 118 | "or was issued to another client", 119 | "unauthorized_client": "The authenticated client is not authorized to " 120 | "use this authorization grant type", 121 | "unsupported_grant_type": "The authorization grant type is not " 122 | "supported by the authorization server", 123 | "invalid_scope": "The requested scope is invalid, unknown, malformed, " 124 | "or exceeds the scope granted by the resource owner", 125 | } 126 | 127 | def __init__(self, error): 128 | self.error = error 129 | self.description = self._errors.get(error) 130 | 131 | def create_dict(self): 132 | dic = { 133 | "error": self.error, 134 | "error_description": self.description, 135 | } 136 | 137 | return dic 138 | 139 | 140 | class BearerTokenError(Exception): 141 | """ 142 | OAuth2 errors. 143 | https://tools.ietf.org/html/rfc6750#section-3.1 144 | """ 145 | 146 | _errors = { 147 | "invalid_request": ("The request is otherwise malformed", 400), 148 | "invalid_token": ( 149 | "The access token provided is expired, revoked, malformed, " 150 | "or invalid for other reasons", 151 | 401, 152 | ), 153 | "insufficient_scope": ( 154 | "The request requires higher privileges than provided by the access token", 155 | 403, 156 | ), 157 | } 158 | 159 | def __init__(self, code): 160 | self.code = code 161 | error_tuple = self._errors.get(code, ("", "")) 162 | self.description = error_tuple[0] 163 | self.status = error_tuple[1] 164 | -------------------------------------------------------------------------------- /oidc_provider/lib/claims.py: -------------------------------------------------------------------------------- 1 | import copy 2 | 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | from oidc_provider import settings 6 | 7 | STANDARD_CLAIMS = { 8 | "name": "", 9 | "given_name": "", 10 | "family_name": "", 11 | "middle_name": "", 12 | "nickname": "", 13 | "preferred_username": "", 14 | "profile": "", 15 | "picture": "", 16 | "website": "", 17 | "gender": "", 18 | "birthdate": "", 19 | "zoneinfo": "", 20 | "locale": "", 21 | "updated_at": "", 22 | "email": "", 23 | "email_verified": "", 24 | "phone_number": "", 25 | "phone_number_verified": "", 26 | "address": { 27 | "formatted": "", 28 | "street_address": "", 29 | "locality": "", 30 | "region": "", 31 | "postal_code": "", 32 | "country": "", 33 | }, 34 | } 35 | 36 | 37 | class ScopeClaims(object): 38 | def __init__(self, token): 39 | self.user = token.user 40 | claims = copy.deepcopy(STANDARD_CLAIMS) 41 | self.userinfo = settings.get("OIDC_USERINFO", import_str=True)(claims, self.user) 42 | self.scopes = token.scope 43 | self.client = token.client 44 | 45 | def create_response_dic(self): 46 | """ 47 | Generate the dic that will be jsonify. Checking scopes given vs 48 | registered. 49 | 50 | Returns a dic. 51 | """ 52 | dic = {} 53 | 54 | for scope in self.scopes: 55 | if scope in self._scopes_registered(): 56 | dic.update(getattr(self, "scope_" + scope)()) 57 | 58 | dic = self._clean_dic(dic) 59 | 60 | return dic 61 | 62 | def _scopes_registered(self): 63 | """ 64 | Return a list that contains all the scopes registered 65 | in the class. 66 | """ 67 | scopes = [] 68 | 69 | for name in dir(self.__class__): 70 | if name.startswith("scope_"): 71 | scope = name.split("scope_")[1] 72 | scopes.append(scope) 73 | 74 | return scopes 75 | 76 | def _clean_dic(self, dic): 77 | """ 78 | Clean recursively all empty or None values inside a dict. 79 | """ 80 | aux_dic = dic.copy() 81 | for key, value in iter(dic.items()): 82 | if value is None or value == "": 83 | del aux_dic[key] 84 | elif type(value) is dict: 85 | cleaned_dict = self._clean_dic(value) 86 | if not cleaned_dict: 87 | del aux_dic[key] 88 | continue 89 | aux_dic[key] = cleaned_dict 90 | return aux_dic 91 | 92 | @classmethod 93 | def get_scopes_info(cls, scopes=None): 94 | if scopes is None: 95 | scopes = [] 96 | scopes_info = [] 97 | 98 | for name in dir(cls): 99 | if name.startswith("info_"): 100 | scope_name = name.split("info_")[1] 101 | if scope_name in scopes: 102 | touple_info = getattr(cls, name) 103 | scopes_info.append( 104 | { 105 | "scope": scope_name, 106 | "name": touple_info[0], 107 | "description": touple_info[1], 108 | } 109 | ) 110 | 111 | return scopes_info 112 | 113 | 114 | class StandardScopeClaims(ScopeClaims): 115 | """ 116 | Based on OpenID Standard Claims. 117 | See: http://openid.net/specs/openid-connect-core-1_0.html#StandardClaims 118 | """ 119 | 120 | info_profile = ( 121 | _("Basic profile"), 122 | _( 123 | "Access to your basic information. Includes names, gender, birthdate " 124 | "and other information." 125 | ), 126 | ) 127 | 128 | def scope_profile(self): 129 | dic = { 130 | "name": self.userinfo.get("name"), 131 | "given_name": ( 132 | self.userinfo.get("given_name") or getattr(self.user, "first_name", None) 133 | ), 134 | "family_name": ( 135 | self.userinfo.get("family_name") or getattr(self.user, "last_name", None) 136 | ), 137 | "middle_name": self.userinfo.get("middle_name"), 138 | "nickname": self.userinfo.get("nickname") or getattr(self.user, "username", None), 139 | "preferred_username": self.userinfo.get("preferred_username"), 140 | "profile": self.userinfo.get("profile"), 141 | "picture": self.userinfo.get("picture"), 142 | "website": self.userinfo.get("website"), 143 | "gender": self.userinfo.get("gender"), 144 | "birthdate": self.userinfo.get("birthdate"), 145 | "zoneinfo": self.userinfo.get("zoneinfo"), 146 | "locale": self.userinfo.get("locale"), 147 | "updated_at": self.userinfo.get("updated_at"), 148 | } 149 | 150 | return dic 151 | 152 | info_email = ( 153 | _("Email"), 154 | _("Access to your email address."), 155 | ) 156 | 157 | def scope_email(self): 158 | dic = { 159 | "email": self.userinfo.get("email") or getattr(self.user, "email", None), 160 | "email_verified": self.userinfo.get("email_verified"), 161 | } 162 | 163 | return dic 164 | 165 | info_phone = ( 166 | _("Phone number"), 167 | _("Access to your phone number."), 168 | ) 169 | 170 | def scope_phone(self): 171 | dic = { 172 | "phone_number": self.userinfo.get("phone_number"), 173 | "phone_number_verified": self.userinfo.get("phone_number_verified"), 174 | } 175 | 176 | return dic 177 | 178 | info_address = ( 179 | _("Address information"), 180 | _("Access to your address. Includes country, locality, street and other information."), 181 | ) 182 | 183 | def scope_address(self): 184 | dic = { 185 | "address": { 186 | "formatted": self.userinfo.get("address", {}).get("formatted"), 187 | "street_address": self.userinfo.get("address", {}).get("street_address"), 188 | "locality": self.userinfo.get("address", {}).get("locality"), 189 | "region": self.userinfo.get("address", {}).get("region"), 190 | "postal_code": self.userinfo.get("address", {}).get("postal_code"), 191 | "country": self.userinfo.get("address", {}).get("country"), 192 | } 193 | } 194 | 195 | return dic 196 | -------------------------------------------------------------------------------- /docs/sections/scopesclaims.rst: -------------------------------------------------------------------------------- 1 | .. _scopesclaims: 2 | 3 | Scopes and Claims 4 | ################# 5 | 6 | This subset of OpenID Connect defines a set of standard Claims. They are returned in the UserInfo Response. 7 | 8 | The package comes with a setting called ``OIDC_USERINFO``, basically it refers to a function that will be called with ``claims`` (dict) and ``user`` (user instance). It returns the ``claims`` dict with all the claims populated. 9 | 10 | List of all the ``claims`` keys grouped by scopes: 11 | 12 | +--------------------+----------------+-----------------------+------------------------+ 13 | | profile | email | phone | address | 14 | +====================+================+=======================+========================+ 15 | | name | email | phone_number | formatted | 16 | +--------------------+----------------+-----------------------+------------------------+ 17 | | given_name | email_verified | phone_number_verified | street_address | 18 | +--------------------+----------------+-----------------------+------------------------+ 19 | | family_name | | | locality | 20 | +--------------------+----------------+-----------------------+------------------------+ 21 | | middle_name | | | region | 22 | +--------------------+----------------+-----------------------+------------------------+ 23 | | nickname | | | postal_code | 24 | +--------------------+----------------+-----------------------+------------------------+ 25 | | preferred_username | | | country | 26 | +--------------------+----------------+-----------------------+------------------------+ 27 | | profile | | | | 28 | +--------------------+----------------+-----------------------+------------------------+ 29 | | picture | | | | 30 | +--------------------+----------------+-----------------------+------------------------+ 31 | | website | | | | 32 | +--------------------+----------------+-----------------------+------------------------+ 33 | | gender | | | | 34 | +--------------------+----------------+-----------------------+------------------------+ 35 | | birthdate | | | | 36 | +--------------------+----------------+-----------------------+------------------------+ 37 | | zoneinfo | | | | 38 | +--------------------+----------------+-----------------------+------------------------+ 39 | | locale | | | | 40 | +--------------------+----------------+-----------------------+------------------------+ 41 | | updated_at | | | | 42 | +--------------------+----------------+-----------------------+------------------------+ 43 | 44 | How to populate standard claims 45 | =============================== 46 | 47 | Somewhere in your Django ``settings.py``:: 48 | 49 | OIDC_USERINFO = 'myproject.oidc_provider_settings.userinfo' 50 | 51 | 52 | Then inside your ``oidc_provider_settings.py`` file create the function for the ``OIDC_USERINFO`` setting:: 53 | 54 | def userinfo(claims, user): 55 | # Populate claims dict. 56 | claims['name'] = '{0} {1}'.format(user.first_name, user.last_name) 57 | claims['given_name'] = user.first_name 58 | claims['family_name'] = user.last_name 59 | claims['email'] = user.email 60 | claims['address']['street_address'] = '...' 61 | 62 | return claims 63 | 64 | Now test an Authorization Request using these scopes ``openid profile email`` and see how user attributes are returned. 65 | 66 | .. note:: 67 | Please **DO NOT** add extra keys or delete the existing ones in the ``claims`` dict. If you want to add extra claims to some scopes you can use the ``OIDC_EXTRA_SCOPE_CLAIMS`` setting. 68 | 69 | How to add custom scopes and claims 70 | =================================== 71 | 72 | The ``OIDC_EXTRA_SCOPE_CLAIMS`` setting is used to add extra scopes specific for your app. Is just a class that inherit from ``oidc_provider.lib.claims.ScopeClaims``. You can create or modify scopes by adding this methods into it: 73 | 74 | * ``info_scopename`` class property for setting the verbose name and description. 75 | * ``scope_scopename`` method for returning some information related. 76 | 77 | Let's say that you want add your custom ``foo`` scope for your OAuth2/OpenID provider. So when a client (RP) makes an Authorization Request containing ``foo`` in the list of scopes, it will be listed in the consent page (``templates/oidc_provider/authorize.html``) and then some specific claims like ``bar`` will be returned from the ``/userinfo`` response. 78 | 79 | Somewhere in your Django ``settings.py``:: 80 | 81 | OIDC_EXTRA_SCOPE_CLAIMS = 'yourproject.oidc_provider_settings.CustomScopeClaims' 82 | 83 | Inside your oidc_provider_settings.py file add the following class:: 84 | 85 | from django.utils.translation import ugettext_lazy as _ 86 | from oidc_provider.lib.claims import ScopeClaims 87 | 88 | class CustomScopeClaims(ScopeClaims): 89 | 90 | info_foo = ( 91 | _(u'Foo'), 92 | _(u'Some description for the scope.'), 93 | ) 94 | 95 | def scope_foo(self): 96 | # self.user - Django user instance. 97 | # self.userinfo - Dict returned by OIDC_USERINFO function. 98 | # self.scopes - List of scopes requested. 99 | # self.client - Client requesting this claims. 100 | dic = { 101 | 'bar': 'Something dynamic here', 102 | } 103 | 104 | return dic 105 | 106 | # If you want to change the description of the profile scope, you can redefine it. 107 | info_profile = ( 108 | _(u'Profile'), 109 | _(u'Another description.'), 110 | ) 111 | 112 | .. note:: 113 | If a field is empty or ``None`` inside the dictionary you return on the ``scope_scopename`` method, it will be cleaned from the response. 114 | 115 | Include claims in the ID Token 116 | ============================== 117 | 118 | The draft specifies that ID Tokens MAY include additional claims. You can add claims to the ID Token using ``OIDC_IDTOKEN_INCLUDE_CLAIMS``. Note that the claims will be filtered based on the token's scope. 119 | 120 | .. note:: 121 | Any extra claims defined with ``OIDC_EXTRA_SCOPE_CLAIMS`` will also be included. -------------------------------------------------------------------------------- /oidc_provider/settings.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import random 3 | import string 4 | 5 | from django.conf import settings 6 | 7 | 8 | class DefaultSettings(object): 9 | required_attrs = () 10 | 11 | def __init__(self): 12 | self._unauthenticated_session_management_key = None 13 | 14 | @property 15 | def OIDC_LOGIN_URL(self): 16 | """ 17 | OPTIONAL. Used to log the user in. By default Django's LOGIN_URL will be used. 18 | """ 19 | return settings.LOGIN_URL 20 | 21 | @property 22 | def SITE_URL(self): 23 | """ 24 | OPTIONAL. The OP server url. 25 | """ 26 | return None 27 | 28 | @property 29 | def OIDC_AFTER_USERLOGIN_HOOK(self): 30 | """ 31 | OPTIONAL. Provide a way to plug into the process after 32 | the user has logged in, typically to perform some business logic. 33 | """ 34 | return "oidc_provider.lib.utils.common.default_after_userlogin_hook" 35 | 36 | @property 37 | def OIDC_AFTER_END_SESSION_HOOK(self): 38 | """ 39 | OPTIONAL. Provide a way to plug into the end session process just before calling 40 | Django's logout function, typically to perform some business logic. 41 | """ 42 | return "oidc_provider.lib.utils.common.default_after_end_session_hook" 43 | 44 | @property 45 | def OIDC_CODE_EXPIRE(self): 46 | """ 47 | OPTIONAL. Code expiration time expressed in seconds. 48 | """ 49 | return 60 * 10 50 | 51 | @property 52 | def OIDC_DISCOVERY_CACHE_ENABLE(self): 53 | """ 54 | OPTIONAL. Enable caching the response on the discovery endpoint. 55 | """ 56 | return False 57 | 58 | @property 59 | def OIDC_DISCOVERY_CACHE_EXPIRE(self): 60 | """ 61 | OPTIONAL. Discovery endpoint cache expiration time expressed in seconds. 62 | """ 63 | return 60 * 60 * 24 64 | 65 | @property 66 | def OIDC_EXTRA_SCOPE_CLAIMS(self): 67 | """ 68 | OPTIONAL. A string with the location of your class. 69 | Used to add extra scopes specific for your app. 70 | """ 71 | return None 72 | 73 | @property 74 | def OIDC_IDTOKEN_EXPIRE(self): 75 | """ 76 | OPTIONAL. Id token expiration time expressed in seconds. 77 | """ 78 | return 60 * 10 79 | 80 | @property 81 | def OIDC_IDTOKEN_SUB_GENERATOR(self): 82 | """ 83 | OPTIONAL. Subject Identifier. A locally unique and never 84 | reassigned identifier within the Issuer for the End-User, 85 | which is intended to be consumed by the Client. 86 | """ 87 | return "oidc_provider.lib.utils.common.default_sub_generator" 88 | 89 | @property 90 | def OIDC_IDTOKEN_INCLUDE_CLAIMS(self): 91 | """ 92 | OPTIONAL. If enabled, id_token will include standard claims of the user. 93 | """ 94 | return False 95 | 96 | @property 97 | def OIDC_SESSION_MANAGEMENT_ENABLE(self): 98 | """ 99 | OPTIONAL. If enabled, the Server will support Session Management 1.0 specification. 100 | """ 101 | return False 102 | 103 | @property 104 | def OIDC_UNAUTHENTICATED_SESSION_MANAGEMENT_KEY(self): 105 | """ 106 | OPTIONAL. Supply a fixed string to use as browser-state key for unauthenticated clients. 107 | """ 108 | 109 | # Memoize generated value 110 | if not self._unauthenticated_session_management_key: 111 | self._unauthenticated_session_management_key = "".join( 112 | random.choice(string.ascii_uppercase + string.digits) for _ in range(100) 113 | ) 114 | return self._unauthenticated_session_management_key 115 | 116 | @property 117 | def OIDC_SKIP_CONSENT_EXPIRE(self): 118 | """ 119 | OPTIONAL. User consent expiration after been granted. 120 | """ 121 | return 30 * 3 122 | 123 | @property 124 | def OIDC_TOKEN_EXPIRE(self): 125 | """ 126 | OPTIONAL. Token object expiration after been created. 127 | Expressed in seconds. 128 | """ 129 | return 60 * 60 130 | 131 | @property 132 | def OIDC_USERINFO(self): 133 | """ 134 | OPTIONAL. A string with the location of your function. 135 | Used to populate standard claims with your user information. 136 | """ 137 | return "oidc_provider.lib.utils.common.default_userinfo" 138 | 139 | @property 140 | def OIDC_IDTOKEN_PROCESSING_HOOK(self): 141 | """ 142 | OPTIONAL. A string with the location of your hook. 143 | Used to add extra dictionary values specific for your app into id_token. 144 | """ 145 | return "oidc_provider.lib.utils.common.default_idtoken_processing_hook" 146 | 147 | @property 148 | def OIDC_INTROSPECTION_PROCESSING_HOOK(self): 149 | """ 150 | OPTIONAL. A string with the location of your function. 151 | Used to update the response for a valid introspection token request. 152 | """ 153 | return "oidc_provider.lib.utils.common.default_introspection_processing_hook" 154 | 155 | @property 156 | def OIDC_INTROSPECTION_VALIDATE_AUDIENCE_SCOPE(self): 157 | """ 158 | OPTIONAL: A boolean to specify whether or not to verify that the introspection 159 | resource has the requesting client id as one of its scopes. 160 | """ 161 | return True 162 | 163 | @property 164 | def OIDC_GRANT_TYPE_PASSWORD_ENABLE(self): 165 | """ 166 | OPTIONAL. A boolean to set whether to allow the Resource Owner Password 167 | Credentials Grant. https://tools.ietf.org/html/rfc6749#section-4.3 168 | 169 | From the specification: 170 | Since this access token request utilizes the resource owner's 171 | password, the authorization server MUST protect the endpoint 172 | against brute force attacks (e.g., using rate-limitation or 173 | generating alerts). 174 | 175 | How you do this, is up to you. 176 | """ 177 | return False 178 | 179 | @property 180 | def OIDC_TEMPLATES(self): 181 | return {"authorize": "oidc_provider/authorize.html", "error": "oidc_provider/error.html"} 182 | 183 | @property 184 | def OIDC_INTROSPECTION_RESPONSE_SCOPE_ENABLE(self): 185 | """ 186 | OPTIONAL: A boolean to specify whether or not to include scope in introspection response. 187 | """ 188 | return False 189 | 190 | 191 | default_settings = DefaultSettings() 192 | 193 | 194 | def import_from_str(value): 195 | """ 196 | Attempt to import a class from a string representation. 197 | """ 198 | try: 199 | parts = value.split(".") 200 | module_path, class_name = ".".join(parts[:-1]), parts[-1] 201 | module = importlib.import_module(module_path) 202 | return getattr(module, class_name) 203 | except ImportError as e: 204 | msg = "Could not import %s for settings. %s: %s." % (value, e.__class__.__name__, e) 205 | raise ImportError(msg) 206 | 207 | 208 | def get(name, import_str=False): 209 | """ 210 | Helper function to use inside the package. 211 | """ 212 | value = None 213 | default_value = getattr(default_settings, name) 214 | 215 | try: 216 | value = getattr(settings, name) 217 | except AttributeError: 218 | if name in default_settings.required_attrs: 219 | raise Exception("You must set " + name + " in your settings.") 220 | 221 | if isinstance(default_value, dict) and value: 222 | default_value.update(value) 223 | value = default_value 224 | else: 225 | if value is None: 226 | value = default_value 227 | value = import_from_str(value) if import_str else value 228 | 229 | return value 230 | -------------------------------------------------------------------------------- /oidc_provider/lib/utils/token.py: -------------------------------------------------------------------------------- 1 | import time 2 | import uuid 3 | from datetime import timedelta 4 | 5 | import jwt 6 | from cryptography.hazmat.primitives import serialization 7 | from django.utils import dateformat 8 | from django.utils import timezone 9 | 10 | from oidc_provider import settings 11 | from oidc_provider.lib.claims import StandardScopeClaims 12 | from oidc_provider.lib.utils.common import get_issuer 13 | from oidc_provider.lib.utils.common import run_processing_hook 14 | from oidc_provider.models import Code 15 | from oidc_provider.models import RSAKey 16 | from oidc_provider.models import Token 17 | 18 | # Cache for loaded RSA keys to avoid repeated PEM parsing 19 | # Cache is automatically cleaned of stale entries (keys no longer in DB) 20 | _rsa_key_cache = {} 21 | 22 | 23 | def create_id_token(token, user, aud, nonce="", at_hash="", request=None, scope=None): 24 | """ 25 | Creates the id_token dictionary. 26 | See: http://openid.net/specs/openid-connect-core-1_0.html#IDToken 27 | Return a dic. 28 | """ 29 | if scope is None: 30 | scope = [] 31 | sub = settings.get("OIDC_IDTOKEN_SUB_GENERATOR", import_str=True)(user=user) 32 | 33 | expires_in = settings.get("OIDC_IDTOKEN_EXPIRE") 34 | 35 | # Convert datetimes into timestamps. 36 | now = int(time.time()) 37 | iat_time = now 38 | exp_time = int(now + expires_in) 39 | user_auth_time = user.last_login or user.date_joined 40 | auth_time = int(dateformat.format(user_auth_time, "U")) 41 | 42 | dic = { 43 | "iss": get_issuer(request=request), 44 | "sub": sub, 45 | "aud": str(aud), 46 | "exp": exp_time, 47 | "iat": iat_time, 48 | "auth_time": auth_time, 49 | } 50 | 51 | if nonce: 52 | dic["nonce"] = str(nonce) 53 | 54 | if at_hash: 55 | dic["at_hash"] = at_hash 56 | 57 | # Inlude (or not) user standard claims in the id_token. 58 | if settings.get("OIDC_IDTOKEN_INCLUDE_CLAIMS"): 59 | standard_claims = StandardScopeClaims(token) 60 | dic.update(standard_claims.create_response_dic()) 61 | 62 | if settings.get("OIDC_EXTRA_SCOPE_CLAIMS"): 63 | extra_claims = settings.get("OIDC_EXTRA_SCOPE_CLAIMS", import_str=True)(token) 64 | dic.update(extra_claims.create_response_dic()) 65 | 66 | dic = run_processing_hook( 67 | dic, "OIDC_IDTOKEN_PROCESSING_HOOK", user=user, token=token, request=request 68 | ) 69 | 70 | return dic 71 | 72 | 73 | def encode_id_token(payload, client): 74 | """ 75 | Represent the ID Token as a JSON Web Token (JWT). 76 | Returns a dict. 77 | """ 78 | keys = get_client_alg_keys(client) 79 | # Use the first key for encoding 80 | # TODO: make key selection more explicit 81 | key_info = keys[0] 82 | 83 | headers = {} 84 | if "kid" in key_info: 85 | headers["kid"] = key_info["kid"] 86 | 87 | return jwt.encode(payload, key_info["key"], algorithm=key_info["algorithm"], headers=headers) 88 | 89 | 90 | def decode_id_token(token, client): 91 | """ 92 | Represent the ID Token as a JSON Web Token (JWT). 93 | Returns a dict. 94 | """ 95 | # Try decoding with each available key 96 | for key in get_client_alg_keys(client): 97 | try: 98 | return jwt.decode( 99 | jwt=token, 100 | # HS256 uses the same key for signing and verifying 101 | key=key["key"] if key["algorithm"] == "HS256" else key["public_key"], 102 | algorithms=[key["algorithm"]], 103 | options={ 104 | "verify_signature": True, 105 | "verify_aud": False, # Disable audience validation for compatibility 106 | "verify_exp": False, # Disable expiration validation for compatibility 107 | "verify_iat": False, # Disable issued at validation for compatibility 108 | "verify_nbf": False, # Disable not before validation for compatibility 109 | }, 110 | ) 111 | except jwt.InvalidTokenError: 112 | continue 113 | 114 | # If we get here, none of the keys worked 115 | raise jwt.InvalidTokenError("Token could not be decoded with any available key") 116 | 117 | 118 | def client_id_from_id_token(id_token): 119 | """ 120 | Extracts the client id from a JSON Web Token (JWT). 121 | Does NOT verify the token signature or expiration. 122 | Returns a string or None. 123 | """ 124 | # Decode without verification to get the payload 125 | payload = jwt.decode(id_token, options={"verify_signature": False}) 126 | aud = payload.get("aud", None) 127 | if aud is None: 128 | return None 129 | if isinstance(aud, list): 130 | return aud[0] 131 | return aud 132 | 133 | 134 | def create_token(user, client, scope, id_token_dic=None): 135 | """ 136 | Create and populate a Token object. 137 | Return a Token object. 138 | """ 139 | token = Token() 140 | token.user = user 141 | token.client = client 142 | token.access_token = uuid.uuid4().hex 143 | 144 | if id_token_dic is not None: 145 | token.id_token = id_token_dic 146 | 147 | token.refresh_token = uuid.uuid4().hex 148 | token.expires_at = timezone.now() + timedelta(seconds=settings.get("OIDC_TOKEN_EXPIRE")) 149 | token.scope = scope 150 | 151 | return token 152 | 153 | 154 | def create_code( 155 | user, client, scope, nonce, is_authentication, code_challenge=None, code_challenge_method=None 156 | ): 157 | """ 158 | Create and populate a Code object. 159 | Return a Code object. 160 | """ 161 | code = Code() 162 | code.user = user 163 | code.client = client 164 | 165 | code.code = uuid.uuid4().hex 166 | 167 | if code_challenge and code_challenge_method: 168 | code.code_challenge = code_challenge 169 | code.code_challenge_method = code_challenge_method 170 | 171 | code.expires_at = timezone.now() + timedelta(seconds=settings.get("OIDC_CODE_EXPIRE")) 172 | code.scope = scope 173 | code.nonce = nonce 174 | code.is_authentication = is_authentication 175 | 176 | return code 177 | 178 | 179 | def get_client_alg_keys(client): 180 | """ 181 | Takes a client and returns the set of keys associated with it. 182 | Returns a list of keys compatible with PyJWT. 183 | """ 184 | if client.jwt_alg == "RS256": 185 | keys = [] 186 | current_kids = set() 187 | 188 | for rsakey in RSAKey.objects.all(): 189 | cache_key = f"rsa_key_{rsakey.kid}" 190 | current_kids.add(cache_key) 191 | 192 | if cache_key not in _rsa_key_cache: 193 | # Load the RSA private key using cryptography (expensive operation) 194 | private_key = serialization.load_pem_private_key( 195 | rsakey.key.encode("utf-8"), 196 | password=None, 197 | ) 198 | # Also cache the public key to avoid repeated .public_key() calls 199 | public_key = private_key.public_key() 200 | _rsa_key_cache[cache_key] = {"private_key": private_key, "public_key": public_key} 201 | 202 | key_pair = _rsa_key_cache[cache_key] 203 | keys.append( 204 | { 205 | "key": key_pair["private_key"], 206 | "public_key": key_pair["public_key"], 207 | "kid": rsakey.kid, 208 | "algorithm": "RS256", 209 | } 210 | ) 211 | 212 | # Clean up stale cache entries (keys that no longer exist in DB) 213 | stale_keys = set(_rsa_key_cache.keys()) - current_kids 214 | for stale_key in stale_keys: 215 | del _rsa_key_cache[stale_key] 216 | 217 | if not keys: 218 | raise Exception("You must add at least one RSA Key.") 219 | elif client.jwt_alg == "HS256": 220 | # NOTE: HS256 does not have any expensive key parsing, so we don't need the 221 | # same key caching as RS256. 222 | keys = [{"key": client.client_secret, "algorithm": "HS256"}] 223 | else: 224 | raise Exception("Unsupported key algorithm.") 225 | 226 | return keys 227 | --------------------------------------------------------------------------------