django-device-bound-sessions 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 (41) hide show
  1. django_device_bound_sessions-0.1.0/.gitignore +25 -0
  2. django_device_bound_sessions-0.1.0/LICENSE +21 -0
  3. django_device_bound_sessions-0.1.0/PKG-INFO +247 -0
  4. django_device_bound_sessions-0.1.0/README.md +229 -0
  5. django_device_bound_sessions-0.1.0/example/demo/__init__.py +0 -0
  6. django_device_bound_sessions-0.1.0/example/demo/settings.py +73 -0
  7. django_device_bound_sessions-0.1.0/example/demo/templates/base.html +19 -0
  8. django_device_bound_sessions-0.1.0/example/demo/templates/home.html +33 -0
  9. django_device_bound_sessions-0.1.0/example/demo/templates/registration/login.html +8 -0
  10. django_device_bound_sessions-0.1.0/example/demo/urls.py +15 -0
  11. django_device_bound_sessions-0.1.0/example/demo/views.py +49 -0
  12. django_device_bound_sessions-0.1.0/example/manage.py +9 -0
  13. django_device_bound_sessions-0.1.0/pyproject.toml +147 -0
  14. django_device_bound_sessions-0.1.0/src/django_device_bound_sessions/__init__.py +9 -0
  15. django_device_bound_sessions-0.1.0/src/django_device_bound_sessions/api.py +214 -0
  16. django_device_bound_sessions-0.1.0/src/django_device_bound_sessions/apps.py +11 -0
  17. django_device_bound_sessions-0.1.0/src/django_device_bound_sessions/challenges.py +94 -0
  18. django_device_bound_sessions-0.1.0/src/django_device_bound_sessions/checks.py +77 -0
  19. django_device_bound_sessions-0.1.0/src/django_device_bound_sessions/conf.py +149 -0
  20. django_device_bound_sessions-0.1.0/src/django_device_bound_sessions/cookies.py +69 -0
  21. django_device_bound_sessions-0.1.0/src/django_device_bound_sessions/decorators.py +13 -0
  22. django_device_bound_sessions-0.1.0/src/django_device_bound_sessions/middleware.py +107 -0
  23. django_device_bound_sessions-0.1.0/src/django_device_bound_sessions/proof.py +159 -0
  24. django_device_bound_sessions-0.1.0/src/django_device_bound_sessions/py.typed +0 -0
  25. django_device_bound_sessions-0.1.0/src/django_device_bound_sessions/signals.py +20 -0
  26. django_device_bound_sessions-0.1.0/src/django_device_bound_sessions/structured_fields.py +248 -0
  27. django_device_bound_sessions-0.1.0/src/django_device_bound_sessions/testing.py +190 -0
  28. django_device_bound_sessions-0.1.0/src/django_device_bound_sessions/urls.py +10 -0
  29. django_device_bound_sessions-0.1.0/src/django_device_bound_sessions/views.py +147 -0
  30. django_device_bound_sessions-0.1.0/tests/__init__.py +0 -0
  31. django_device_bound_sessions-0.1.0/tests/conftest.py +36 -0
  32. django_device_bound_sessions-0.1.0/tests/settings.py +27 -0
  33. django_device_bound_sessions-0.1.0/tests/test_checks.py +101 -0
  34. django_device_bound_sessions-0.1.0/tests/test_engines.py +43 -0
  35. django_device_bound_sessions-0.1.0/tests/test_flow.py +610 -0
  36. django_device_bound_sessions-0.1.0/tests/test_internals.py +183 -0
  37. django_device_bound_sessions-0.1.0/tests/test_proof.py +227 -0
  38. django_device_bound_sessions-0.1.0/tests/test_structured_fields.py +177 -0
  39. django_device_bound_sessions-0.1.0/tests/urls.py +51 -0
  40. django_device_bound_sessions-0.1.0/typecheck/downstream.py +110 -0
  41. django_device_bound_sessions-0.1.0/uv.lock +949 -0
@@ -0,0 +1,25 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+
12
+ # Tool caches
13
+ .mypy_cache/
14
+ .pytest_cache/
15
+ .ruff_cache/
16
+
17
+ # Coverage data and reports
18
+ .coverage
19
+ .coverage.*
20
+ htmlcov/
21
+ coverage.xml
22
+ coverage.json
23
+
24
+ # Django (e.g. the example project's database)
25
+ *.sqlite3
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 0xdade
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 all
13
+ 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 THE
21
+ SOFTWARE.
@@ -0,0 +1,247 @@
1
+ Metadata-Version: 2.5
2
+ Name: django-device-bound-sessions
3
+ Version: 0.1.0
4
+ Summary: Device Bound Session Credentials (DBSC) for Django
5
+ Author: 0xdade
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Classifier: Framework :: Django
9
+ Classifier: Framework :: Django :: 5.2
10
+ Classifier: Framework :: Django :: 6.1
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Security
13
+ Classifier: Typing :: Typed
14
+ Requires-Python: >=3.12
15
+ Requires-Dist: django>=5.2
16
+ Requires-Dist: pyjwt[crypto]>=2.10
17
+ Description-Content-Type: text/markdown
18
+
19
+ # django-device-bound-sessions
20
+
21
+ [![coverage](https://img.shields.io/endpoint?url=https%3A%2F%2F0xdade.github.io%2Fdjango-device-bound-sessions%2Fcoverage%2Fbadge.json)](https://0xdade.github.io/django-device-bound-sessions/coverage/)
22
+
23
+ [Device Bound Session Credentials](https://developer.chrome.com/docs/web-platform/device-bound-session-credentials) (DBSC) for Django. If someone steals a user's session cookie, it stops working on any other device.
24
+
25
+ ## How it works
26
+
27
+ With DBSC, a supporting browser (Chrome) creates a key pair in the device's secure hardware when the server offers registration after a user logs in. The private key never leaves the device. The library adds a second, **short-lived "bound" cookie** alongside Django's normal session cookie. A browser can only get a new bound cookie by signing a server challenge with that key.
28
+
29
+ ```
30
+ login, then the next page ──► Secure-Session-Registration: (ES256 RS256);path="/dbsc/register/";challenge="…"
31
+ browser ──► POST /dbsc/register/ Secure-Session-Response: <JWT signed by the new key, includes its public JWK>
32
+ server ◄── 200 session config JSON
33
+ Set-Cookie: dbsc_bound=…; Max-Age=600
34
+ Secure-Session-Challenge: "<next challenge>";id="<id>"
35
+
36
+ …bound cookie expires; the browser refreshes it before sending its next request…
37
+ browser ──► POST /dbsc/refresh/ Sec-Secure-Session-Id: "<id>"
38
+ Secure-Session-Response: <JWT over the cached challenge>
39
+ server ◄── 200 Set-Cookie: dbsc_bound=…
40
+ Secure-Session-Challenge: "<next challenge>";id="<id>"
41
+ ```
42
+
43
+ There are two cookies. Django's normal `sessionid` still says who the user is, and stays as long-lived as it always was. `dbsc_bound` is the short-lived one that proves the request comes from the device that registered; only the refresh endpoint issues it.
44
+
45
+ The registration offer is sent once, on the first page after `login()` that isn't a redirect, within `OFFER_MAX_AGE` (60 seconds).
46
+
47
+ Every response that sets a bound cookie also sends the next challenge, so a refresh normally takes one request. Refresh challenges stay valid as long as a bound cookie does (`BOUND_COOKIE_AGE + BOUND_COOKIE_GRACE`). If the browser has no usable challenge, for example because the user was idle for longer than that, the server answers its refresh with a `403` and a fresh challenge, and the browser retries with a proof over it.
48
+
49
+ Once a Django session is bound, the middleware rejects any request that lacks a valid bound cookie (by default it treats the request as anonymous). An attacker holding only `sessionid` has no key, so they can't refresh, and are locked out once the bound cookie expires: within `BOUND_COOKIE_AGE + BOUND_COOKIE_GRACE` seconds (630 by default).
50
+
51
+ Browsers without DBSC support ignore the registration header. Their sessions never become bound and keep working exactly as they do today.
52
+
53
+ There is no database model. The device binding (public key, algorithm, and audit fields) is stored in the Django session itself, so:
54
+
55
+ - it works with any `SESSION_ENGINE` (db, cache, cached_db, file, signed_cookies);
56
+ - it lives exactly as long as the session does. Logging out or flushing the session removes the binding, and the browser's next refresh gets `{"continue": false}`.
57
+
58
+ ## Installation
59
+
60
+ Requires Python 3.12+ and Django 5.2 (LTS) or 6.1+.
61
+
62
+ ```bash
63
+ pip install django-device-bound-sessions
64
+ ```
65
+
66
+ ```python
67
+ INSTALLED_APPS = [
68
+ ...,
69
+ "django_device_bound_sessions",
70
+ ]
71
+
72
+ MIDDLEWARE = [
73
+ ...,
74
+ "django.contrib.sessions.middleware.SessionMiddleware",
75
+ ...,
76
+ "django.contrib.auth.middleware.AuthenticationMiddleware",
77
+ "django_device_bound_sessions.middleware.DeviceBoundSessionMiddleware", # after auth
78
+ ...,
79
+ ]
80
+ ```
81
+
82
+ ```python
83
+ # urls.py
84
+ urlpatterns = [
85
+ ...,
86
+ path("dbsc/", include("django_device_bound_sessions.urls")),
87
+ ]
88
+ ```
89
+
90
+ That's it: no migrations. Any `django.contrib.auth.login()` call offers DBSC registration on the next non-redirect response.
91
+
92
+ ## Using it in views
93
+
94
+ Every request gets `request.dbsc`:
95
+
96
+ | attribute | meaning |
97
+ |---|---|
98
+ | `request.dbsc.binding` | `DeviceBinding` for this session, or `None` |
99
+ | `request.dbsc.session_id` | DBSC session id, or `None` |
100
+ | `request.dbsc.registered` | the session has a binding |
101
+ | `request.dbsc.bound` | request carried a valid bound cookie |
102
+
103
+ A `DeviceBinding` has `session_id`, `algorithm`, `public_key` (JWK), `thumbprint` (RFC 7638), `user_agent`, `created_at`, and `last_refreshed_at`.
104
+
105
+ Helpers:
106
+
107
+ ```python
108
+ from django_device_bound_sessions.api import get_binding, offer_registration
109
+ from django_device_bound_sessions.decorators import dbsc_exempt
110
+
111
+ offer_registration(request) # offer DBSC outside of login (e.g. after step-up auth)
112
+ get_binding(request) # the DeviceBinding, also available as request.dbsc.binding
113
+
114
+
115
+ @dbsc_exempt # skip enforcement and offers for this view
116
+ def health(request): ...
117
+ ```
118
+
119
+ To end a binding, end the session (`logout()`, `request.session.flush()`).
120
+
121
+ ## Type checking
122
+
123
+ The package ships `py.typed` and is checked with mypy (strict, with django-stubs), pyright (strict), and [ty](https://docs.astral.sh/ty/). You don't have to change how your views are typed. Pick whichever of these fits your code:
124
+
125
+ ```python
126
+ from django.http import HttpRequest
127
+ from django_device_bound_sessions.api import DBSCHttpRequest, DBSCRequestMixin, get_dbsc_state
128
+
129
+
130
+ # 1. Views typed against plain HttpRequest: nothing to change.
131
+ def account(request: HttpRequest) -> HttpResponse:
132
+ state = get_dbsc_state(request) # DBSCState
133
+ if state.binding is not None:
134
+ ...
135
+
136
+
137
+ # 2. Use DBSCHttpRequest to access request.dbsc directly.
138
+ def account(request: DBSCHttpRequest) -> HttpResponse:
139
+ if request.dbsc.bound:
140
+ ...
141
+
142
+
143
+ # 3. Already have your own request type? Add the mixin to it.
144
+ class AppRequest(DBSCRequestMixin, HttpRequest):
145
+ tenant: Tenant
146
+ ```
147
+
148
+ If the middleware isn't installed, `get_dbsc_state()` raises `ImproperlyConfigured` instead of quietly reporting an unbound session. `get_binding(request)` reads the session directly and works without the middleware.
149
+
150
+ `typecheck/downstream.py` pins these types down for apps using the library. mypy, pyright and ty all check it strictly, so a change that loosens or breaks them fails the check.
151
+
152
+ Annotate your settings with `DBSCConfig` to get misspelled keys and wrong value types flagged:
153
+
154
+ ```python
155
+ from django_device_bound_sessions.conf import DBSCConfig
156
+
157
+ DBSC: DBSCConfig = {"BOUND_COOKIE_AGE": 300, "ENFORCEMENT": "anonymous"}
158
+ ```
159
+
160
+ ## Settings
161
+
162
+ Set these in a `DBSC` dict in your settings:
163
+
164
+ | setting | default | |
165
+ |---|---|---|
166
+ | `BOUND_COOKIE_NAME` | `"dbsc_bound"` | |
167
+ | `BOUND_COOKIE_AGE` | `600` | seconds; this is how long a stolen bound cookie stays useful |
168
+ | `BOUND_COOKIE_GRACE` | `30` | extra seconds the server accepts after expiry |
169
+ | `BOUND_COOKIE_DOMAIN` / `_PATH` / `_SECURE` / `_HTTPONLY` / `_SAMESITE` | `None` / `"/"` / `True` / `True` / `"Lax"` | |
170
+ | `ALGORITHMS` | `["ES256", "RS256"]` | offered in preference order (`none` is never accepted) |
171
+ | `CHALLENGE_MAX_AGE` | `60` | seconds a registration challenge is valid, from when the offer is sent. Refresh challenges last as long as a bound cookie (`BOUND_COOKIE_AGE + BOUND_COOKIE_GRACE`) |
172
+ | `CACHE_ALIAS` | `"default"` | cache that makes challenges single-use; must be shared by every worker (see Security notes) |
173
+ | `SCOPE_ORIGIN` | `None` | `scope.origin` |
174
+ | `SCOPE_INCLUDE_SITE` | `False` | `scope.include_site` |
175
+ | `SCOPE_SPECIFICATION` | `[]` | e.g. `[{"type": "exclude", "domain": "*", "path": "/static"}]` |
176
+ | `ALLOWED_REFRESH_INITIATORS` | `[]` | |
177
+ | `REGISTRATION_PATH` / `REFRESH_PATH` | `None` | override the URLs (default: reversed from `django_device_bound_sessions.urls`) |
178
+ | `OFFER_ON_LOGIN` | `True` | |
179
+ | `OFFER_MAX_AGE` | `60` | seconds after `login()` that a registration offer can still be sent |
180
+ | `ENFORCEMENT` | `"anonymous"` | `"anonymous"`: treat the request as logged out. `"logout"`: flush the session. `"off"`: check `request.dbsc` yourself |
181
+
182
+ ## Security notes
183
+
184
+ - **A binding is permanent for the life of the session.** The first device to register owns the session. Registration is refused (409) on a session that is already bound, so a stolen session cookie can't be used to move the session onto another device. To use a new device, log in again, which creates a new session.
185
+ - **The window before binding is unprotected, and it's kept short.** Until registration completes, whoever holds the session cookie could receive the registration offer. Offers are only sent for `OFFER_MAX_AGE` seconds after login and are used once. DBSC can't protect a session that was stolen before it was bound; like the spec, this library assumes the device wasn't compromised at login time.
186
+ - Challenges are signed with `SECRET_KEY` and expire. Each one is accepted once, enforced through the cache. Registration challenges are tied to the Django session that received them.
187
+ - **The cache must be shared by every worker.** It's the only state the library keeps outside the session: a record of used challenges, so a signed proof can't be replayed. Use a cache that every process and server sees and that has an atomic `add()`, such as Redis, Memcached or Django's database cache, and point `CACHE_ALIAS` at it. Django's default `LocMemCache` is per-process, so with several workers a used proof could be replayed on another worker until its challenge expires. `DummyCache` disables replay protection entirely, because its `add()` always succeeds. A system check (run by `manage.py check`, `runserver` and `migrate`) warns about `LocMemCache` (`django_device_bound_sessions.W001`, which you can silence with `SILENCED_SYSTEM_CHECKS` if you only ever run one process) and reports an error for `DummyCache` (`E002`) or a `CACHE_ALIAS` that isn't in `CACHES` (`E001`).
188
+ - A refresh with an invalid proof is rejected, but the DBSC session stays alive. Otherwise anyone holding a stolen cookie could knock the real device off.
189
+ - A refresh without the matching Django session (the user logged out, the session expired, or the binding was replaced) returns `{"continue": false}`, which tells the browser to stop. It never changes the session it was sent with.
190
+ - DBSC endpoints are CSRF-exempt, because the browser calls them without a token. The signed challenge authenticates them instead. Responses are `no-store` and `X-Frame-Options: DENY`.
191
+
192
+ ## Testing your app
193
+
194
+ `django_device_bound_sessions.testing.DBSCBrowser` wraps a Django test `Client` and simulates the browser side of DBSC:
195
+
196
+ ```python
197
+ from django_device_bound_sessions.testing import DBSCBrowser
198
+
199
+
200
+ def test_private_page(client, user):
201
+ browser = DBSCBrowser(client) # or algorithm="RS256"
202
+ client.force_login(user)
203
+ browser.get("/") # registers automatically
204
+ browser.expire_bound_cookie()
205
+ assert browser.get("/private/").status_code == 200 # refreshes first, like Chrome
206
+ ```
207
+
208
+ ## Development
209
+
210
+ ```bash
211
+ uv sync
212
+ uv run pre-commit install # ruff lint + format and zizmor on every commit
213
+ uv run pytest
214
+ uv run pytest --cov # with line + branch coverage (fails below 95%)
215
+ uv run mypy
216
+ uv run pyright
217
+ uv run ty check
218
+ ```
219
+
220
+ CI (`.github/workflows/ci.yml`) runs on pushes to `main` and on pull requests:
221
+
222
+ - **Tests** on Python 3.12–3.15 with the latest Django 6.1.x, and Python 3.12–3.14 with the latest
223
+ Django 5.2.x (LTS).
224
+ - **Coverage**: branch coverage with a 95% floor. The run summary shows the coverage table, and the full HTML report is attached as the `coverage-report` artifact. A separate workflow (`pages.yml`) publishes the report and the README badge's data to GitHub Pages on pushes to `main`. There's also a run against the lowest versions allowed by `pyproject.toml` (Django 5.2.0, PyJWT 2.10.0).
225
+ - **Pre-commit** on every file: `ruff check` + `ruff format`, and [zizmor](https://docs.zizmor.sh/) for the workflows and Dependabot config. In CI, zizmor also runs its online audits, such as checking that each pinned action SHA is a real commit in that action's repository.
226
+ - **mypy** (strict, with django-stubs), **pyright** (strict on `src/` and `typecheck/`) and **ty**.
227
+
228
+ A final `CI passed` job depends on all of these and succeeds only if they all did. It's the one check to require in branch protection, so changing the matrix never means updating the ruleset.
229
+
230
+ Actions are pinned to commit SHAs. Dependabot or Renovate can keep them up to date.
231
+
232
+ Run `uv run pre-commit run --all-files` to lint and format everything by hand. Ruff isn't a project dependency. It runs only through pre-commit, so the hook's `rev` in
233
+ `.pre-commit-config.yaml` is the single ruff version used locally and in CI.
234
+
235
+ ### Demo project
236
+
237
+ ```bash
238
+ uv run example/manage.py migrate
239
+ uv run example/manage.py createsuperuser
240
+ uv run example/manage.py runserver
241
+ ```
242
+
243
+ Open http://localhost:8000 in Chrome with DBSC enabled and log in. The home page shows the binding status. The demo's bound cookie lives 60 seconds, and an **Expire bound cookie** button forces a refresh, which you can watch in DevTools → Application → Device Bound Sessions.
244
+
245
+ DBSC requires HTTPS, except on `localhost`.
246
+
247
+ Protocol reference: <https://w3c.github.io/webappsec-dbsc/>
@@ -0,0 +1,229 @@
1
+ # django-device-bound-sessions
2
+
3
+ [![coverage](https://img.shields.io/endpoint?url=https%3A%2F%2F0xdade.github.io%2Fdjango-device-bound-sessions%2Fcoverage%2Fbadge.json)](https://0xdade.github.io/django-device-bound-sessions/coverage/)
4
+
5
+ [Device Bound Session Credentials](https://developer.chrome.com/docs/web-platform/device-bound-session-credentials) (DBSC) for Django. If someone steals a user's session cookie, it stops working on any other device.
6
+
7
+ ## How it works
8
+
9
+ With DBSC, a supporting browser (Chrome) creates a key pair in the device's secure hardware when the server offers registration after a user logs in. The private key never leaves the device. The library adds a second, **short-lived "bound" cookie** alongside Django's normal session cookie. A browser can only get a new bound cookie by signing a server challenge with that key.
10
+
11
+ ```
12
+ login, then the next page ──► Secure-Session-Registration: (ES256 RS256);path="/dbsc/register/";challenge="…"
13
+ browser ──► POST /dbsc/register/ Secure-Session-Response: <JWT signed by the new key, includes its public JWK>
14
+ server ◄── 200 session config JSON
15
+ Set-Cookie: dbsc_bound=…; Max-Age=600
16
+ Secure-Session-Challenge: "<next challenge>";id="<id>"
17
+
18
+ …bound cookie expires; the browser refreshes it before sending its next request…
19
+ browser ──► POST /dbsc/refresh/ Sec-Secure-Session-Id: "<id>"
20
+ Secure-Session-Response: <JWT over the cached challenge>
21
+ server ◄── 200 Set-Cookie: dbsc_bound=…
22
+ Secure-Session-Challenge: "<next challenge>";id="<id>"
23
+ ```
24
+
25
+ There are two cookies. Django's normal `sessionid` still says who the user is, and stays as long-lived as it always was. `dbsc_bound` is the short-lived one that proves the request comes from the device that registered; only the refresh endpoint issues it.
26
+
27
+ The registration offer is sent once, on the first page after `login()` that isn't a redirect, within `OFFER_MAX_AGE` (60 seconds).
28
+
29
+ Every response that sets a bound cookie also sends the next challenge, so a refresh normally takes one request. Refresh challenges stay valid as long as a bound cookie does (`BOUND_COOKIE_AGE + BOUND_COOKIE_GRACE`). If the browser has no usable challenge, for example because the user was idle for longer than that, the server answers its refresh with a `403` and a fresh challenge, and the browser retries with a proof over it.
30
+
31
+ Once a Django session is bound, the middleware rejects any request that lacks a valid bound cookie (by default it treats the request as anonymous). An attacker holding only `sessionid` has no key, so they can't refresh, and are locked out once the bound cookie expires: within `BOUND_COOKIE_AGE + BOUND_COOKIE_GRACE` seconds (630 by default).
32
+
33
+ Browsers without DBSC support ignore the registration header. Their sessions never become bound and keep working exactly as they do today.
34
+
35
+ There is no database model. The device binding (public key, algorithm, and audit fields) is stored in the Django session itself, so:
36
+
37
+ - it works with any `SESSION_ENGINE` (db, cache, cached_db, file, signed_cookies);
38
+ - it lives exactly as long as the session does. Logging out or flushing the session removes the binding, and the browser's next refresh gets `{"continue": false}`.
39
+
40
+ ## Installation
41
+
42
+ Requires Python 3.12+ and Django 5.2 (LTS) or 6.1+.
43
+
44
+ ```bash
45
+ pip install django-device-bound-sessions
46
+ ```
47
+
48
+ ```python
49
+ INSTALLED_APPS = [
50
+ ...,
51
+ "django_device_bound_sessions",
52
+ ]
53
+
54
+ MIDDLEWARE = [
55
+ ...,
56
+ "django.contrib.sessions.middleware.SessionMiddleware",
57
+ ...,
58
+ "django.contrib.auth.middleware.AuthenticationMiddleware",
59
+ "django_device_bound_sessions.middleware.DeviceBoundSessionMiddleware", # after auth
60
+ ...,
61
+ ]
62
+ ```
63
+
64
+ ```python
65
+ # urls.py
66
+ urlpatterns = [
67
+ ...,
68
+ path("dbsc/", include("django_device_bound_sessions.urls")),
69
+ ]
70
+ ```
71
+
72
+ That's it: no migrations. Any `django.contrib.auth.login()` call offers DBSC registration on the next non-redirect response.
73
+
74
+ ## Using it in views
75
+
76
+ Every request gets `request.dbsc`:
77
+
78
+ | attribute | meaning |
79
+ |---|---|
80
+ | `request.dbsc.binding` | `DeviceBinding` for this session, or `None` |
81
+ | `request.dbsc.session_id` | DBSC session id, or `None` |
82
+ | `request.dbsc.registered` | the session has a binding |
83
+ | `request.dbsc.bound` | request carried a valid bound cookie |
84
+
85
+ A `DeviceBinding` has `session_id`, `algorithm`, `public_key` (JWK), `thumbprint` (RFC 7638), `user_agent`, `created_at`, and `last_refreshed_at`.
86
+
87
+ Helpers:
88
+
89
+ ```python
90
+ from django_device_bound_sessions.api import get_binding, offer_registration
91
+ from django_device_bound_sessions.decorators import dbsc_exempt
92
+
93
+ offer_registration(request) # offer DBSC outside of login (e.g. after step-up auth)
94
+ get_binding(request) # the DeviceBinding, also available as request.dbsc.binding
95
+
96
+
97
+ @dbsc_exempt # skip enforcement and offers for this view
98
+ def health(request): ...
99
+ ```
100
+
101
+ To end a binding, end the session (`logout()`, `request.session.flush()`).
102
+
103
+ ## Type checking
104
+
105
+ The package ships `py.typed` and is checked with mypy (strict, with django-stubs), pyright (strict), and [ty](https://docs.astral.sh/ty/). You don't have to change how your views are typed. Pick whichever of these fits your code:
106
+
107
+ ```python
108
+ from django.http import HttpRequest
109
+ from django_device_bound_sessions.api import DBSCHttpRequest, DBSCRequestMixin, get_dbsc_state
110
+
111
+
112
+ # 1. Views typed against plain HttpRequest: nothing to change.
113
+ def account(request: HttpRequest) -> HttpResponse:
114
+ state = get_dbsc_state(request) # DBSCState
115
+ if state.binding is not None:
116
+ ...
117
+
118
+
119
+ # 2. Use DBSCHttpRequest to access request.dbsc directly.
120
+ def account(request: DBSCHttpRequest) -> HttpResponse:
121
+ if request.dbsc.bound:
122
+ ...
123
+
124
+
125
+ # 3. Already have your own request type? Add the mixin to it.
126
+ class AppRequest(DBSCRequestMixin, HttpRequest):
127
+ tenant: Tenant
128
+ ```
129
+
130
+ If the middleware isn't installed, `get_dbsc_state()` raises `ImproperlyConfigured` instead of quietly reporting an unbound session. `get_binding(request)` reads the session directly and works without the middleware.
131
+
132
+ `typecheck/downstream.py` pins these types down for apps using the library. mypy, pyright and ty all check it strictly, so a change that loosens or breaks them fails the check.
133
+
134
+ Annotate your settings with `DBSCConfig` to get misspelled keys and wrong value types flagged:
135
+
136
+ ```python
137
+ from django_device_bound_sessions.conf import DBSCConfig
138
+
139
+ DBSC: DBSCConfig = {"BOUND_COOKIE_AGE": 300, "ENFORCEMENT": "anonymous"}
140
+ ```
141
+
142
+ ## Settings
143
+
144
+ Set these in a `DBSC` dict in your settings:
145
+
146
+ | setting | default | |
147
+ |---|---|---|
148
+ | `BOUND_COOKIE_NAME` | `"dbsc_bound"` | |
149
+ | `BOUND_COOKIE_AGE` | `600` | seconds; this is how long a stolen bound cookie stays useful |
150
+ | `BOUND_COOKIE_GRACE` | `30` | extra seconds the server accepts after expiry |
151
+ | `BOUND_COOKIE_DOMAIN` / `_PATH` / `_SECURE` / `_HTTPONLY` / `_SAMESITE` | `None` / `"/"` / `True` / `True` / `"Lax"` | |
152
+ | `ALGORITHMS` | `["ES256", "RS256"]` | offered in preference order (`none` is never accepted) |
153
+ | `CHALLENGE_MAX_AGE` | `60` | seconds a registration challenge is valid, from when the offer is sent. Refresh challenges last as long as a bound cookie (`BOUND_COOKIE_AGE + BOUND_COOKIE_GRACE`) |
154
+ | `CACHE_ALIAS` | `"default"` | cache that makes challenges single-use; must be shared by every worker (see Security notes) |
155
+ | `SCOPE_ORIGIN` | `None` | `scope.origin` |
156
+ | `SCOPE_INCLUDE_SITE` | `False` | `scope.include_site` |
157
+ | `SCOPE_SPECIFICATION` | `[]` | e.g. `[{"type": "exclude", "domain": "*", "path": "/static"}]` |
158
+ | `ALLOWED_REFRESH_INITIATORS` | `[]` | |
159
+ | `REGISTRATION_PATH` / `REFRESH_PATH` | `None` | override the URLs (default: reversed from `django_device_bound_sessions.urls`) |
160
+ | `OFFER_ON_LOGIN` | `True` | |
161
+ | `OFFER_MAX_AGE` | `60` | seconds after `login()` that a registration offer can still be sent |
162
+ | `ENFORCEMENT` | `"anonymous"` | `"anonymous"`: treat the request as logged out. `"logout"`: flush the session. `"off"`: check `request.dbsc` yourself |
163
+
164
+ ## Security notes
165
+
166
+ - **A binding is permanent for the life of the session.** The first device to register owns the session. Registration is refused (409) on a session that is already bound, so a stolen session cookie can't be used to move the session onto another device. To use a new device, log in again, which creates a new session.
167
+ - **The window before binding is unprotected, and it's kept short.** Until registration completes, whoever holds the session cookie could receive the registration offer. Offers are only sent for `OFFER_MAX_AGE` seconds after login and are used once. DBSC can't protect a session that was stolen before it was bound; like the spec, this library assumes the device wasn't compromised at login time.
168
+ - Challenges are signed with `SECRET_KEY` and expire. Each one is accepted once, enforced through the cache. Registration challenges are tied to the Django session that received them.
169
+ - **The cache must be shared by every worker.** It's the only state the library keeps outside the session: a record of used challenges, so a signed proof can't be replayed. Use a cache that every process and server sees and that has an atomic `add()`, such as Redis, Memcached or Django's database cache, and point `CACHE_ALIAS` at it. Django's default `LocMemCache` is per-process, so with several workers a used proof could be replayed on another worker until its challenge expires. `DummyCache` disables replay protection entirely, because its `add()` always succeeds. A system check (run by `manage.py check`, `runserver` and `migrate`) warns about `LocMemCache` (`django_device_bound_sessions.W001`, which you can silence with `SILENCED_SYSTEM_CHECKS` if you only ever run one process) and reports an error for `DummyCache` (`E002`) or a `CACHE_ALIAS` that isn't in `CACHES` (`E001`).
170
+ - A refresh with an invalid proof is rejected, but the DBSC session stays alive. Otherwise anyone holding a stolen cookie could knock the real device off.
171
+ - A refresh without the matching Django session (the user logged out, the session expired, or the binding was replaced) returns `{"continue": false}`, which tells the browser to stop. It never changes the session it was sent with.
172
+ - DBSC endpoints are CSRF-exempt, because the browser calls them without a token. The signed challenge authenticates them instead. Responses are `no-store` and `X-Frame-Options: DENY`.
173
+
174
+ ## Testing your app
175
+
176
+ `django_device_bound_sessions.testing.DBSCBrowser` wraps a Django test `Client` and simulates the browser side of DBSC:
177
+
178
+ ```python
179
+ from django_device_bound_sessions.testing import DBSCBrowser
180
+
181
+
182
+ def test_private_page(client, user):
183
+ browser = DBSCBrowser(client) # or algorithm="RS256"
184
+ client.force_login(user)
185
+ browser.get("/") # registers automatically
186
+ browser.expire_bound_cookie()
187
+ assert browser.get("/private/").status_code == 200 # refreshes first, like Chrome
188
+ ```
189
+
190
+ ## Development
191
+
192
+ ```bash
193
+ uv sync
194
+ uv run pre-commit install # ruff lint + format and zizmor on every commit
195
+ uv run pytest
196
+ uv run pytest --cov # with line + branch coverage (fails below 95%)
197
+ uv run mypy
198
+ uv run pyright
199
+ uv run ty check
200
+ ```
201
+
202
+ CI (`.github/workflows/ci.yml`) runs on pushes to `main` and on pull requests:
203
+
204
+ - **Tests** on Python 3.12–3.15 with the latest Django 6.1.x, and Python 3.12–3.14 with the latest
205
+ Django 5.2.x (LTS).
206
+ - **Coverage**: branch coverage with a 95% floor. The run summary shows the coverage table, and the full HTML report is attached as the `coverage-report` artifact. A separate workflow (`pages.yml`) publishes the report and the README badge's data to GitHub Pages on pushes to `main`. There's also a run against the lowest versions allowed by `pyproject.toml` (Django 5.2.0, PyJWT 2.10.0).
207
+ - **Pre-commit** on every file: `ruff check` + `ruff format`, and [zizmor](https://docs.zizmor.sh/) for the workflows and Dependabot config. In CI, zizmor also runs its online audits, such as checking that each pinned action SHA is a real commit in that action's repository.
208
+ - **mypy** (strict, with django-stubs), **pyright** (strict on `src/` and `typecheck/`) and **ty**.
209
+
210
+ A final `CI passed` job depends on all of these and succeeds only if they all did. It's the one check to require in branch protection, so changing the matrix never means updating the ruleset.
211
+
212
+ Actions are pinned to commit SHAs. Dependabot or Renovate can keep them up to date.
213
+
214
+ Run `uv run pre-commit run --all-files` to lint and format everything by hand. Ruff isn't a project dependency. It runs only through pre-commit, so the hook's `rev` in
215
+ `.pre-commit-config.yaml` is the single ruff version used locally and in CI.
216
+
217
+ ### Demo project
218
+
219
+ ```bash
220
+ uv run example/manage.py migrate
221
+ uv run example/manage.py createsuperuser
222
+ uv run example/manage.py runserver
223
+ ```
224
+
225
+ Open http://localhost:8000 in Chrome with DBSC enabled and log in. The home page shows the binding status. The demo's bound cookie lives 60 seconds, and an **Expire bound cookie** button forces a refresh, which you can watch in DevTools → Application → Device Bound Sessions.
226
+
227
+ DBSC requires HTTPS, except on `localhost`.
228
+
229
+ Protocol reference: <https://w3c.github.io/webappsec-dbsc/>
@@ -0,0 +1,73 @@
1
+ """Demo project for django-device-bound-sessions. Run with: uv run example/manage.py runserver"""
2
+
3
+ from pathlib import Path
4
+
5
+ from django_device_bound_sessions.conf import DBSCConfig
6
+
7
+ BASE_DIR = Path(__file__).resolve().parent.parent
8
+
9
+ SECRET_KEY = "django-device-bound-sessions-demo-not-secret" # noqa: S105 - demo only, never deploy
10
+ DEBUG = True
11
+ ALLOWED_HOSTS = ["localhost", "127.0.0.1"]
12
+
13
+ INSTALLED_APPS = [
14
+ "django.contrib.admin",
15
+ "django.contrib.auth",
16
+ "django.contrib.contenttypes",
17
+ "django.contrib.sessions",
18
+ "django.contrib.messages",
19
+ "django.contrib.staticfiles",
20
+ "django_device_bound_sessions",
21
+ "demo",
22
+ ]
23
+
24
+ MIDDLEWARE = [
25
+ "django.middleware.security.SecurityMiddleware",
26
+ "django.contrib.sessions.middleware.SessionMiddleware",
27
+ "django.middleware.common.CommonMiddleware",
28
+ "django.middleware.csrf.CsrfViewMiddleware",
29
+ "django.contrib.auth.middleware.AuthenticationMiddleware",
30
+ "django_device_bound_sessions.middleware.DeviceBoundSessionMiddleware",
31
+ "django.contrib.messages.middleware.MessageMiddleware",
32
+ "django.middleware.clickjacking.XFrameOptionsMiddleware",
33
+ ]
34
+
35
+ ROOT_URLCONF = "demo.urls"
36
+
37
+ TEMPLATES = [
38
+ {
39
+ "BACKEND": "django.template.backends.django.DjangoTemplates",
40
+ "APP_DIRS": True,
41
+ "OPTIONS": {
42
+ "context_processors": [
43
+ "django.template.context_processors.request",
44
+ "django.contrib.auth.context_processors.auth",
45
+ "django.contrib.messages.context_processors.messages",
46
+ ],
47
+ },
48
+ },
49
+ ]
50
+
51
+ DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": BASE_DIR / "db.sqlite3"}}
52
+
53
+ USE_TZ = True
54
+ STATIC_URL = "static/"
55
+ LOGIN_REDIRECT_URL = "/"
56
+ LOGOUT_REDIRECT_URL = "/"
57
+
58
+ DBSC: DBSCConfig = {
59
+ # Short lifetime so you can watch Chrome refresh in DevTools.
60
+ "BOUND_COOKIE_AGE": 60,
61
+ "SCOPE_SPECIFICATION": [{"type": "exclude", "domain": "*", "path": "/static"}],
62
+ }
63
+
64
+ # The demo runs as a single runserver process, so Django's default per-process
65
+ # LocMemCache is fine as the replay cache. Deployments with several workers
66
+ # need a shared cache (see the README's security notes).
67
+ SILENCED_SYSTEM_CHECKS = ["django_device_bound_sessions.W001"]
68
+
69
+ LOGGING = {
70
+ "version": 1,
71
+ "handlers": {"console": {"class": "logging.StreamHandler"}},
72
+ "loggers": {"django_device_bound_sessions": {"handlers": ["console"], "level": "INFO"}},
73
+ }
@@ -0,0 +1,19 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>django-device-bound-sessions demo</title>
7
+ <style>
8
+ body { font-family: system-ui, sans-serif; max-width: 42rem; margin: 3rem auto; padding: 0 1rem; line-height: 1.5; }
9
+ .ok { color: #0a7d32; } .bad { color: #b3261e; }
10
+ dl { display: grid; grid-template-columns: max-content 1fr; gap: .25rem 1rem; }
11
+ dt { font-weight: 600; } dd { margin: 0; overflow-wrap: anywhere; }
12
+ form { display: inline; } button { margin-right: .5rem; }
13
+ </style>
14
+ </head>
15
+ <body>
16
+ <h1>django-device-bound-sessions demo</h1>
17
+ {% block content %}{% endblock %}
18
+ </body>
19
+ </html>
@@ -0,0 +1,33 @@
1
+ {% extends "base.html" %}
2
+ {% block content %}
3
+ {% if user.is_authenticated %}
4
+ <p>Logged in as <strong>{{ user.get_username }}</strong>.</p>
5
+ {% else %}
6
+ <p>Not logged in. <a href="{% url 'login' %}">Log in</a></p>
7
+ {% endif %}
8
+
9
+ <dl>
10
+ <dt>DBSC session</dt>
11
+ <dd>{% if request.dbsc.session_id %}<code>{{ request.dbsc.session_id }}</code>{% else %}none (not registered){% endif %}</dd>
12
+ <dt>Bound cookie</dt>
13
+ <dd>{% if request.dbsc.bound %}<span class="ok">valid</span>{% elif request.dbsc.session_id %}<span class="bad">missing / invalid: request treated as anonymous</span>{% else %}n/a{% endif %}</dd>
14
+ {% if binding %}
15
+ <dt>Algorithm</dt><dd>{{ binding.algorithm }}</dd>
16
+ <dt>Key thumbprint</dt><dd><code>{{ binding.thumbprint }}</code></dd>
17
+ <dt>Registered</dt><dd>{{ binding.created_at }}</dd>
18
+ <dt>Last refresh</dt><dd>{{ binding.last_refreshed_at|default:"never" }}</dd>
19
+ <dt>User agent</dt><dd>{{ binding.user_agent }}</dd>
20
+ {% endif %}
21
+ </dl>
22
+
23
+ {% if user.is_authenticated or request.dbsc.session_id %}
24
+ <p>
25
+ <form method="post" action="{% url 'expire' %}">{% csrf_token %}<button>Expire bound cookie</button></form>
26
+ <form method="post" action="{% url 'logout' %}">{% csrf_token %}<button>Log out</button></form>
27
+ </p>
28
+ <p>The bound cookie lives 60 seconds. After it expires (or you expire it), Chrome should
29
+ refresh it by signing a challenge with its device-bound key before the next request reaches
30
+ the server. Watch the <em>Application → Device Bound Sessions</em> panel and the network log in DevTools.</p>
31
+ <p>The same state as JSON: <a href="{% url 'status' %}">/status/</a></p>
32
+ {% endif %}
33
+ {% endblock %}
@@ -0,0 +1,8 @@
1
+ {% extends "base.html" %}
2
+ {% block content %}
3
+ <form method="post">
4
+ {% csrf_token %}
5
+ {{ form.as_p }}
6
+ <button type="submit">Log in</button>
7
+ </form>
8
+ {% endblock %}