grantor-django 0.1.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 (31) hide show
  1. grantor_django-0.1.0/.gitignore +18 -0
  2. grantor_django-0.1.0/PKG-INFO +64 -0
  3. grantor_django-0.1.0/README.md +31 -0
  4. grantor_django-0.1.0/pyproject.toml +63 -0
  5. grantor_django-0.1.0/src/grantor_django/__init__.py +29 -0
  6. grantor_django-0.1.0/src/grantor_django/admin.py +349 -0
  7. grantor_django-0.1.0/src/grantor_django/apps.py +78 -0
  8. grantor_django-0.1.0/src/grantor_django/backends.py +240 -0
  9. grantor_django-0.1.0/src/grantor_django/client.py +44 -0
  10. grantor_django-0.1.0/src/grantor_django/conf.py +274 -0
  11. grantor_django-0.1.0/src/grantor_django/drf.py +323 -0
  12. grantor_django-0.1.0/src/grantor_django/management/__init__.py +0 -0
  13. grantor_django-0.1.0/src/grantor_django/management/commands/__init__.py +0 -0
  14. grantor_django-0.1.0/src/grantor_django/management/commands/grantor_break_glass.py +190 -0
  15. grantor_django-0.1.0/src/grantor_django/py.typed +0 -0
  16. grantor_django-0.1.0/src/grantor_django/transaction.py +110 -0
  17. grantor_django-0.1.0/src/grantor_django/urls.py +49 -0
  18. grantor_django-0.1.0/src/grantor_django/views.py +292 -0
  19. grantor_django-0.1.0/tests/conftest.py +141 -0
  20. grantor_django-0.1.0/tests/django_support.py +8 -0
  21. grantor_django-0.1.0/tests/test_admin.py +249 -0
  22. grantor_django-0.1.0/tests/test_break_glass.py +228 -0
  23. grantor_django-0.1.0/tests/test_configuration.py +273 -0
  24. grantor_django-0.1.0/tests/test_consumer_findings.py +133 -0
  25. grantor_django-0.1.0/tests/test_drf.py +299 -0
  26. grantor_django-0.1.0/tests/test_flow.py +210 -0
  27. grantor_django-0.1.0/tests/test_provisioning.py +65 -0
  28. grantor_django-0.1.0/tests/test_session_backends.py +51 -0
  29. grantor_django-0.1.0/tests/test_signout.py +112 -0
  30. grantor_django-0.1.0/tests/test_soft_delete.py +140 -0
  31. grantor_django-0.1.0/tests/test_spa_shape.py +93 -0
@@ -0,0 +1,18 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
7
+ venv/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ .mypy_cache/
11
+ .coverage
12
+ htmlcov/
13
+ .tox/
14
+ .env
15
+ .DS_Store
16
+ uv.lock.bak
17
+ .venv*/
18
+ node_modules/
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.5
2
+ Name: grantor-django
3
+ Version: 0.1.0
4
+ Summary: Sign in with Grantor from Django — views, an authentication backend, DRF and the admin.
5
+ Project-URL: Homepage, https://grantor.id
6
+ Project-URL: Documentation, https://docs.grantor.id
7
+ Project-URL: Source, https://github.com/Grantor-id/grantor-python
8
+ Project-URL: Issues, https://github.com/Grantor-id/grantor-python/issues
9
+ Author: Allure Labs
10
+ License-Expression: MIT
11
+ Keywords: django,grantor,oauth2,oidc,openid-connect,sso
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Web Environment
14
+ Classifier: Framework :: Django
15
+ Classifier: Framework :: Django :: 4.2
16
+ Classifier: Framework :: Django :: 5.0
17
+ Classifier: Framework :: Django :: 5.1
18
+ Classifier: Framework :: Django :: 5.2
19
+ Classifier: Intended Audience :: Developers
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Topic :: Security
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.10
27
+ Requires-Dist: django<6,>=4.2
28
+ Requires-Dist: grantor<0.2,>=0.1.0
29
+ Provides-Extra: admin
30
+ Provides-Extra: drf
31
+ Requires-Dist: djangorestframework>=3.14; extra == 'drf'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # `grantor-django`
35
+
36
+ Sign in with [Grantor](https://grantor.id) from Django — an OAuth 2.1 /
37
+ OpenID Connect provider.
38
+
39
+ ```sh
40
+ pip install grantor-django # session sign-in
41
+ pip install grantor-django[drf] # + a DRF API that answers to an access token
42
+ pip install grantor-django[admin] # + a Django admin with no local password
43
+ ```
44
+
45
+ ```python
46
+ INSTALLED_APPS = [..., "grantor_django"]
47
+
48
+ GRANTOR_ISSUER = "https://acme.api.grantor.id"
49
+ GRANTOR_CLIENT_ID = env("GRANTOR_CLIENT_ID")
50
+ GRANTOR_CLIENT_SECRET = env("GRANTOR_CLIENT_SECRET")
51
+ GRANTOR_CALLBACK_BASE_URL = "https://app.example.com"
52
+ ```
53
+
54
+ ```python
55
+ urlpatterns = [path("auth/", include("grantor_django.urls"))]
56
+ ```
57
+
58
+ Every endpoint — authorization, token, JWKS, end-session — is read from the
59
+ issuer's discovery document. The issuer is the only string you configure.
60
+
61
+ Documentation: [docs.grantor.id](https://docs.grantor.id).
62
+ Source and security policy: [Grantor-id/grantor-python](https://github.com/Grantor-id/grantor-python).
63
+
64
+ MIT. © Allure Labs.
@@ -0,0 +1,31 @@
1
+ # `grantor-django`
2
+
3
+ Sign in with [Grantor](https://grantor.id) from Django — an OAuth 2.1 /
4
+ OpenID Connect provider.
5
+
6
+ ```sh
7
+ pip install grantor-django # session sign-in
8
+ pip install grantor-django[drf] # + a DRF API that answers to an access token
9
+ pip install grantor-django[admin] # + a Django admin with no local password
10
+ ```
11
+
12
+ ```python
13
+ INSTALLED_APPS = [..., "grantor_django"]
14
+
15
+ GRANTOR_ISSUER = "https://acme.api.grantor.id"
16
+ GRANTOR_CLIENT_ID = env("GRANTOR_CLIENT_ID")
17
+ GRANTOR_CLIENT_SECRET = env("GRANTOR_CLIENT_SECRET")
18
+ GRANTOR_CALLBACK_BASE_URL = "https://app.example.com"
19
+ ```
20
+
21
+ ```python
22
+ urlpatterns = [path("auth/", include("grantor_django.urls"))]
23
+ ```
24
+
25
+ Every endpoint — authorization, token, JWKS, end-session — is read from the
26
+ issuer's discovery document. The issuer is the only string you configure.
27
+
28
+ Documentation: [docs.grantor.id](https://docs.grantor.id).
29
+ Source and security policy: [Grantor-id/grantor-python](https://github.com/Grantor-id/grantor-python).
30
+
31
+ MIT. © Allure Labs.
@@ -0,0 +1,63 @@
1
+ [project]
2
+ name = "grantor-django"
3
+ version = "0.1.0"
4
+ description = "Sign in with Grantor from Django — views, an authentication backend, DRF and the admin."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "MIT"
8
+ authors = [{ name = "Allure Labs" }]
9
+ keywords = ["django", "oauth2", "oidc", "openid-connect", "sso", "grantor"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Environment :: Web Environment",
13
+ "Framework :: Django",
14
+ "Framework :: Django :: 4.2",
15
+ "Framework :: Django :: 5.0",
16
+ "Framework :: Django :: 5.1",
17
+ "Framework :: Django :: 5.2",
18
+ "Intended Audience :: Developers",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Security",
24
+ "Typing :: Typed",
25
+ ]
26
+ dependencies = [
27
+ # Lockstep through 0.x: the two packages are cut from one commit and one
28
+ # CI run, so a `grantor-django` can never be installed beside a core it
29
+ # was not tested against. They decouple at 1.0.
30
+ #
31
+ # Written out rather than as `grantor~=0.1`, because `~=` excludes
32
+ # pre-releases: `grantor~=0.1` cannot resolve `grantor 0.1.0a0`, which
33
+ # is the very core this release is cut with, and
34
+ # `pip install grantor-django==0.1.0a0` fails on it. A specifier that
35
+ # names a pre-release admits them. Same lockstep, one that installs.
36
+ "grantor>=0.1.0,<0.2",
37
+ # Capped at the declared support matrix — 4.2 LTS through 5.x. An
38
+ # uncapped range would let a fresh install resolve to a Django this
39
+ # release was never tested against, which is a promise the
40
+ # classifiers above do not make.
41
+ "django>=4.2,<6",
42
+ ]
43
+
44
+ [project.optional-dependencies]
45
+ drf = ["djangorestframework>=3.14"]
46
+ # Empty on purpose, and kept rather than tidied away: the admin needs
47
+ # nothing Django does not already provide, but `pip install
48
+ # grantor-django[admin]` is in the README, and an extra that does not exist
49
+ # makes that line print a warning about a typo the reader did not make.
50
+ admin = []
51
+
52
+ [project.urls]
53
+ Homepage = "https://grantor.id"
54
+ Documentation = "https://docs.grantor.id"
55
+ Source = "https://github.com/Grantor-id/grantor-python"
56
+ Issues = "https://github.com/Grantor-id/grantor-python/issues"
57
+
58
+ [build-system]
59
+ requires = ["hatchling"]
60
+ build-backend = "hatchling.build"
61
+
62
+ [tool.hatch.build.targets.wheel]
63
+ packages = ["src/grantor_django"]
@@ -0,0 +1,29 @@
1
+ """Sign in with Grantor, from Django.
2
+
3
+ INSTALLED_APPS = [..., "grantor_django"]
4
+
5
+ GRANTOR_ISSUER = "https://acme.api.grantor.id"
6
+ GRANTOR_CLIENT_ID = env("GRANTOR_CLIENT_ID")
7
+ GRANTOR_CLIENT_SECRET = env("GRANTOR_CLIENT_SECRET")
8
+ GRANTOR_CALLBACK_BASE_URL = "https://app.example.com"
9
+
10
+ urlpatterns = [path("auth/", include("grantor_django.urls"))]
11
+
12
+ That is the whole configuration. Every endpoint — authorization, token,
13
+ JWKS, end-session — comes from the issuer's discovery document.
14
+
15
+ The dividing rule between this package and ``grantor``, sharp enough for
16
+ review: anything that touches ``settings``, ``request``, ``session``, the
17
+ ``User`` model, or returns an ``HttpResponse`` belongs here. Everything else
18
+ belongs in the core, where a framework this package has never heard of can
19
+ compose it.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ __all__ = ["__version__", "default_app_config"]
25
+
26
+ __version__ = "0.1.0"
27
+
28
+ # Django ≥3.2 discovers AppConfig automatically; named here for readers.
29
+ default_app_config = "grantor_django.apps.GrantorDjangoConfig"
@@ -0,0 +1,349 @@
1
+ """A Django admin with no local password at all.
2
+
3
+ The third integration shape, and the one with the sharpest security
4
+ argument. The admin is a privileged surface on a public host, so it has no
5
+ local password path: ``ModelBackend`` need not be installed, admin users are
6
+ created with an unusable password, and the only way in is the authorization
7
+ code flow. There is nothing to brute-force and no credential to leak,
8
+ because none exists on this side.
9
+
10
+ Django's login form is **replaced**, not hidden. A hidden form is a form
11
+ somebody finds.
12
+
13
+ from grantor_django.admin import GrantorAdminSite
14
+
15
+ site = GrantorAdminSite(name="admin")
16
+
17
+ GRANTOR_ADMIN_CLIENT_ID = env("GRANTOR_ADMIN_CLIENT_ID")
18
+ GRANTOR_ADMIN_CLIENT_SECRET = env("GRANTOR_ADMIN_CLIENT_SECRET")
19
+ GRANTOR_ADMIN_ROLE = "superadmin"
20
+
21
+ Privilege comes from a Grantor role and is **re-read on every sign-in,
22
+ including off**. Revoking the role at the issuer takes effect the next time
23
+ the person authenticates, rather than whenever somebody remembers to untick
24
+ a box in a database.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+ import secrets
31
+ from dataclasses import asdict
32
+ from typing import Any
33
+
34
+ from django.conf import settings
35
+ from django.contrib.admin import AdminSite
36
+ from django.contrib.auth import get_user_model
37
+ from django.contrib.auth import login as django_login
38
+ from django.contrib.auth import logout as django_logout
39
+ from django.core import signing
40
+ from django.core.exceptions import ImproperlyConfigured, PermissionDenied
41
+ from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
42
+ from django.urls import path, reverse
43
+ from grantor import GrantorClient, GrantorError, ProtocolError, TokenError, parse_redirect_error
44
+
45
+ from . import conf, transaction
46
+ from .views import relative_path_only
47
+
48
+ __all__ = ["GrantorAdminSite", "user_for_claims", "admin_client", "BREAK_GLASS_SETTING"]
49
+
50
+ logger = logging.getLogger("grantor_django.admin")
51
+
52
+ BREAK_GLASS_SETTING = "GRANTOR_ADMIN_BREAK_GLASS"
53
+
54
+ # Its own cookie, so an admin sign-in and an application sign-in in the same
55
+ # browser cannot overwrite each other's transaction.
56
+ _TXN_COOKIE = "grantor_admin_txn"
57
+
58
+
59
+ def _required(name: str) -> str:
60
+ value = getattr(settings, name, None)
61
+ if not value or not isinstance(value, str):
62
+ raise ImproperlyConfigured(
63
+ f"{name} must be set to use the Grantor admin. The admin is a separate "
64
+ "OAuth client from the application's: it asks for the `roles` scope and "
65
+ "its own redirect URI, and sharing one client between the two would mean "
66
+ "a token for the application is a token for the admin."
67
+ )
68
+ return value
69
+
70
+
71
+ def admin_client() -> GrantorClient:
72
+ """The admin's own client — deliberately not the application's."""
73
+ return GrantorClient(
74
+ conf.issuer(),
75
+ http=conf.http_client(),
76
+ client_id=_required("GRANTOR_ADMIN_CLIENT_ID"),
77
+ client_secret=_required("GRANTOR_ADMIN_CLIENT_SECRET"),
78
+ # `roles` is the whole point: it is what says whether this person may
79
+ # be here at all.
80
+ scope="openid profile email roles",
81
+ auth_method=conf.get("GRANTOR_AUTH_METHOD"),
82
+ timeout=conf.get("GRANTOR_TIMEOUT"),
83
+ )
84
+
85
+
86
+ def user_for_claims(claims: dict[str, Any]) -> Any:
87
+ """Map a Grantor identity onto an admin user, or refuse.
88
+
89
+ Both flags are set from the role on **every** sign-in, including to
90
+ ``False``. A person whose role was revoked at the issuer loses the admin
91
+ the next time they authenticate — and the record here says so, rather
92
+ than keeping a stale ``is_superuser`` that some other surface might one
93
+ day believe.
94
+ """
95
+ required_role = getattr(settings, "GRANTOR_ADMIN_ROLE", "superadmin")
96
+ roles = claims.get("roles") or []
97
+ holds_it = isinstance(roles, (list, tuple)) and required_role in roles
98
+
99
+ User = get_user_model()
100
+ username_field = getattr(User, "USERNAME_FIELD", "username")
101
+ user = User._default_manager.filter(**{username_field: claims["sub"]}).first()
102
+
103
+ if user is None:
104
+ # A person being refused leaves no record behind. Creating one would
105
+ # let anybody who can reach the issuer populate this table, and a row
106
+ # in a table called `user` on an admin surface reads like an account
107
+ # whether or not its flags say otherwise.
108
+ if not holds_it:
109
+ _refuse(claims, required_role)
110
+ user = User(**{username_field: claims["sub"], "email": claims.get("email", "")})
111
+ # No password path exists on this side, so there is nothing to guess.
112
+ user.set_unusable_password()
113
+
114
+ if claims.get("email"):
115
+ user.email = claims["email"]
116
+ user.is_active = True
117
+ user.is_staff = holds_it
118
+ user.is_superuser = holds_it
119
+ user.save()
120
+
121
+ if not holds_it:
122
+ # An account that already exists is told the truth even as it is
123
+ # turned away: never creating and never updating are different
124
+ # rules, and only the first one is right.
125
+ _refuse(claims, required_role)
126
+ return user
127
+
128
+
129
+ def _refuse(claims: dict[str, Any], required_role: str) -> None:
130
+ logger.warning(
131
+ "grantor admin: refused sub %s — the %s role is required",
132
+ claims.get("sub"),
133
+ required_role,
134
+ )
135
+ raise PermissionDenied(f"the {required_role} role is required to use this admin")
136
+
137
+
138
+ class GrantorAdminSite(AdminSite):
139
+ """An admin whose login view is a redirect to the issuer."""
140
+
141
+ def get_urls(self) -> list[Any]:
142
+ return [
143
+ path("grantor/callback", self.grantor_callback, name="grantor_callback"),
144
+ *super().get_urls(),
145
+ ]
146
+
147
+ def _redirect_uri(self, request: HttpRequest) -> str:
148
+ return request.build_absolute_uri(reverse(f"{self.name}:grantor_callback"))
149
+
150
+ def login(self, request: HttpRequest, extra_context: Any = None) -> HttpResponse:
151
+ """No password form. Straight to the issuer.
152
+
153
+ Unless break-glass is on — see :meth:`_break_glass_login`, and read
154
+ its docstring before turning it on.
155
+ """
156
+ if getattr(settings, BREAK_GLASS_SETTING, False):
157
+ return self._break_glass_login(request, extra_context)
158
+
159
+ # Same guard the session views use. Unvalidated, this sends the
160
+ # browser off-site **after a successful sign-in** — the moment a
161
+ # person is most likely to trust what they are looking at, on the
162
+ # most privileged surface the product has. The library already had
163
+ # `relative_path_only`; this path simply was not using it.
164
+ next_url = relative_path_only(request.GET.get("next"), reverse(f"{self.name}:index"))
165
+ try:
166
+ authorization = admin_client().start_authorization(
167
+ redirect_uri=self._redirect_uri(request)
168
+ )
169
+ except GrantorError as exc:
170
+ raise PermissionDenied("the issuer is unreachable") from exc
171
+
172
+ response = HttpResponseRedirect(authorization.url)
173
+ _issue_txn(
174
+ response,
175
+ transaction.Transaction(
176
+ state=authorization.state,
177
+ code_verifier=authorization.code_verifier,
178
+ nonce=authorization.nonce,
179
+ next_url=next_url,
180
+ ),
181
+ )
182
+ return response
183
+
184
+ def _break_glass_login(self, request: HttpRequest, extra_context: Any = None) -> HttpResponse:
185
+ """Django's password form, back, on purpose. **This is dangerous.**
186
+
187
+ It exists because this product has an incident on record where
188
+ enforcing a second factor locked out the only enrolled account, and
189
+ an auth library that ships no way back is repeating it. It is still
190
+ the thing an attacker most wants you to have left on.
191
+
192
+ Turning it on requires two deliberate acts, not one: setting
193
+ ``GRANTOR_ADMIN_BREAK_GLASS = True`` **and** giving somebody a usable
194
+ password *and* ``is_staff``, which no normal path in this library
195
+ ever does (``manage.py grantor_break_glass`` is the supported way,
196
+ and it says the same things this docstring does).
197
+
198
+ ``GRANTOR_ADMIN_BREAK_GLASS`` is a **Django setting, not an
199
+ environment variable**. A project that keeps configuration in the
200
+ environment has to read it across itself::
201
+
202
+ GRANTOR_ADMIN_BREAK_GLASS = env.bool(
203
+ "GRANTOR_ADMIN_BREAK_GLASS", default=False
204
+ )
205
+
206
+ Setting the variable alone changes nothing this library can see. A
207
+ consumer set it, force-deployed, and the admin went on redirecting
208
+ to the issuer — which reads as the break-glass being broken rather
209
+ than as never having been switched on.
210
+
211
+ And it needs ``ModelBackend`` in ``AUTHENTICATION_BACKENDS``. A
212
+ project following this module's own advice does not install it, and
213
+ then no password authenticates at all.
214
+
215
+ Afterwards, in the audit trail at the issuer, check:
216
+
217
+ * every admin sign-in during the window — break-glass sign-ins do
218
+ **not** appear there, so anything that does was a normal sign-in
219
+ and anything that happened without one was not;
220
+ * the local ``last_login`` of the account you gave a password to;
221
+ * that the password was removed **and ``is_staff`` revoked** and the
222
+ setting turned back off — the step people forget, because by then
223
+ it is working again. A close that leaves a staff row behind leaves
224
+ a standing admin account on a surface whose whole argument is that
225
+ none exists.
226
+
227
+ Note what this path does **not** do: it does not consult the issuer
228
+ at all, so the Grantor role check is bypassed rather than merely
229
+ deferred. That is the point — the role check is what is broken when
230
+ you need this — and it is the reason the window is closed rather
231
+ than left open because it is working.
232
+ """
233
+ logger.error(
234
+ "grantor admin: BREAK GLASS is enabled — the admin is accepting local "
235
+ "passwords. Turn %s off once you are back in.",
236
+ BREAK_GLASS_SETTING,
237
+ )
238
+ return super().login(request, extra_context)
239
+
240
+ def logout(self, request: HttpRequest, extra_context: Any = None) -> HttpResponse:
241
+ """End the local session, then the issuer's.
242
+
243
+ In that order. Somebody who signs out of an admin and is silently
244
+ signed back in by a session they were never shown has been told
245
+ something untrue — and on this surface, that is a privileged session
246
+ they believe is closed.
247
+ """
248
+ id_token = request.COOKIES.get(conf.get("GRANTOR_ID_TOKEN_COOKIE_NAME"), "")
249
+ django_logout(request)
250
+
251
+ destination = reverse(f"{self.name}:index")
252
+ if id_token:
253
+ try:
254
+ destination = admin_client().end_session_url(
255
+ id_token,
256
+ post_logout_redirect_uri=conf.get("GRANTOR_POST_LOGOUT_REDIRECT_URI"),
257
+ )
258
+ except GrantorError:
259
+ logger.warning(
260
+ "grantor admin: could not build the end-session URL; signed out locally"
261
+ )
262
+
263
+ response = HttpResponseRedirect(destination)
264
+ response.delete_cookie(conf.get("GRANTOR_ID_TOKEN_COOKIE_NAME"), path="/")
265
+ return response
266
+
267
+ def grantor_callback(self, request: HttpRequest) -> HttpResponse:
268
+ refusal = parse_redirect_error(request.GET)
269
+ if refusal:
270
+ # Surfaced rather than retried. A retry loop against a refusal is
271
+ # how somebody ends up watching a redirect that never settles.
272
+ raise PermissionDenied(f"the issuer refused the sign-in: {refusal}")
273
+
274
+ try:
275
+ txn = _read_txn(request)
276
+ except transaction.InvalidTransaction as exc:
277
+ raise PermissionDenied("this sign-in expired; start again") from exc
278
+
279
+ if not secrets.compare_digest(request.GET.get("state", ""), txn.state):
280
+ raise PermissionDenied("state mismatch")
281
+
282
+ client = admin_client()
283
+ try:
284
+ tokens = client.exchange_code(
285
+ request.GET.get("code", ""),
286
+ redirect_uri=self._redirect_uri(request),
287
+ code_verifier=txn.code_verifier,
288
+ )
289
+ except ProtocolError as exc:
290
+ # Logged as well as raised. Django renders PermissionDenied as a
291
+ # bare 403 page with no detail — correct for a browser, useless
292
+ # for whoever has to work out why the admin stopped letting
293
+ # people in. The code is safe to log and is the whole diagnosis.
294
+ logger.warning("grantor admin: token exchange refused: %s", exc.code)
295
+ raise PermissionDenied(
296
+ f"the issuer refused the authorization code: {exc.code}"
297
+ ) from exc
298
+ except GrantorError as exc:
299
+ raise PermissionDenied("the issuer is unreachable") from exc
300
+
301
+ if not tokens.id_token:
302
+ raise PermissionDenied("the issuer returned no ID token")
303
+ try:
304
+ claims = client.verify_id_token(tokens.id_token, nonce=txn.nonce)
305
+ except TokenError as exc:
306
+ logger.warning("grantor admin: id token rejected: %s", exc.reason)
307
+ raise PermissionDenied(f"the ID token was rejected: {exc.reason}") from exc
308
+
309
+ user = user_for_claims(claims)
310
+ django_login(request, user, backend="django.contrib.auth.backends.ModelBackend")
311
+
312
+ response = HttpResponseRedirect(txn.next_url or reverse(f"{self.name}:index"))
313
+ _clear_txn(response)
314
+ response.set_cookie(
315
+ conf.get("GRANTOR_ID_TOKEN_COOKIE_NAME"),
316
+ tokens.id_token,
317
+ httponly=True,
318
+ secure=conf.cookie_secure(),
319
+ samesite=conf.cookie_samesite(),
320
+ path="/",
321
+ )
322
+ return response
323
+
324
+
325
+ def _issue_txn(response: HttpResponse, txn: transaction.Transaction) -> None:
326
+ response.set_cookie(
327
+ _TXN_COOKIE,
328
+ signing.dumps(asdict(txn), salt=transaction.SALT),
329
+ max_age=conf.get("GRANTOR_TXN_MAX_AGE"),
330
+ httponly=True,
331
+ secure=conf.cookie_secure(),
332
+ samesite=conf.cookie_samesite(),
333
+ path="/",
334
+ )
335
+
336
+
337
+ def _read_txn(request: HttpRequest) -> transaction.Transaction:
338
+ raw = request.COOKIES.get(_TXN_COOKIE, "")
339
+ if not raw:
340
+ raise transaction.InvalidTransaction("no transaction cookie")
341
+ try:
342
+ payload = signing.loads(raw, salt=transaction.SALT, max_age=conf.get("GRANTOR_TXN_MAX_AGE"))
343
+ except signing.BadSignature as exc:
344
+ raise transaction.InvalidTransaction("bad or expired transaction") from exc
345
+ return transaction.Transaction(**payload)
346
+
347
+
348
+ def _clear_txn(response: HttpResponse) -> None:
349
+ response.delete_cookie(_TXN_COOKIE, path="/")
@@ -0,0 +1,78 @@
1
+ """The app, and the check that refuses to boot on a broken configuration.
2
+
3
+ A missing setting discovered at the first sign-in attempt is discovered by
4
+ the wrong person: somebody trying to log in, looking at an error page, with
5
+ no way to fix it. Discovered at boot it is a deploy that does not start,
6
+ which is the right audience and the right moment.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from django.apps import AppConfig
14
+ from django.core.checks import Error, Warning, register
15
+
16
+ __all__ = ["GrantorDjangoConfig"]
17
+
18
+
19
+ class GrantorDjangoConfig(AppConfig):
20
+ name = "grantor_django"
21
+ verbose_name = "Grantor"
22
+
23
+ def ready(self) -> None:
24
+ register(_check_settings)
25
+ register(_check_subject_queryset)
26
+
27
+
28
+ def _check_settings(app_configs: Any, **kwargs: Any) -> list[Error]:
29
+ from .conf import check_configuration
30
+
31
+ return [
32
+ Error(problem, id=f"grantor_django.E{index:03d}")
33
+ for index, problem in enumerate(check_configuration(), start=1)
34
+ ]
35
+
36
+
37
+ def _check_subject_queryset(app_configs: Any, **kwargs: Any) -> list[Warning]:
38
+ """Warn when an explicit queryset replaces the safe default.
39
+
40
+ When ``sub`` lives on a related model, lookups go through **that
41
+ model's** default manager, so a project with soft-deleted rows is
42
+ correct without configuring anything. Setting
43
+ ``GRANTOR_USER_QUERYSET`` replaces that route entirely — which is the
44
+ point of the setting, and also the one way left to reintroduce the
45
+ defect it was added to close.
46
+
47
+ Quiet unless both are true: the subject is behind a relation, and that
48
+ relation's manager excludes rows. A project with a stock manager hears
49
+ nothing, so this does not become noise people learn to ignore.
50
+ """
51
+ from django.db.models import Manager
52
+
53
+ from .backends import _subject_relation
54
+ from .conf import get
55
+
56
+ path = get("GRANTOR_USER_QUERYSET")
57
+ if not path:
58
+ return []
59
+ rel = _subject_relation()
60
+ if rel is None:
61
+ return []
62
+ model = rel.related_model
63
+ manager = model._default_manager
64
+ if type(manager) is Manager and type(manager) is type(model._base_manager):
65
+ return []
66
+ return [
67
+ Warning(
68
+ f"GRANTOR_USER_QUERYSET ({path}) replaces the default lookup, which would "
69
+ f"otherwise go through {model.__name__}.{manager.__class__.__name__} and honour "
70
+ f"its exclusions. Make sure your queryset excludes the same rows — a queryset "
71
+ f"that joins across the relation reads that table directly and skips the manager.",
72
+ hint=(
73
+ "Remove GRANTOR_USER_QUERYSET to use the default route, which honours "
74
+ f"{model.__name__}'s manager, unless your project needs something else."
75
+ ),
76
+ id="grantor_django.W001",
77
+ )
78
+ ]