simple_openid_connect 2.3.2__tar.gz → 2.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/.envrc.dist +2 -0
  2. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/.github/workflows/checks.yml +12 -9
  3. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/CHANGELOG.md +12 -0
  4. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/PKG-INFO +4 -4
  5. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/README.md +3 -3
  6. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/requirements.dev.txt +1 -0
  7. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/__init__.py +1 -1
  8. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/client.py +80 -13
  9. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/integrations/django/admin.py +16 -1
  10. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/integrations/django/templates/simple_openid_connect/admin/openid_session_inline.html +6 -3
  11. simple_openid_connect-2.4.0/src/simple_openid_connect/jwk.py +59 -0
  12. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/tests/conftest.py +24 -1
  13. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/tests/test_client.py +9 -2
  14. simple_openid_connect-2.4.0/tests/test_jwk.py +47 -0
  15. simple_openid_connect-2.3.2/src/simple_openid_connect/jwk.py +0 -23
  16. simple_openid_connect-2.3.2/tests/test_jwk.py +0 -18
  17. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/.git-blame-ignore-revs +0 -0
  18. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/.gitattributes +0 -0
  19. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/.gitignore +0 -0
  20. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/.pre-commit-config.yaml +0 -0
  21. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/.readthedocs.yaml +0 -0
  22. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/DEVELOPMENT.md +0 -0
  23. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/LICENSE +0 -0
  24. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/dev/compose.yaml +0 -0
  25. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/dev/keycloak_exports/realm.json +0 -0
  26. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/docs/Makefile +0 -0
  27. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/docs/_static/.gitkeep +0 -0
  28. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/docs/_templates/.gitkeep +0 -0
  29. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/docs/api.rst +0 -0
  30. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/docs/conf.py +0 -0
  31. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/docs/django-integration.rst +0 -0
  32. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/docs/drf-integration.rst +0 -0
  33. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/docs/index.rst +0 -0
  34. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/docs/installation.rst +0 -0
  35. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/docs/nonce_and_state.rst +0 -0
  36. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/docs/usage.rst +0 -0
  37. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/flake.lock +0 -0
  38. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/flake.nix +0 -0
  39. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/pyproject.toml +0 -0
  40. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/base_data.py +0 -0
  41. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/client_authentication.py +0 -0
  42. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/data.py +0 -0
  43. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/discovery.py +0 -0
  44. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/exceptions.py +0 -0
  45. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/flows/__init__.py +0 -0
  46. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/flows/authorization_code_flow/__init__.py +0 -0
  47. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/flows/authorization_code_flow/client.py +0 -0
  48. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/flows/client_credentials_grant/__init__.py +0 -0
  49. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/flows/client_credentials_grant/client.py +0 -0
  50. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/flows/direct_access_grant/__init__.py +0 -0
  51. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/flows/direct_access_grant/client.py +0 -0
  52. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/integrations/__init__.py +0 -0
  53. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/integrations/django/__init__.py +0 -0
  54. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/integrations/django/apps.py +0 -0
  55. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/integrations/django/decorators.py +0 -0
  56. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/integrations/django/management/__init__.py +0 -0
  57. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/integrations/django/management/commands/__init__.py +0 -0
  58. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/integrations/django/management/commands/clear-expired-openid-sessions.py +0 -0
  59. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/integrations/django/middleware.py +0 -0
  60. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/integrations/django/migrations/0001_initial.py +0 -0
  61. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/integrations/django/migrations/0002_move_sessions_id_token.py +0 -0
  62. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/integrations/django/migrations/0003_openidsession_add_raw_id_token.py +0 -0
  63. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/integrations/django/migrations/__init__.py +0 -0
  64. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/integrations/django/models.py +0 -0
  65. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/integrations/django/static/simple_openid_connect/login_failed.css +0 -0
  66. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/integrations/django/templates/simple_openid_connect/login_failed.html +0 -0
  67. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/integrations/django/urls.py +0 -0
  68. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/integrations/django/user_mapping.py +0 -0
  69. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/integrations/django/views.py +0 -0
  70. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/integrations/djangorestframework/__init__.py +0 -0
  71. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/integrations/djangorestframework/authentication.py +0 -0
  72. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/integrations/djangorestframework/drf_spectacular_schema.py +0 -0
  73. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/integrations/djangorestframework/permissions.py +0 -0
  74. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/pkce.py +0 -0
  75. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/py.typed +0 -0
  76. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/rp_initiated_logout.py +0 -0
  77. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/scripts/__init__.py +0 -0
  78. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/scripts/show_openid_tokens.py +0 -0
  79. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/token_introspection.py +0 -0
  80. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/token_refresh.py +0 -0
  81. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/userinfo.py +0 -0
  82. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/src/simple_openid_connect/utils.py +0 -0
  83. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/tests/django_test_project/django_test_project/__init__.py +0 -0
  84. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/tests/django_test_project/django_test_project/settings_base.py +0 -0
  85. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/tests/django_test_project/django_test_project/settings_dev.py +0 -0
  86. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/tests/django_test_project/django_test_project/settings_test.py +0 -0
  87. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/tests/django_test_project/django_test_project/templates/admin/login.html +0 -0
  88. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/tests/django_test_project/django_test_project/tests/conftest.py +0 -0
  89. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/tests/django_test_project/django_test_project/tests/test_access_token_protected_view.py +0 -0
  90. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/tests/django_test_project/django_test_project/tests/test_frontchannel_logout_notifications.py +0 -0
  91. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/tests/django_test_project/django_test_project/tests/test_login.py +0 -0
  92. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/tests/django_test_project/django_test_project/tests/test_logout.py +0 -0
  93. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/tests/django_test_project/django_test_project/urls.py +0 -0
  94. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/tests/django_test_project/django_test_project/views.py +0 -0
  95. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/tests/django_test_project/django_test_project/wsgi.py +0 -0
  96. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/tests/django_test_project/manage.py +0 -0
  97. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/tests/test_authorization_code_flow.py +0 -0
  98. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/tests/test_client_auth.py +0 -0
  99. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/tests/test_client_credentials_grant.py +0 -0
  100. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/tests/test_direct_access_grant.py +0 -0
  101. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/tests/test_discovery.py +0 -0
  102. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/tests/test_message_encoding.py +0 -0
  103. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/tests/test_pkce.py +0 -0
  104. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/tests/test_rp_initiated_logout.py +0 -0
  105. {simple_openid_connect-2.3.2 → simple_openid_connect-2.4.0}/tests/test_validate_extern.py +0 -0
@@ -1,3 +1,5 @@
1
+ watch_file requirements.dev.txt pyproject.toml
2
+
1
3
  if has nix; then
2
4
  use flake
3
5
  fi
@@ -27,18 +27,21 @@ jobs:
27
27
  strategy:
28
28
  fail-fast: false
29
29
  matrix:
30
- py_version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
31
- drf_version: ["3.14", "3.15", "3.16"]
30
+ py_version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
31
+ drf_version: ["3.15", "3.16", "3.17"]
32
32
  django_version:
33
33
  - "4.2" # LTS
34
- - "5.1" # current
35
- - "5.2" # current, LTS
34
+ - "5.2" # LTS
35
+ - "6" # current major version
36
36
  exclude:
37
- # django 5.* requires python >3.9
38
- - py_version: "3.9"
39
- django_version: "5.1"
40
- - py_version: "3.9"
41
- django_version: "5.2"
37
+ # exclude new django versions with old python versions
38
+ - py_version: "3.10"
39
+ django_version: "6"
40
+ - py_version: "3.11"
41
+ django_version: "6"
42
+ # exclude old django versions with new python versions
43
+ - py_version: "3.14"
44
+ django_version: "4.2"
42
45
  runs-on: ubuntu-latest
43
46
  steps:
44
47
  - uses: actions/checkout@v3
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## v2.4.0
4
+
5
+ ### Notable Features
6
+
7
+ - JSON-Web-Keys of Identity-Providers are not cached indefinitely anymore.
8
+ In previous versions, when using the *client-style* API of this library, any instance of `OpenidClient` would fetch JSON-Web-Keys from the configured identity provider during initialization and then keep them forever. This has been changed so that JWKs are now only fetched on demand and cached based on the Identity-Providers HTTP `Cache-Control` header information. Because many Identity-Provider implementations don't send 'Cache-Control' headers, a default minimum cache of 10 minutes has also been implemented.
9
+
10
+ ### Minor Changes
11
+
12
+ - The supported list of python and integration versions has been updated to match currently supported upstream ones.
13
+ - The included django admin view now only displays raw tokens when djangos debug mode is enabled.
14
+
3
15
  ## v2.3.2
4
16
 
5
17
  ### Bug-Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simple_openid_connect
3
- Version: 2.3.2
3
+ Version: 2.4.0
4
4
  Summary: Simple and opinionated OpenID-Connect relying party and resource server implementation
5
5
  Author-email: lilly <li@lly.sh>
6
6
  Requires-Python: ~=3.9
@@ -66,14 +66,14 @@ Simple and opinionated OpenID-Connect relying party and resource server implemen
66
66
 
67
67
  ## Language & Framework Support
68
68
 
69
- We aim to follow pythons officially supported versions. This is currently python `v3.9`, `v3.10`, `v3.11`, `v3.12` and `v3.13`.
69
+ We aim to follow pythons officially supported versions. This is currently python `v3.10`, `v3.11`, `v3.12`, `v3.13` and `v3.14`.
70
70
 
71
71
  In addition to being an OpenID library from which one can build their own authentication solutaion, we also offer explicit integrations into the following frameworks and projects:
72
72
 
73
73
  | Name | Package Feature | Integration Docs | Supported Versions |
74
74
  |-----------------------------------------------------------------|-----------------------|----------------------------------------------------------------------------------------------------|---------------------------|
75
- | [Django](https://www.djangoproject.com/) | `django` | [Integration Docs](https://simple-openid-connect.readthedocs.io/en/stable/django-integration.html) | `v4.2`, `v5.1`, `v5.2` |
76
- | [Django-Rest-Framework](https://www.django-rest-framework.org/) | `djangorestframework` | [Integration Docs](https://simple-openid-connect.readthedocs.io/en/stable/drf-integration.html) | `v3.14`, `v3.15`, `v3.16` |
75
+ | [Django](https://www.djangoproject.com/) | `django` | [Integration Docs](https://simple-openid-connect.readthedocs.io/en/stable/django-integration.html) | `v4.2`, `v5.2`, `v6.0` |
76
+ | [Django-Rest-Framework](https://www.django-rest-framework.org/) | `djangorestframework` | [Integration Docs](https://simple-openid-connect.readthedocs.io/en/stable/drf-integration.html) | `v3.15`, `v3.16`, `v3.17` |
77
77
 
78
78
 
79
79
 
@@ -33,14 +33,14 @@ Simple and opinionated OpenID-Connect relying party and resource server implemen
33
33
 
34
34
  ## Language & Framework Support
35
35
 
36
- We aim to follow pythons officially supported versions. This is currently python `v3.9`, `v3.10`, `v3.11`, `v3.12` and `v3.13`.
36
+ We aim to follow pythons officially supported versions. This is currently python `v3.10`, `v3.11`, `v3.12`, `v3.13` and `v3.14`.
37
37
 
38
38
  In addition to being an OpenID library from which one can build their own authentication solutaion, we also offer explicit integrations into the following frameworks and projects:
39
39
 
40
40
  | Name | Package Feature | Integration Docs | Supported Versions |
41
41
  |-----------------------------------------------------------------|-----------------------|----------------------------------------------------------------------------------------------------|---------------------------|
42
- | [Django](https://www.djangoproject.com/) | `django` | [Integration Docs](https://simple-openid-connect.readthedocs.io/en/stable/django-integration.html) | `v4.2`, `v5.1`, `v5.2` |
43
- | [Django-Rest-Framework](https://www.django-rest-framework.org/) | `djangorestframework` | [Integration Docs](https://simple-openid-connect.readthedocs.io/en/stable/drf-integration.html) | `v3.14`, `v3.15`, `v3.16` |
42
+ | [Django](https://www.djangoproject.com/) | `django` | [Integration Docs](https://simple-openid-connect.readthedocs.io/en/stable/django-integration.html) | `v4.2`, `v5.2`, `v6.0` |
43
+ | [Django-Rest-Framework](https://www.django-rest-framework.org/) | `djangorestframework` | [Integration Docs](https://simple-openid-connect.readthedocs.io/en/stable/drf-integration.html) | `v3.15`, `v3.16`, `v3.17` |
44
44
 
45
45
 
46
46
 
@@ -10,6 +10,7 @@ mypy>=1.7.0
10
10
  pytest
11
11
  pytest-django
12
12
  pytest-explicit
13
+ freezegun
13
14
  pyyaml
14
15
  responses
15
16
  ruff
@@ -2,4 +2,4 @@
2
2
  Simple and opinionated OpenID-Connect relying party (client) and resource server implementation.
3
3
  """
4
4
 
5
- __version__ = "2.3.2"
5
+ __version__ = "2.4.0"
@@ -14,10 +14,11 @@ from typing import (
14
14
  TypeVar,
15
15
  Union,
16
16
  )
17
+ from datetime import datetime, timedelta, timezone
18
+ import warnings
17
19
 
18
20
  from cryptojwt import JWK
19
21
  from cryptojwt.jwk.jwk import key_from_jwk_dict
20
-
21
22
  from simple_openid_connect import (
22
23
  jwk,
23
24
  rp_initiated_logout,
@@ -62,8 +63,8 @@ class OpenidClient:
62
63
  """
63
64
 
64
65
  provider_config: ProviderMetadata
65
- provider_keys: List[JWK]
66
66
  client_auth: ClientAuthenticationMethod
67
+ min_jwks_cache_duration: timedelta
67
68
  scope: str
68
69
 
69
70
  authorization_code_flow: AuthorizationCodeFlowClient
@@ -75,22 +76,48 @@ class OpenidClient:
75
76
  client_credentials_grant: ClientCredentialsGrantClient
76
77
  "*Client Credentials Grant* (or *Service Account Authentication*) functionality"
77
78
 
79
+ _jwks_max_age: Optional[datetime]
80
+ "Maximum time until the currently cached provider keys are valid"
81
+ _provider_keys: Optional[List[JWK]]
82
+ "Cached provider keys that can be used until they expire"
83
+
78
84
  def __init__(
79
85
  self,
80
86
  provider_config: ProviderMetadata,
81
- provider_keys: List[JWK],
82
- authentication_redirect_uri: Optional[str],
83
- client_id: str,
87
+ provider_keys: Optional[List[JWK]] = None,
88
+ authentication_redirect_uri: Optional[str] = None,
89
+ client_id: str = "",
84
90
  client_secret: Optional[str] = None,
85
91
  scope: str = "openid",
92
+ min_jwks_cache_duration: timedelta = timedelta(minutes=10),
86
93
  ):
94
+ """
95
+ Construct a new client that is bound to an Identity-Provider.
96
+
97
+ See :func:`from_issuer_url` for a more convenient factory that constructs a client instance.
98
+
99
+ :param provider_config: Configuration of the identity provider, probably fetched via autodiscovery
100
+ :param authentication_redirect_uri: The URL that a User-Agent should be redirected to during the authentication process to reach this client
101
+ :param client_id: The client_id for this client. This is assigned to you by the Identity-Provider
102
+ :param client_secret: The client_secret for this client. This is assigned to you by the Identity-Provider and is recommended to set. If this client is not able to keep it's secret safe, it shoud however not be set.
103
+ :param scope: The authorization scope this client should request from the Identity-Provider
104
+ :param min_jwks_cache_duration: Minimum time this client will cache JWKs responses from the Identity-Provider. If set to 0, the Identity-Providers Cache-Control HTTP headers will be resepcted fully.
105
+ """
87
106
  self.provider_config = provider_config
88
- self.provider_keys = provider_keys
107
+ self.scope = scope
108
+ self.authentication_redirect_uri = authentication_redirect_uri
109
+ self.min_jwks_cache_duration = min_jwks_cache_duration
89
110
  self.authorization_code_flow = AuthorizationCodeFlowClient(self)
90
111
  self.direct_access_grant = DirectAccessGrantClient(self)
91
112
  self.client_credentials_grant = ClientCredentialsGrantClient(self)
92
- self.scope = scope
93
- self.authentication_redirect_uri = authentication_redirect_uri
113
+ self._jwks_max_age = None
114
+ self._provider_keys = None
115
+
116
+ if provider_keys is not None:
117
+ warnings.warn(
118
+ "giving a provider_keys= parameter to the OpenidClient constructor and the parameter will be removed in a future release",
119
+ DeprecationWarning,
120
+ )
94
121
 
95
122
  if client_secret is None:
96
123
  self.client_auth = NoneAuth(client_id)
@@ -113,6 +140,7 @@ class OpenidClient:
113
140
  client_id: str,
114
141
  client_secret: Union[str, None] = None,
115
142
  scope: str = "openid",
143
+ min_jwks_cache_duration: timedelta = timedelta(minutes=10),
116
144
  ) -> Self:
117
145
  """
118
146
  Create a new client instance with an issuer url as base, automatically discovering information about the issuer in the process.
@@ -124,11 +152,17 @@ class OpenidClient:
124
152
  :param client_secret: Optionally a client secret which has been assigned to your client from the issuer.
125
153
  If not supplied, this client is assumed to be *public* which means it has not client secret because it cannot be kept safe (e.g. a web-app).
126
154
  :param scope: Which scopes to request from the OP
155
+ :param min_jwks_cache_duration: Minimum time this client will cache JWKs responses from the Identity-Provider. If set to 0, the Identity-Providers Cache-Control HTTP headers will be resepcted fully.
127
156
  """
128
157
 
129
158
  config = discover_configuration_from_issuer(url)
130
159
  return cls.from_issuer_config(
131
- config, authentication_redirect_uri, client_id, client_secret, scope
160
+ config,
161
+ authentication_redirect_uri,
162
+ client_id,
163
+ client_secret,
164
+ scope,
165
+ min_jwks_cache_duration,
132
166
  )
133
167
 
134
168
  @classmethod
@@ -139,6 +173,7 @@ class OpenidClient:
139
173
  client_id: str,
140
174
  client_secret: Union[str, None] = None,
141
175
  scope: str = "openid",
176
+ min_jwks_cache_duration: timedelta = timedelta(minutes=10),
142
177
  ) -> Self:
143
178
  """
144
179
  Create a new client instance with a resolved issuer configuration as base.
@@ -152,12 +187,40 @@ class OpenidClient:
152
187
  :param client_secret: Optionally a client secret which has been assigned to your client from the issuer.
153
188
  If not supplied, this client is assumed to be *public* which means it has not client secret because it cannot be kept safe (e.g. a web-app).
154
189
  :param scope: Which scopes to request from the OP
190
+ :param min_jwks_cache_duration: Minimum time this client will cache JWKs responses from the Identity-Provider. If set to 0, the Identity-Providers Cache-Control HTTP headers will be resepcted fully.
155
191
  """
156
- keys = jwk.fetch_jwks(config.jwks_uri)
157
192
  return cls(
158
- config, keys, authentication_redirect_uri, client_id, client_secret, scope
193
+ config,
194
+ None,
195
+ authentication_redirect_uri,
196
+ client_id,
197
+ client_secret,
198
+ scope,
199
+ min_jwks_cache_duration,
159
200
  )
160
201
 
202
+ @property
203
+ def provider_keys(self) -> List[JWK]:
204
+ now = datetime.now(timezone.utc)
205
+
206
+ # return the cached data if it is still valid
207
+ if (
208
+ self._provider_keys is not None
209
+ and self._jwks_max_age is not None
210
+ and now <= self._jwks_max_age
211
+ ):
212
+ return self._provider_keys
213
+
214
+ # fetch new keys otherwise
215
+ jwks, idp_max_age = jwk.fetch_jwks_max_age(self.provider_config.jwks_uri)
216
+ self._provider_keys = jwks
217
+
218
+ # remember how long we can cache the retrieved keys
219
+ # if the idp did not return any max-age information, interpret as expiring immediately but also cache for the minimum configured time
220
+ self._jwks_max_age = max(idp_max_age or now, now + self.min_jwks_cache_duration)
221
+
222
+ return self._provider_keys
223
+
161
224
  @property
162
225
  def client_type(self) -> Literal["public", "confidential"]:
163
226
  """
@@ -309,11 +372,15 @@ class OpenidClient:
309
372
  # this implements support for pickling this class
310
373
  # it is basically the default pickle behavior but explicitly serializes keys because they are FFI backed and not normally picklable
311
374
  result = self.__dict__.copy()
312
- result["provider_keys"] = [k.serialize() for k in self.provider_keys]
375
+ if self._provider_keys is not None:
376
+ result["_provider_keys"] = [k.serialize() for k in self._provider_keys]
313
377
  return result
314
378
 
315
379
  def __setstate__(self, state: Dict[str, Any]) -> None:
316
380
  # this implements support for unpickling this class
317
381
  # it is basically the default pickle behavior but explicitly deserializes keys
318
- state["provider_keys"] = [key_from_jwk_dict(k) for k in state["provider_keys"]]
382
+ if state["_provider_keys"] is not None:
383
+ state["_provider_keys"] = [
384
+ key_from_jwk_dict(k) for k in state["_provider_keys"]
385
+ ]
319
386
  self.__dict__ = state
@@ -1,8 +1,9 @@
1
- from typing import List
1
+ from typing import List, Any, Dict, Optional
2
2
 
3
3
  from django.contrib import admin
4
4
  from django.http import HttpRequest
5
5
  from django.contrib.auth import get_user_model
6
+ from django.conf import settings
6
7
 
7
8
  from . import models
8
9
 
@@ -34,3 +35,17 @@ class OpenidUserAdmin(admin.ModelAdmin): # type: ignore[type-arg]
34
35
  result.append(f"user__{UserModel.EMAIL_FIELD}")
35
36
 
36
37
  return result
38
+
39
+ def change_view(
40
+ self,
41
+ request: Any,
42
+ object_id: Any,
43
+ form_url: Any = "",
44
+ extra_context: Optional[Dict[str, Any]] = None,
45
+ ) -> Any:
46
+ # add a settings.DEBUG context variable
47
+ extra_context = extra_context or dict()
48
+ extra_context.setdefault("settings", dict())
49
+ extra_context["settings"]["DEBUG"] = settings.DEBUG and True
50
+
51
+ return super().change_view(request, object_id, form_url, extra_context)
@@ -1,7 +1,6 @@
1
1
  <fieldset class="module aligned" aria-labelledby="{{ inline_admin_formset.formset.prefix }}-heading">
2
2
  <h2 id="{{ inline_admin_formset.formset.prefix }}-heading" class="inline-heading">OpenID Sessions</h2>
3
3
 
4
-
5
4
  {% for inline_admin_form in inline_admin_formset %}
6
5
  {% with i_session=inline_admin_form.original %}
7
6
  {% if i_session %}
@@ -27,12 +26,14 @@
27
26
  <span style="color: green;">Valid until {{ i_session.access_token_expiry | default:"-empty-" }}</span>
28
27
  {% endif %}
29
28
  </div>
29
+ {% if settings.DEBUG %}
30
30
  <div style="margin-left: 2rem; min-width: 128px; padding: 6px 0;">
31
31
  <details>
32
32
  <summary>Token</summary>
33
33
  <code style="display: block; padding: 8px; line-break: anywhere;">{{ i_session.access_token }}</code>
34
- </details>
34
+ </details>
35
35
  </div>
36
+ {% endif %}
36
37
  </div>
37
38
 
38
39
  <div class="flex-container">
@@ -44,12 +45,14 @@
44
45
  <span style="color: green;">Valid until {{ i_session.refresh_token_expiry | default:"-empty-" }}</span>
45
46
  {% endif %}
46
47
  </div>
48
+ {% if settings.DEBUG %}
47
49
  <div style="margin-left: 2rem; min-width: 128px; padding: 6px 0;">
48
50
  <details>
49
51
  <summary>Token</summary>
50
52
  <code style="display: block; padding: 8px; line-break: anywhere;">{{ i_session.refresh_token }}</code>
51
- </details>
53
+ </details>
52
54
  </div>
55
+ {% endif %}
53
56
  </div>
54
57
 
55
58
  <div class="flex-container">
@@ -0,0 +1,59 @@
1
+ """
2
+ JSON-Web-Key handling code
3
+ """
4
+
5
+ from typing import List, cast, Tuple, Any, Optional
6
+ from datetime import datetime, timedelta, timezone
7
+
8
+ from cryptojwt import JWK, KeyBundle
9
+
10
+ from simple_openid_connect.exceptions import OpenidProtocolError
11
+ import requests
12
+
13
+ requests.request
14
+
15
+
16
+ class _Httpc:
17
+ """
18
+ A wrapper around ``requests.request`` which assigns a max_age parameter as instance variable for later retrieval
19
+ """
20
+
21
+ max_age: Optional[datetime] = None
22
+
23
+ def __call__(self, *args: Any, **kwargs: Any) -> requests.Response:
24
+ now = datetime.now(timezone.utc)
25
+ response = requests.request(*args, **kwargs)
26
+
27
+ # extract Cache-Control header and save its value for later
28
+ cache_control = response.headers.get("Cache-Control")
29
+ if cache_control and "max-age" in cache_control:
30
+ http_max_age = int(cache_control.split("max-age=")[1].split(",")[0])
31
+ self.max_age = now + timedelta(seconds=http_max_age)
32
+
33
+ return response
34
+
35
+
36
+ def fetch_jwks_max_age(jwks_uri: str) -> Tuple[List[JWK], Optional[datetime]]:
37
+ """
38
+ Fetch JSON web keys from the given jwks_uri.
39
+ This uri is part of the provider configuration and used to validate responses and tokens sent by the provider.
40
+
41
+ :raises OpenidProtocolError: If fetching the keys fails
42
+ """
43
+ httpc = _Httpc()
44
+ bundle = KeyBundle(source=jwks_uri, httpc=httpc)
45
+ keys = cast(List[JWK], bundle.keys())
46
+ if not keys:
47
+ raise OpenidProtocolError("Failed to fetch keys")
48
+ return keys, httpc.max_age
49
+
50
+
51
+ def fetch_jwks(jwks_uri: str) -> List[JWK]:
52
+ """
53
+ Fetch JSON web keys from the given jwks_uri.
54
+ This uri is part of the provider configuration and used to validate responses and tokens sent by the provider.
55
+
56
+ :raises OpenidProtocolError: If fetching the keys fails
57
+ """
58
+ keys, _ = fetch_jwks_max_age(jwks_uri)
59
+ return keys
@@ -65,7 +65,7 @@ def known_provider_configs(response_mock):
65
65
  """
66
66
  Mock requests to known OpenID provider config URLs to return static content
67
67
  """
68
- # mafiasi identity
68
+ # mafiasi identity (keycloak)
69
69
  response_mock.get(
70
70
  url="https://identity.mafiasi.de/auth/realms/simple_openid_test/.well-known/openid-configuration",
71
71
  content_type="application/json",
@@ -77,6 +77,18 @@ def known_provider_configs(response_mock):
77
77
  body='{"keys":[{"kid":"JAehgk0O0uzTEht7KGCPVB_urwfsBGe22phHVDZezeo","kty":"RSA","alg":"RS256","use":"sig","n":"n1t4R6mVlBZggtmEM5HohZYg5QjLtjlRVyAMMM93H4WRPpP2Xqj3RrGwP-LZr6lQhk6w8nvPNGnFujWtXb_dBrhZDBh2z0mCNvfjJMp5Ki5sLT9nCbuZ4NkIhfx7qTzQ--GomQ6uKOSuoE12A_r9UopwuCf_1zrx0MIqN_kMeYU2px5yJW-5qbwPL9hcWcfY_Pdz5SFcQ3kDi6GlXA0PN74Ivhs9baVIv60cwB0TytQXGn__GYFZ-K5qAjIymyIy8cPRy9N27Gs29cWF-C56n1pAxwkcPY1SAQgcrspIhntQbZBeGboUx2pnQ_v8LEqVt9ahh52eAbV36YoQqu1DbQ","e":"AQAB","x5c":["MIICszCCAZsCBgGEZpqrijANBgkqhkiG9w0BAQsFADAdMRswGQYDVQQDDBJzaW1wbGVfb3BlbmlkX3Rlc3QwHhcNMjIxMTExMTIxMDIzWhcNMzIxMTExMTIxMjAzWjAdMRswGQYDVQQDDBJzaW1wbGVfb3BlbmlkX3Rlc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCfW3hHqZWUFmCC2YQzkeiFliDlCMu2OVFXIAwwz3cfhZE+k/ZeqPdGsbA/4tmvqVCGTrDye880acW6Na1dv90GuFkMGHbPSYI29+MkynkqLmwtP2cJu5ng2QiF/HupPND74aiZDq4o5K6gTXYD+v1SinC4J//XOvHQwio3+Qx5hTanHnIlb7mpvA8v2FxZx9j893PlIVxDeQOLoaVcDQ83vgi+Gz1tpUi/rRzAHRPK1Bcaf/8ZgVn4rmoCMjKbIjLxw9HL03bsazb1xYX4LnqfWkDHCRw9jVIBCByuykiGe1BtkF4ZuhTHamdD+/wsSpW31qGHnZ4BtXfpihCq7UNtAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAD3aKq2xiLNPlUcmxwtR1FihkEM+NM7VV+m+74Lf9m/lhNzuM00JZAW67gIz2kO5K4XaU84PCRzt3sELma/xVoBs3iW8AhtldTax17g0NZzpMEExzYi0vyihq8xihbx+8XrVwxQ3JBkb/y3b+otDX3xRxBKSCDuxuigL1bX7UzgJ3oe4wD6m8yN7nrMdqSMbspd8lkceKNPziclXnbKPIUwrnxdWMmB41zYcT3y+zHxqk6EDkf3NU+C3gCoRCQoBKcK4X2dwZdUbv0ginU9kROjNXc2+gP53CYDT4fqOEX/jBuljbYb9ziaQ9/5kJZ9SsazgWNjpf4VvhAsH64i7+dE="],"x5t":"PxLqu4M4uNWaUhbfkvI0tGrkhtQ","x5t#S256":"OWVSYSSNFaLyfxV0Lg6XyVDnB2t6sHwtNbzVB2fNCVc"}]}',
78
78
  )
79
79
 
80
+ # hanse.de (authentik)
81
+ response_mock.get(
82
+ url="https://auth.hanse.de/application/o/git/.well-known/openid-configuration",
83
+ content_type="application/json",
84
+ body='{"issuer":"https://auth.hanse.de/application/o/git/","authorization_endpoint":"https://auth.hanse.de/application/o/authorize/","token_endpoint":"https://auth.hanse.de/application/o/token/","userinfo_endpoint":"https://auth.hanse.de/application/o/userinfo/","end_session_endpoint":"https://auth.hanse.de/application/o/git/end-session/","introspection_endpoint":"https://auth.hanse.de/application/o/introspect/","revocation_endpoint":"https://auth.hanse.de/application/o/revoke/","device_authorization_endpoint":"https://auth.hanse.de/application/o/device/","backchannel_logout_supported":true,"backchannel_logout_session_supported":true,"response_types_supported":["code","id_token","id_token token","code token","code id_token","code id_token token"],"response_modes_supported":["query","fragment","form_post"],"jwks_uri":"https://auth.hanse.de/application/o/git/jwks/","grant_types_supported":["authorization_code","refresh_token","implicit","client_credentials","password","urn:ietf:params:oauth:grant-type:device_code"],"id_token_signing_alg_values_supported":["RS256"],"subject_types_supported":["public"],"token_endpoint_auth_methods_supported":["client_secret_post","client_secret_basic"],"acr_values_supported":["goauthentik.io/providers/oauth2/default"],"scopes_supported":["email","profile","offline_access","openid"],"request_parameter_supported":false,"claims_supported":["sub","iss","aud","exp","iat","auth_time","acr","amr","nonce","email","email_verified","name","given_name","preferred_username","nickname","groups"],"claims_parameter_supported":false,"code_challenge_methods_supported":["plain","S256"]}',
85
+ )
86
+ response_mock.get(
87
+ url="https://auth.hanse.de/application/o/git/jwks/",
88
+ content_type="application/json",
89
+ body='{"keys":[{"alg":"RS256","kid":"25bfaaaa8e5aed3a44d130d66ac81022","kty":"RSA","use":"sig","n":"tpOexiJZfPHxl3GOiQJCCmkM9GVkG7R-DUYwUYTuTDnGR0XClAD3HRWe7yW-Lzurk8kBH6OjfLU3aYhQAhjWBblvmTSXcIDRRkckHwYj-M4b59wxZW26rdSuI0FzUrJOEOMpNtm_HzxvdUyWYQQXYl7P9BNuJtTT0AG3Od-VC7hZMSLU_42cRxo3XTzbqpFfJlF1TRtjDh7_RB8oofohGF2LRKB-iP3wrRFO2_erLJI5rbxPUBiVhDisPau0R9kvIC-BbcL_RBHfZyZgl78PkWEDaRXCZ8JtweHnoMWZw4-6sIQHDcbigz6UFVlngcaV3aktPNobLZyJcAR4K_Xfz_FA135HOXpqzsS064TuksOj8TnklMHoz4k-Y7Q8FZFvFgJw_R4fxOG23IMfzWHmAEt8qhrcLfd_kZCP2dUbmH_z984NZ0Y34fZRSP7m5CMxGFzOFpueSjCBWRpgzifpEP4LBVZ1PyP5a06xHf_87imXdZ6oYgBcGBVmmsM9lSJhR4-XYuyz6sHvIXnMSRMw7LG6hqfh2fKUnRQIzcP_ldU6nQdTzA0cRcsYJ7HnoWCJj78uPo5vLVTjz3eoQuk8z5Beyce43SdkUH1kHbaobI8uxuwOcMnQVnRge3YFIpZR3SO5UPjSyjNhDFfuz2jt5idQT9cKX1vLFo6BtUf_1J8","e":"AQAB","x5c":["MIIFUzCCAzugAwIBAgIQX/aqWMV4Rf27MyUo/KcDYDANBgkqhkiG9w0BAQsFADAeMRwwGgYDVQQDDBNhdXRoZW50aWsgMjAyNS4xMC4wMB4XDTI1MTAyOTIzMjQ0M1oXDTI2MTAzMDIzMjQ0M1owVjEqMCgGA1UEAwwhYXV0aGVudGlrIFNlbGYtc2lnbmVkIENlcnRpZmljYXRlMRIwEAYDVQQKDAlhdXRoZW50aWsxFDASBgNVBAsMC1NlbGYtc2lnbmVkMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAtpOexiJZfPHxl3GOiQJCCmkM9GVkG7R+DUYwUYTuTDnGR0XClAD3HRWe7yW+Lzurk8kBH6OjfLU3aYhQAhjWBblvmTSXcIDRRkckHwYj+M4b59wxZW26rdSuI0FzUrJOEOMpNtm/HzxvdUyWYQQXYl7P9BNuJtTT0AG3Od+VC7hZMSLU/42cRxo3XTzbqpFfJlF1TRtjDh7/RB8oofohGF2LRKB+iP3wrRFO2/erLJI5rbxPUBiVhDisPau0R9kvIC+BbcL/RBHfZyZgl78PkWEDaRXCZ8JtweHnoMWZw4+6sIQHDcbigz6UFVlngcaV3aktPNobLZyJcAR4K/Xfz/FA135HOXpqzsS064TuksOj8TnklMHoz4k+Y7Q8FZFvFgJw/R4fxOG23IMfzWHmAEt8qhrcLfd/kZCP2dUbmH/z984NZ0Y34fZRSP7m5CMxGFzOFpueSjCBWRpgzifpEP4LBVZ1PyP5a06xHf/87imXdZ6oYgBcGBVmmsM9lSJhR4+XYuyz6sHvIXnMSRMw7LG6hqfh2fKUnRQIzcP/ldU6nQdTzA0cRcsYJ7HnoWCJj78uPo5vLVTjz3eoQuk8z5Beyce43SdkUH1kHbaobI8uxuwOcMnQVnRge3YFIpZR3SO5UPjSyjNhDFfuz2jt5idQT9cKX1vLFo6BtUf/1J8CAwEAAaNVMFMwUQYDVR0RAQH/BEcwRYJDUm5RNjFyUnZGUm1rU1d2b2tuVWFHMVlmWHIyQ0xzcEt6Mmw4UVdodC5zZWxmLXNpZ25lZC5nb2F1dGhlbnRpay5pbzANBgkqhkiG9w0BAQsFAAOCAgEAEa+ERlfh5DxgWtGa0U2WA7E+TcoF12ZsXfO0NOIMPZ/o9Pqou1Huc21Azzyh0dMAbJEuFVFmnqNLwqvjB6iK25Gk3pJakaN9iYvzhmAsb9eYLfGmOD6Bk9fHO6chEgVCxNtPja0ROV8SaEA/r8xT7pfpPJBnbjxgQjg1UDeszv8FXij0oct9tojkDPnMWJBv0m4SfZL33Vm093P9aEGLivfi/fAbjoA1nnOF+KOyoXGMN28qXUnYuc7ekjwaCAKIfkG/b06oaH6+0qV9Fl/58HMegoqPuJTEDrueM5hNKoLDhcb/unXyko1Cd/BRIID4uvmDhZl6P3HWo/lV9qW1lZwl2Dc63Hmq0XD1Vp6m8eBdT+yjXphwcsTadfVsKnVRbc+OZ27cMY2b/FHZ6kHE/qUUk+y11BrcVFx5uzF3gkCy5JmXnzB2249fJ/ixajRGyNZVt1rt8ItYwyRUpkzaSvhsl+1N+beRVm4rUFSNbOSCNq3/4NWmNhv+kvE9bS9w/vXZpkhlKRBUgCg8hZBdVSdloO4aJPCwKOgf/C2F7KtIqmj3vIxmQ9d6yD1OXGExtLHtBlE1g5n7N1At85YLFpeJAGqMwpZH430YKAoDuVf/+C5uSzLiAsubXW8d5gt7FTemJopu8/mmLswwC3eOuCRlIg49X76SOKOjRTJVRXA="],"x5t":"B6EFPg14J2vLHlggZg2c_4d5uxE","x5t#S256":"9FYLuDimrcpGF9RbZw7JNWZzoMElCaM7Yg8J7yf_FtI"}]}',
90
+ )
91
+
80
92
  # google
81
93
  response_mock.get(
82
94
  "https://accounts.google.com/.well-known/openid-configuration",
@@ -102,12 +114,23 @@ def dummy_provider_config(jwks, response_mock):
102
114
  id_token_signing_alg_values_supported=["RS256"],
103
115
  ).model_dump(exclude_defaults=True),
104
116
  )
117
+
105
118
  response_mock.get(
106
119
  url="https://provider.example.com/jwks",
107
120
  body=jwks.jwks(),
108
121
  content_type="application/json",
109
122
  )
110
123
 
124
+ response_mock.get(
125
+ url="https://provider.example.com/jwks-with-expiry",
126
+ body=jwks.jwks(),
127
+ content_type="application/json",
128
+ headers={
129
+ # 60 second expiry with some other directives around that need to be stripped out
130
+ "Cache-Control": "no-transform, max-age=60, public",
131
+ },
132
+ )
133
+
111
134
 
112
135
  @pytest.fixture
113
136
  def dummy_auth_response(response_mock):
@@ -102,9 +102,16 @@ def test_token_introspection(dummy_provider_config, dummy_token_introspection_re
102
102
 
103
103
 
104
104
  def test_pickling(dummy_provider_config):
105
- # arrange
105
+ # arrange (make client and ensure provider JWKs is fetched)
106
106
  client = make_client()
107
+ _ = client.provider_keys
108
+ assert client._provider_keys is not None
107
109
 
108
110
  # act (assert not throwing)
109
111
  enc = pickle.dumps(client)
110
- _ = pickle.loads(enc)
112
+ client2 = pickle.loads(enc)
113
+
114
+ # assert provider key equality
115
+ assert client._provider_keys is not None
116
+ assert client2._provider_keys is not None
117
+ assert client._provider_keys == client2._provider_keys
@@ -0,0 +1,47 @@
1
+ from datetime import datetime, timezone
2
+
3
+ from freezegun import freeze_time
4
+ from cryptojwt import JWK
5
+
6
+ from simple_openid_connect.jwk import fetch_jwks, fetch_jwks_max_age
7
+
8
+
9
+ def test_load_mafiasi_jwks(known_provider_configs):
10
+ keys = fetch_jwks(
11
+ "https://identity.mafiasi.de/auth/realms/simple_openid_test/protocol/openid-connect/certs"
12
+ )
13
+ assert all(isinstance(k, JWK) for k in keys)
14
+ assert len(keys) == 1
15
+ assert keys[0].kid == "JAehgk0O0uzTEht7KGCPVB_urwfsBGe22phHVDZezeo"
16
+
17
+
18
+ def test_load_hanse_jwks(known_provider_configs):
19
+ keys = fetch_jwks("https://auth.hanse.de/application/o/git/jwks/")
20
+ assert all(isinstance(k, JWK) for k in keys)
21
+ assert len(keys) == 1
22
+ assert keys[0].kid == "25bfaaaa8e5aed3a44d130d66ac81022"
23
+
24
+
25
+ def test_load_dummy_jwks(dummy_provider_config):
26
+ keys = fetch_jwks("https://provider.example.com/jwks")
27
+ assert all(isinstance(k, JWK) for k in keys)
28
+ assert len(keys) == 1
29
+
30
+
31
+ def test_load_dummy_jwks_max_age_without_cache_header(dummy_provider_config):
32
+ keys, max_age = fetch_jwks_max_age("https://provider.example.com/jwks")
33
+ assert all(isinstance(k, JWK) for k in keys)
34
+ assert len(keys) == 1
35
+ assert max_age is None
36
+
37
+
38
+ @freeze_time("2026-01-01T00:00:00")
39
+ def test_load_dummy_jwks_max_age_with_cache_header(dummy_provider_config):
40
+ keys, max_age = fetch_jwks_max_age(
41
+ "https://provider.example.com/jwks-with-expiry"
42
+ ) # returns a 60 second Cache-Control header
43
+ assert all(isinstance(k, JWK) for k in keys)
44
+ assert len(keys) == 1
45
+ assert max_age == datetime(
46
+ year=2026, month=1, day=1, hour=0, minute=1, second=0, tzinfo=timezone.utc
47
+ )
@@ -1,23 +0,0 @@
1
- """
2
- JSON-Web-Key handling code
3
- """
4
-
5
- from typing import List, cast
6
-
7
- from cryptojwt import JWK, KeyBundle
8
-
9
- from simple_openid_connect.exceptions import OpenidProtocolError
10
-
11
-
12
- def fetch_jwks(jwks_uri: str) -> List[JWK]:
13
- """
14
- Fetch JSON web keys from the given jwks_uri.
15
- This uri is part of the provider configuration and used to validate responses and tokens sent by the provider.
16
-
17
- :raises OpenidProtocolError: If fetching the keys fails
18
- """
19
- bundle = KeyBundle(source=jwks_uri)
20
- keys = cast(List[JWK], bundle.keys())
21
- if not keys:
22
- raise OpenidProtocolError("Failed to fetch keys")
23
- return keys
@@ -1,18 +0,0 @@
1
- from cryptojwt import JWK
2
-
3
- from simple_openid_connect.jwk import fetch_jwks
4
-
5
-
6
- def test_load_mafiasi_jwks(known_provider_configs):
7
- keys = fetch_jwks(
8
- "https://identity.mafiasi.de/auth/realms/simple_openid_test/protocol/openid-connect/certs"
9
- )
10
- assert all(isinstance(k, JWK) for k in keys)
11
- assert len(keys) == 1
12
- assert keys[0].kid == "JAehgk0O0uzTEht7KGCPVB_urwfsBGe22phHVDZezeo"
13
-
14
-
15
- def test_load_dummy_jwks(dummy_provider_config):
16
- keys = fetch_jwks("https://provider.example.com/jwks")
17
- assert all(isinstance(k, JWK) for k in keys)
18
- assert len(keys) == 1