channels-jwt-subprotocol 1.0.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TheLoops (https://theloops.live)
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,204 @@
1
+ Metadata-Version: 2.4
2
+ Name: channels-jwt-subprotocol
3
+ Version: 1.0.0
4
+ Summary: Authenticate Django Channels WebSockets with a JWT sent as a subprotocol, so the token never lands in the URL or in access logs.
5
+ Author-email: TheLoops <support@theloops.live>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://theloops.live/engineering/channels-jwt-subprotocol
8
+ Project-URL: Source, https://github.com/YouPanel/channels-jwt-subprotocol
9
+ Keywords: django,channels,websocket,jwt,simplejwt,authentication,subprotocol
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Framework :: Django
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Topic :: Internet :: WWW/HTTP
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: channels>=4.0
18
+ Requires-Dist: Django>=4.2
19
+ Provides-Extra: simplejwt
20
+ Requires-Dist: djangorestframework-simplejwt>=5.0; extra == "simplejwt"
21
+ Provides-Extra: test
22
+ Requires-Dist: pytest>=7; extra == "test"
23
+ Requires-Dist: djangorestframework-simplejwt>=5.0; extra == "test"
24
+ Requires-Dist: daphne>=4.0; extra == "test"
25
+ Requires-Dist: uvicorn>=0.30; extra == "test"
26
+ Requires-Dist: websockets>=14; extra == "test"
27
+ Dynamic: license-file
28
+
29
+ # channels-jwt-subprotocol
30
+
31
+ Authenticate [Django Channels](https://channels.readthedocs.io/) WebSockets with a JWT sent as a
32
+ **subprotocol**, so the token never appears in the URL.
33
+
34
+ A browser cannot set an `Authorization` header on a WebSocket handshake. The usual workaround puts
35
+ the token in the query string, `wss://example.com/ws/?token=…`, and the query string is part of the
36
+ request line that nginx, load balancers and CDNs write to their access logs by default. An access
37
+ token lives for minutes; a log line lives for months.
38
+
39
+ The `Sec-WebSocket-Protocol` header is the other place a browser lets you put a value, and access
40
+ logs do not record it by default. This package reads the token from there, validates it with
41
+ [SimpleJWT](https://github.com/jazzband/djangorestframework-simplejwt) (or any check you supply),
42
+ and handles the two handshake details that otherwise fail silently:
43
+
44
+ - **The server must echo a subprotocol the client offered.** Echo nothing and the browser drops the
45
+ connection; echo the token and it ends up in a response header. The middleware records a fixed
46
+ marker, and the consumer helpers echo the marker, never the token.
47
+ - **A close code sent before `accept()` never reaches the client.** Daphne and Uvicorn answer the
48
+ upgrade with an HTTP 403 instead, and the browser sees code 1006, the same as a dropped network.
49
+ `refuse()` accepts first and then closes, so the client receives `4401` and knows to renew its
50
+ token.
51
+
52
+ ## Install
53
+
54
+ ```bash
55
+ pip install "channels-jwt-subprotocol[simplejwt]"
56
+ ```
57
+
58
+ Python 3.10+, Django 4.2+, Channels 4.0+. Leave out `[simplejwt]` if you validate tokens yourself.
59
+
60
+ ## Use
61
+
62
+ **Client.** Offer the marker `jwt`, then the token:
63
+
64
+ ```js
65
+ const ws = new WebSocket('wss://example.com/ws/feed/', ['jwt', accessToken])
66
+ ```
67
+
68
+ A JWT is made of base64url segments and dots, which are all valid subprotocol characters. An invalid
69
+ value makes the constructor throw a `SyntaxError`, so treat a throw like a failed connect.
70
+
71
+ **ASGI.** Wrap your WebSocket router:
72
+
73
+ ```python
74
+ # asgi.py
75
+ from channels.routing import ProtocolTypeRouter, URLRouter
76
+ from channels.security.websocket import AllowedHostsOriginValidator
77
+ from django.core.asgi import get_asgi_application
78
+
79
+ django_asgi_app = get_asgi_application()
80
+
81
+ from channels_jwt_subprotocol import JWTSubprotocolMiddleware # noqa: E402
82
+ from myapp.routing import websocket_urlpatterns # noqa: E402
83
+
84
+ application = ProtocolTypeRouter({
85
+ "http": django_asgi_app,
86
+ "websocket": AllowedHostsOriginValidator(
87
+ JWTSubprotocolMiddleware(URLRouter(websocket_urlpatterns))
88
+ ),
89
+ })
90
+ ```
91
+
92
+ **Consumer.** Use the mixin's `accept_authenticated()` and `refuse()` instead of calling `accept()`
93
+ and `close()` yourself:
94
+
95
+ ```python
96
+ from channels.generic.websocket import AsyncJsonWebsocketConsumer
97
+ from channels_jwt_subprotocol import AsyncJWTSubprotocolMixin
98
+
99
+
100
+ class FeedConsumer(AsyncJWTSubprotocolMixin, AsyncJsonWebsocketConsumer):
101
+ async def connect(self):
102
+ if not self.is_authenticated():
103
+ return await self.refuse() # accept, then close with 4401
104
+ await self.accept_authenticated() # echoes "jwt", never the token
105
+ await self.send_json({"type": "connected"})
106
+ ```
107
+
108
+ `JWTSubprotocolMixin` is the same for the synchronous `WebsocketConsumer` and
109
+ `JsonWebsocketConsumer`.
110
+
111
+ **Client, on close.** Because the server accepts before it refuses, `onopen` fires for a socket that is
112
+ about to be closed. Treat the connection as live when the server's own first message arrives, and
113
+ renew the token on `4401`:
114
+
115
+ ```js
116
+ ws.onmessage = (e) => {
117
+ if (JSON.parse(e.data).type === 'connected') markLive()
118
+ }
119
+ ws.onclose = (e) => {
120
+ if (e.code === 4401) return renewTokenThenReconnect() // refused: a new token may help
121
+ scheduleRetry() // network: back off and retry
122
+ }
123
+ ```
124
+
125
+ If a freshly renewed token is refused too, back off instead of renewing again, or a deactivated
126
+ account turns into a loop against your refresh endpoint.
127
+
128
+ ## Behaviour
129
+
130
+ | Client offers | `scope["user"]` | Consumer with `refuse()` |
131
+ |---|---|---|
132
+ | `["jwt", <valid access token>]` | the token's user | accepted, echoes `jwt` |
133
+ | `["jwt", <expired, refresh, garbage token>]` | `AnonymousUser` | accepted with `jwt`, closed `4401` |
134
+ | `["jwt"]` | `AnonymousUser` | accepted with `jwt`, closed `4401` |
135
+ | nothing, or no `jwt` marker | unchanged, or `AnonymousUser` if unset | accepted, closed `4401` |
136
+
137
+ The token is the value **immediately after** the marker, so a client can offer an application
138
+ protocol too: `['graphql-transport-ws', 'jwt', token]`. If you do, accept with that protocol yourself,
139
+ as the server may echo only one value.
140
+
141
+ When the marker is offered, the token decides the user, even if an earlier middleware already set
142
+ one. When it is not, an existing `scope["user"]` is left alone, so the middleware can sit inside
143
+ `AuthMiddlewareStack` for apps that also use session cookies.
144
+
145
+ The default check is SimpleJWT's `JWTAuthentication`, the class that already guards your REST API:
146
+ same signing key, expiry, token type and `is_active` rule, no second implementation to drift.
147
+
148
+ ## Options
149
+
150
+ ```python
151
+ JWTSubprotocolMiddleware(inner, marker="jwt", get_user=None)
152
+ ```
153
+
154
+ - `marker`: the fixed value the client offers before the token. Use the same one in the client.
155
+ - `get_user`: an async callable `get_user(raw_token) -> user`. Return `AnonymousUser()` to refuse.
156
+ Use it for a non-SimpleJWT token, or to add your own checks.
157
+
158
+ ```python
159
+ from channels.db import database_sync_to_async
160
+ from django.contrib.auth.models import AnonymousUser
161
+
162
+
163
+ @database_sync_to_async
164
+ def get_user(raw_token):
165
+ user = verify_with_your_identity_provider(raw_token)
166
+ return user or AnonymousUser()
167
+
168
+
169
+ application = JWTSubprotocolMiddleware(router, get_user=get_user)
170
+ ```
171
+
172
+ The refusal code is `CLOSE_UNAUTHENTICATED` (`4401`). Override `close_unauthenticated_code` on the
173
+ consumer, or pass `refuse(code=...)`.
174
+
175
+ ## What this does not do
176
+
177
+ - **It authenticates the handshake only.** A socket opened with a token that has two minutes left
178
+ stays open after the token expires. Renew near-expiry tokens before connecting, and close sockets
179
+ yourself if your app needs revocation to take effect immediately.
180
+ - **It is not encryption.** Use `wss://`. The point is that the token is not written to logs by
181
+ default, not that it is hidden from anything on the connection.
182
+ - **Check your own logging.** A proxy configured to log request headers will log this one too.
183
+
184
+ ## Tests
185
+
186
+ ```bash
187
+ pip install -e ".[test]"
188
+ pytest
189
+ ```
190
+
191
+ The suite includes live tests that start Daphne and Uvicorn on a local port and connect with the
192
+ `websockets` client, which is how the HTTP 403 for a close before `accept()`, and the `4401` that
193
+ arrives after accepting, are checked against real servers rather than assumed. They pass on Daphne
194
+ 4.0 and 4.2, Uvicorn 0.30 and 0.54, Channels 4.0 and 4.3.
195
+
196
+ ## Background
197
+
198
+ Written for the dashboard of [TheLoops](https://theloops.live), where one socket per tab pushes the
199
+ status of 24/7 streams. The long version, including the nginx and idle-timeout parts, is in
200
+ [Django Channels: JWT auth without the token in the URL](https://theloops.live/engineering/django-channels-jwt-websocket).
201
+
202
+ ## License
203
+
204
+ MIT
@@ -0,0 +1,176 @@
1
+ # channels-jwt-subprotocol
2
+
3
+ Authenticate [Django Channels](https://channels.readthedocs.io/) WebSockets with a JWT sent as a
4
+ **subprotocol**, so the token never appears in the URL.
5
+
6
+ A browser cannot set an `Authorization` header on a WebSocket handshake. The usual workaround puts
7
+ the token in the query string, `wss://example.com/ws/?token=…`, and the query string is part of the
8
+ request line that nginx, load balancers and CDNs write to their access logs by default. An access
9
+ token lives for minutes; a log line lives for months.
10
+
11
+ The `Sec-WebSocket-Protocol` header is the other place a browser lets you put a value, and access
12
+ logs do not record it by default. This package reads the token from there, validates it with
13
+ [SimpleJWT](https://github.com/jazzband/djangorestframework-simplejwt) (or any check you supply),
14
+ and handles the two handshake details that otherwise fail silently:
15
+
16
+ - **The server must echo a subprotocol the client offered.** Echo nothing and the browser drops the
17
+ connection; echo the token and it ends up in a response header. The middleware records a fixed
18
+ marker, and the consumer helpers echo the marker, never the token.
19
+ - **A close code sent before `accept()` never reaches the client.** Daphne and Uvicorn answer the
20
+ upgrade with an HTTP 403 instead, and the browser sees code 1006, the same as a dropped network.
21
+ `refuse()` accepts first and then closes, so the client receives `4401` and knows to renew its
22
+ token.
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ pip install "channels-jwt-subprotocol[simplejwt]"
28
+ ```
29
+
30
+ Python 3.10+, Django 4.2+, Channels 4.0+. Leave out `[simplejwt]` if you validate tokens yourself.
31
+
32
+ ## Use
33
+
34
+ **Client.** Offer the marker `jwt`, then the token:
35
+
36
+ ```js
37
+ const ws = new WebSocket('wss://example.com/ws/feed/', ['jwt', accessToken])
38
+ ```
39
+
40
+ A JWT is made of base64url segments and dots, which are all valid subprotocol characters. An invalid
41
+ value makes the constructor throw a `SyntaxError`, so treat a throw like a failed connect.
42
+
43
+ **ASGI.** Wrap your WebSocket router:
44
+
45
+ ```python
46
+ # asgi.py
47
+ from channels.routing import ProtocolTypeRouter, URLRouter
48
+ from channels.security.websocket import AllowedHostsOriginValidator
49
+ from django.core.asgi import get_asgi_application
50
+
51
+ django_asgi_app = get_asgi_application()
52
+
53
+ from channels_jwt_subprotocol import JWTSubprotocolMiddleware # noqa: E402
54
+ from myapp.routing import websocket_urlpatterns # noqa: E402
55
+
56
+ application = ProtocolTypeRouter({
57
+ "http": django_asgi_app,
58
+ "websocket": AllowedHostsOriginValidator(
59
+ JWTSubprotocolMiddleware(URLRouter(websocket_urlpatterns))
60
+ ),
61
+ })
62
+ ```
63
+
64
+ **Consumer.** Use the mixin's `accept_authenticated()` and `refuse()` instead of calling `accept()`
65
+ and `close()` yourself:
66
+
67
+ ```python
68
+ from channels.generic.websocket import AsyncJsonWebsocketConsumer
69
+ from channels_jwt_subprotocol import AsyncJWTSubprotocolMixin
70
+
71
+
72
+ class FeedConsumer(AsyncJWTSubprotocolMixin, AsyncJsonWebsocketConsumer):
73
+ async def connect(self):
74
+ if not self.is_authenticated():
75
+ return await self.refuse() # accept, then close with 4401
76
+ await self.accept_authenticated() # echoes "jwt", never the token
77
+ await self.send_json({"type": "connected"})
78
+ ```
79
+
80
+ `JWTSubprotocolMixin` is the same for the synchronous `WebsocketConsumer` and
81
+ `JsonWebsocketConsumer`.
82
+
83
+ **Client, on close.** Because the server accepts before it refuses, `onopen` fires for a socket that is
84
+ about to be closed. Treat the connection as live when the server's own first message arrives, and
85
+ renew the token on `4401`:
86
+
87
+ ```js
88
+ ws.onmessage = (e) => {
89
+ if (JSON.parse(e.data).type === 'connected') markLive()
90
+ }
91
+ ws.onclose = (e) => {
92
+ if (e.code === 4401) return renewTokenThenReconnect() // refused: a new token may help
93
+ scheduleRetry() // network: back off and retry
94
+ }
95
+ ```
96
+
97
+ If a freshly renewed token is refused too, back off instead of renewing again, or a deactivated
98
+ account turns into a loop against your refresh endpoint.
99
+
100
+ ## Behaviour
101
+
102
+ | Client offers | `scope["user"]` | Consumer with `refuse()` |
103
+ |---|---|---|
104
+ | `["jwt", <valid access token>]` | the token's user | accepted, echoes `jwt` |
105
+ | `["jwt", <expired, refresh, garbage token>]` | `AnonymousUser` | accepted with `jwt`, closed `4401` |
106
+ | `["jwt"]` | `AnonymousUser` | accepted with `jwt`, closed `4401` |
107
+ | nothing, or no `jwt` marker | unchanged, or `AnonymousUser` if unset | accepted, closed `4401` |
108
+
109
+ The token is the value **immediately after** the marker, so a client can offer an application
110
+ protocol too: `['graphql-transport-ws', 'jwt', token]`. If you do, accept with that protocol yourself,
111
+ as the server may echo only one value.
112
+
113
+ When the marker is offered, the token decides the user, even if an earlier middleware already set
114
+ one. When it is not, an existing `scope["user"]` is left alone, so the middleware can sit inside
115
+ `AuthMiddlewareStack` for apps that also use session cookies.
116
+
117
+ The default check is SimpleJWT's `JWTAuthentication`, the class that already guards your REST API:
118
+ same signing key, expiry, token type and `is_active` rule, no second implementation to drift.
119
+
120
+ ## Options
121
+
122
+ ```python
123
+ JWTSubprotocolMiddleware(inner, marker="jwt", get_user=None)
124
+ ```
125
+
126
+ - `marker`: the fixed value the client offers before the token. Use the same one in the client.
127
+ - `get_user`: an async callable `get_user(raw_token) -> user`. Return `AnonymousUser()` to refuse.
128
+ Use it for a non-SimpleJWT token, or to add your own checks.
129
+
130
+ ```python
131
+ from channels.db import database_sync_to_async
132
+ from django.contrib.auth.models import AnonymousUser
133
+
134
+
135
+ @database_sync_to_async
136
+ def get_user(raw_token):
137
+ user = verify_with_your_identity_provider(raw_token)
138
+ return user or AnonymousUser()
139
+
140
+
141
+ application = JWTSubprotocolMiddleware(router, get_user=get_user)
142
+ ```
143
+
144
+ The refusal code is `CLOSE_UNAUTHENTICATED` (`4401`). Override `close_unauthenticated_code` on the
145
+ consumer, or pass `refuse(code=...)`.
146
+
147
+ ## What this does not do
148
+
149
+ - **It authenticates the handshake only.** A socket opened with a token that has two minutes left
150
+ stays open after the token expires. Renew near-expiry tokens before connecting, and close sockets
151
+ yourself if your app needs revocation to take effect immediately.
152
+ - **It is not encryption.** Use `wss://`. The point is that the token is not written to logs by
153
+ default, not that it is hidden from anything on the connection.
154
+ - **Check your own logging.** A proxy configured to log request headers will log this one too.
155
+
156
+ ## Tests
157
+
158
+ ```bash
159
+ pip install -e ".[test]"
160
+ pytest
161
+ ```
162
+
163
+ The suite includes live tests that start Daphne and Uvicorn on a local port and connect with the
164
+ `websockets` client, which is how the HTTP 403 for a close before `accept()`, and the `4401` that
165
+ arrives after accepting, are checked against real servers rather than assumed. They pass on Daphne
166
+ 4.0 and 4.2, Uvicorn 0.30 and 0.54, Channels 4.0 and 4.3.
167
+
168
+ ## Background
169
+
170
+ Written for the dashboard of [TheLoops](https://theloops.live), where one socket per tab pushes the
171
+ status of 24/7 streams. The long version, including the nginx and idle-timeout parts, is in
172
+ [Django Channels: JWT auth without the token in the URL](https://theloops.live/engineering/django-channels-jwt-websocket).
173
+
174
+ ## License
175
+
176
+ MIT
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "channels-jwt-subprotocol"
7
+ version = "1.0.0"
8
+ description = "Authenticate Django Channels WebSockets with a JWT sent as a subprotocol, so the token never lands in the URL or in access logs."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.10"
13
+ authors = [{ name = "TheLoops", email = "support@theloops.live" }]
14
+ keywords = ["django", "channels", "websocket", "jwt", "simplejwt", "authentication", "subprotocol"]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Framework :: Django",
18
+ "Operating System :: OS Independent",
19
+ "Topic :: Internet :: WWW/HTTP",
20
+ ]
21
+ dependencies = ["channels>=4.0", "Django>=4.2"]
22
+
23
+ [project.optional-dependencies]
24
+ simplejwt = ["djangorestframework-simplejwt>=5.0"]
25
+ test = ["pytest>=7", "djangorestframework-simplejwt>=5.0", "daphne>=4.0", "uvicorn>=0.30", "websockets>=14"]
26
+
27
+ [project.urls]
28
+ Homepage = "https://theloops.live/engineering/channels-jwt-subprotocol"
29
+ Source = "https://github.com/YouPanel/channels-jwt-subprotocol"
30
+
31
+ [tool.setuptools.packages.find]
32
+ where = ["src"]
33
+
34
+ [tool.pytest.ini_options]
35
+ testpaths = ["tests"]
36
+ pythonpath = ["src", "."]
37
+ markers = ["live: starts a real ASGI server (Daphne or Uvicorn) on a local port"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,20 @@
1
+ """Authenticate Django Channels WebSockets with a JWT sent as a subprotocol."""
2
+ from .consumer import CLOSE_UNAUTHENTICATED, AsyncJWTSubprotocolMixin, JWTSubprotocolMixin
3
+ from .middleware import (
4
+ DEFAULT_MARKER,
5
+ SCOPE_KEY,
6
+ JWTSubprotocolMiddleware,
7
+ token_from_subprotocols,
8
+ )
9
+
10
+ __all__ = [
11
+ "CLOSE_UNAUTHENTICATED",
12
+ "DEFAULT_MARKER",
13
+ "SCOPE_KEY",
14
+ "AsyncJWTSubprotocolMixin",
15
+ "JWTSubprotocolMiddleware",
16
+ "JWTSubprotocolMixin",
17
+ "token_from_subprotocols",
18
+ ]
19
+
20
+ __version__ = "1.0.0"
@@ -0,0 +1,72 @@
1
+ """Consumer helpers: echo the marker on accept, and refuse with a code that arrives.
2
+
3
+ Two handshake details decide whether the browser gets what you meant:
4
+
5
+ * If the client offered subprotocols, the server must answer with one of them.
6
+ Answer with none and the browser fails the connection (close code 1006).
7
+ Answer with the token and the token now sits in a response header.
8
+ * A ``close()`` before ``accept()`` is not a WebSocket close frame. Daphne and
9
+ Uvicorn answer the upgrade with an HTTP 403, so the close code never reaches
10
+ the client, which sees 1006 again, the same as a dropped network. To send a
11
+ code the client can act on, accept first and close second.
12
+
13
+ Accepting before refusing admits nothing as long as the consumer joins no
14
+ group and sends no frame first, but the browser's ``onopen`` does fire. Mark
15
+ the client as connected on a message the server sends after a successful
16
+ accept, not on ``onopen``.
17
+ """
18
+ from .middleware import SCOPE_KEY
19
+
20
+ #: Close code for "this token is not good, renew it and reconnect".
21
+ CLOSE_UNAUTHENTICATED = 4401
22
+
23
+
24
+ def _offered_marker(scope):
25
+ return scope.get(SCOPE_KEY)
26
+
27
+
28
+ class _Base:
29
+ close_unauthenticated_code = CLOSE_UNAUTHENTICATED
30
+
31
+ def is_authenticated(self):
32
+ user = self.scope.get("user")
33
+ return bool(user is not None and getattr(user, "is_authenticated", False))
34
+
35
+
36
+ class AsyncJWTSubprotocolMixin(_Base):
37
+ """For ``AsyncWebsocketConsumer`` and ``AsyncJsonWebsocketConsumer``.
38
+
39
+ Usage::
40
+
41
+ class Feed(AsyncJWTSubprotocolMixin, AsyncJsonWebsocketConsumer):
42
+ async def connect(self):
43
+ if not self.is_authenticated():
44
+ return await self.refuse()
45
+ await self.accept_authenticated()
46
+ """
47
+
48
+ async def accept_authenticated(self, headers=None):
49
+ """Accept, echoing the marker if the client offered it."""
50
+ # ``headers`` is passed only when given: Channels 4.0's accept() has no
51
+ # such parameter.
52
+ kwargs = {"headers": headers} if headers else {}
53
+ await self.accept(subprotocol=_offered_marker(self.scope), **kwargs)
54
+
55
+ async def refuse(self, code=None):
56
+ """Accept, then close with ``code`` (4401 by default)."""
57
+ await self.accept(subprotocol=_offered_marker(self.scope))
58
+ await self.close(code=code or self.close_unauthenticated_code)
59
+
60
+
61
+ class JWTSubprotocolMixin(_Base):
62
+ """For the synchronous ``WebsocketConsumer`` and ``JsonWebsocketConsumer``."""
63
+
64
+ def accept_authenticated(self, headers=None):
65
+ """Accept, echoing the marker if the client offered it."""
66
+ kwargs = {"headers": headers} if headers else {}
67
+ self.accept(subprotocol=_offered_marker(self.scope), **kwargs)
68
+
69
+ def refuse(self, code=None):
70
+ """Accept, then close with ``code`` (4401 by default)."""
71
+ self.accept(subprotocol=_offered_marker(self.scope))
72
+ self.close(code=code or self.close_unauthenticated_code)
@@ -0,0 +1,117 @@
1
+ """ASGI middleware that authenticates a WebSocket from a JWT sent as a subprotocol.
2
+
3
+ A browser cannot set an ``Authorization`` header on a WebSocket handshake. The
4
+ two places left for a token are the query string and the
5
+ ``Sec-WebSocket-Protocol`` header. The query string is part of the request line,
6
+ which access logs, proxies and CDNs record by default. A subprotocol travels as
7
+ a request header, which they do not.
8
+
9
+ The client offers two subprotocols, a fixed marker and the token right after it::
10
+
11
+ new WebSocket(url, ["jwt", accessToken])
12
+
13
+ The middleware finds the marker, takes the value that follows it as the token,
14
+ and puts the resolved user in ``scope["user"]``. It also records the marker in
15
+ ``scope["jwt_subprotocol"]`` so the consumer can echo it back on ``accept()``:
16
+ the server has to answer with one of the offered values, and it must be the
17
+ marker, never the token.
18
+ """
19
+ from django.core.exceptions import ImproperlyConfigured
20
+
21
+ DEFAULT_MARKER = "jwt"
22
+ SCOPE_KEY = "jwt_subprotocol"
23
+
24
+
25
+ def token_from_subprotocols(subprotocols, marker=DEFAULT_MARKER):
26
+ """Return the value offered right after ``marker``, or ``None``.
27
+
28
+ ``None`` when the marker is absent, when nothing follows it, or when the
29
+ value after it is the marker again. Other subprotocols the client offers
30
+ before the marker are ignored, so a client can still negotiate an
31
+ application protocol alongside the token.
32
+ """
33
+ values = [str(p) for p in (subprotocols or [])]
34
+ try:
35
+ index = values.index(marker)
36
+ except ValueError:
37
+ return None
38
+ if index + 1 >= len(values):
39
+ return None
40
+ token = values[index + 1]
41
+ if not token or token == marker:
42
+ return None
43
+ return token
44
+
45
+
46
+ def _anonymous_user():
47
+ from django.contrib.auth.models import AnonymousUser
48
+
49
+ return AnonymousUser()
50
+
51
+
52
+ def _simplejwt_resolver():
53
+ """Build the default resolver on top of SimpleJWT's ``JWTAuthentication``.
54
+
55
+ It reuses the class that already authenticates the REST API, so the signing
56
+ key, the expiry, the token type and the user's ``is_active`` flag are
57
+ checked by the same code in both places.
58
+ """
59
+ try:
60
+ from rest_framework_simplejwt.authentication import JWTAuthentication
61
+ from rest_framework_simplejwt.exceptions import (
62
+ AuthenticationFailed,
63
+ InvalidToken,
64
+ TokenError,
65
+ )
66
+ except ImportError as exc: # pragma: no cover - exercised without simplejwt
67
+ raise ImproperlyConfigured(
68
+ "JWTSubprotocolMiddleware needs djangorestframework-simplejwt for "
69
+ "its default token check. Install it, or pass get_user=..."
70
+ ) from exc
71
+
72
+ from channels.db import database_sync_to_async
73
+
74
+ @database_sync_to_async
75
+ def resolve(raw_token):
76
+ backend = JWTAuthentication()
77
+ try:
78
+ return backend.get_user(backend.get_validated_token(raw_token))
79
+ except (AuthenticationFailed, InvalidToken, TokenError):
80
+ return _anonymous_user()
81
+
82
+ return resolve
83
+
84
+
85
+ class JWTSubprotocolMiddleware:
86
+ """Populate ``scope["user"]`` from a JWT offered as a WebSocket subprotocol.
87
+
88
+ ``get_user`` is an async callable that takes the raw token and returns a
89
+ user, or ``AnonymousUser`` for a token it refuses. Leave it out to validate
90
+ with SimpleJWT.
91
+
92
+ When the client offers the marker, the token decides ``scope["user"]``: a
93
+ missing or refused token gives ``AnonymousUser`` even if an earlier
94
+ middleware had already set a user. When the client does not offer the
95
+ marker at all, an existing ``scope["user"]`` is left alone, so this can sit
96
+ inside ``AuthMiddlewareStack`` for apps that also use session cookies.
97
+ """
98
+
99
+ def __init__(self, inner, marker=DEFAULT_MARKER, get_user=None):
100
+ if not marker:
101
+ raise ValueError("marker must be a non-empty string")
102
+ self.inner = inner
103
+ self.marker = marker
104
+ self.get_user = get_user or _simplejwt_resolver()
105
+
106
+ async def __call__(self, scope, receive, send):
107
+ if scope.get("type") == "websocket":
108
+ subprotocols = scope.get("subprotocols") or []
109
+ if self.marker in [str(p) for p in subprotocols]:
110
+ scope = dict(scope)
111
+ scope[SCOPE_KEY] = self.marker
112
+ token = token_from_subprotocols(subprotocols, self.marker)
113
+ scope["user"] = await self.get_user(token) if token else _anonymous_user()
114
+ elif "user" not in scope:
115
+ scope = dict(scope)
116
+ scope["user"] = _anonymous_user()
117
+ return await self.inner(scope, receive, send)
@@ -0,0 +1,204 @@
1
+ Metadata-Version: 2.4
2
+ Name: channels-jwt-subprotocol
3
+ Version: 1.0.0
4
+ Summary: Authenticate Django Channels WebSockets with a JWT sent as a subprotocol, so the token never lands in the URL or in access logs.
5
+ Author-email: TheLoops <support@theloops.live>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://theloops.live/engineering/channels-jwt-subprotocol
8
+ Project-URL: Source, https://github.com/YouPanel/channels-jwt-subprotocol
9
+ Keywords: django,channels,websocket,jwt,simplejwt,authentication,subprotocol
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Framework :: Django
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Topic :: Internet :: WWW/HTTP
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: channels>=4.0
18
+ Requires-Dist: Django>=4.2
19
+ Provides-Extra: simplejwt
20
+ Requires-Dist: djangorestframework-simplejwt>=5.0; extra == "simplejwt"
21
+ Provides-Extra: test
22
+ Requires-Dist: pytest>=7; extra == "test"
23
+ Requires-Dist: djangorestframework-simplejwt>=5.0; extra == "test"
24
+ Requires-Dist: daphne>=4.0; extra == "test"
25
+ Requires-Dist: uvicorn>=0.30; extra == "test"
26
+ Requires-Dist: websockets>=14; extra == "test"
27
+ Dynamic: license-file
28
+
29
+ # channels-jwt-subprotocol
30
+
31
+ Authenticate [Django Channels](https://channels.readthedocs.io/) WebSockets with a JWT sent as a
32
+ **subprotocol**, so the token never appears in the URL.
33
+
34
+ A browser cannot set an `Authorization` header on a WebSocket handshake. The usual workaround puts
35
+ the token in the query string, `wss://example.com/ws/?token=…`, and the query string is part of the
36
+ request line that nginx, load balancers and CDNs write to their access logs by default. An access
37
+ token lives for minutes; a log line lives for months.
38
+
39
+ The `Sec-WebSocket-Protocol` header is the other place a browser lets you put a value, and access
40
+ logs do not record it by default. This package reads the token from there, validates it with
41
+ [SimpleJWT](https://github.com/jazzband/djangorestframework-simplejwt) (or any check you supply),
42
+ and handles the two handshake details that otherwise fail silently:
43
+
44
+ - **The server must echo a subprotocol the client offered.** Echo nothing and the browser drops the
45
+ connection; echo the token and it ends up in a response header. The middleware records a fixed
46
+ marker, and the consumer helpers echo the marker, never the token.
47
+ - **A close code sent before `accept()` never reaches the client.** Daphne and Uvicorn answer the
48
+ upgrade with an HTTP 403 instead, and the browser sees code 1006, the same as a dropped network.
49
+ `refuse()` accepts first and then closes, so the client receives `4401` and knows to renew its
50
+ token.
51
+
52
+ ## Install
53
+
54
+ ```bash
55
+ pip install "channels-jwt-subprotocol[simplejwt]"
56
+ ```
57
+
58
+ Python 3.10+, Django 4.2+, Channels 4.0+. Leave out `[simplejwt]` if you validate tokens yourself.
59
+
60
+ ## Use
61
+
62
+ **Client.** Offer the marker `jwt`, then the token:
63
+
64
+ ```js
65
+ const ws = new WebSocket('wss://example.com/ws/feed/', ['jwt', accessToken])
66
+ ```
67
+
68
+ A JWT is made of base64url segments and dots, which are all valid subprotocol characters. An invalid
69
+ value makes the constructor throw a `SyntaxError`, so treat a throw like a failed connect.
70
+
71
+ **ASGI.** Wrap your WebSocket router:
72
+
73
+ ```python
74
+ # asgi.py
75
+ from channels.routing import ProtocolTypeRouter, URLRouter
76
+ from channels.security.websocket import AllowedHostsOriginValidator
77
+ from django.core.asgi import get_asgi_application
78
+
79
+ django_asgi_app = get_asgi_application()
80
+
81
+ from channels_jwt_subprotocol import JWTSubprotocolMiddleware # noqa: E402
82
+ from myapp.routing import websocket_urlpatterns # noqa: E402
83
+
84
+ application = ProtocolTypeRouter({
85
+ "http": django_asgi_app,
86
+ "websocket": AllowedHostsOriginValidator(
87
+ JWTSubprotocolMiddleware(URLRouter(websocket_urlpatterns))
88
+ ),
89
+ })
90
+ ```
91
+
92
+ **Consumer.** Use the mixin's `accept_authenticated()` and `refuse()` instead of calling `accept()`
93
+ and `close()` yourself:
94
+
95
+ ```python
96
+ from channels.generic.websocket import AsyncJsonWebsocketConsumer
97
+ from channels_jwt_subprotocol import AsyncJWTSubprotocolMixin
98
+
99
+
100
+ class FeedConsumer(AsyncJWTSubprotocolMixin, AsyncJsonWebsocketConsumer):
101
+ async def connect(self):
102
+ if not self.is_authenticated():
103
+ return await self.refuse() # accept, then close with 4401
104
+ await self.accept_authenticated() # echoes "jwt", never the token
105
+ await self.send_json({"type": "connected"})
106
+ ```
107
+
108
+ `JWTSubprotocolMixin` is the same for the synchronous `WebsocketConsumer` and
109
+ `JsonWebsocketConsumer`.
110
+
111
+ **Client, on close.** Because the server accepts before it refuses, `onopen` fires for a socket that is
112
+ about to be closed. Treat the connection as live when the server's own first message arrives, and
113
+ renew the token on `4401`:
114
+
115
+ ```js
116
+ ws.onmessage = (e) => {
117
+ if (JSON.parse(e.data).type === 'connected') markLive()
118
+ }
119
+ ws.onclose = (e) => {
120
+ if (e.code === 4401) return renewTokenThenReconnect() // refused: a new token may help
121
+ scheduleRetry() // network: back off and retry
122
+ }
123
+ ```
124
+
125
+ If a freshly renewed token is refused too, back off instead of renewing again, or a deactivated
126
+ account turns into a loop against your refresh endpoint.
127
+
128
+ ## Behaviour
129
+
130
+ | Client offers | `scope["user"]` | Consumer with `refuse()` |
131
+ |---|---|---|
132
+ | `["jwt", <valid access token>]` | the token's user | accepted, echoes `jwt` |
133
+ | `["jwt", <expired, refresh, garbage token>]` | `AnonymousUser` | accepted with `jwt`, closed `4401` |
134
+ | `["jwt"]` | `AnonymousUser` | accepted with `jwt`, closed `4401` |
135
+ | nothing, or no `jwt` marker | unchanged, or `AnonymousUser` if unset | accepted, closed `4401` |
136
+
137
+ The token is the value **immediately after** the marker, so a client can offer an application
138
+ protocol too: `['graphql-transport-ws', 'jwt', token]`. If you do, accept with that protocol yourself,
139
+ as the server may echo only one value.
140
+
141
+ When the marker is offered, the token decides the user, even if an earlier middleware already set
142
+ one. When it is not, an existing `scope["user"]` is left alone, so the middleware can sit inside
143
+ `AuthMiddlewareStack` for apps that also use session cookies.
144
+
145
+ The default check is SimpleJWT's `JWTAuthentication`, the class that already guards your REST API:
146
+ same signing key, expiry, token type and `is_active` rule, no second implementation to drift.
147
+
148
+ ## Options
149
+
150
+ ```python
151
+ JWTSubprotocolMiddleware(inner, marker="jwt", get_user=None)
152
+ ```
153
+
154
+ - `marker`: the fixed value the client offers before the token. Use the same one in the client.
155
+ - `get_user`: an async callable `get_user(raw_token) -> user`. Return `AnonymousUser()` to refuse.
156
+ Use it for a non-SimpleJWT token, or to add your own checks.
157
+
158
+ ```python
159
+ from channels.db import database_sync_to_async
160
+ from django.contrib.auth.models import AnonymousUser
161
+
162
+
163
+ @database_sync_to_async
164
+ def get_user(raw_token):
165
+ user = verify_with_your_identity_provider(raw_token)
166
+ return user or AnonymousUser()
167
+
168
+
169
+ application = JWTSubprotocolMiddleware(router, get_user=get_user)
170
+ ```
171
+
172
+ The refusal code is `CLOSE_UNAUTHENTICATED` (`4401`). Override `close_unauthenticated_code` on the
173
+ consumer, or pass `refuse(code=...)`.
174
+
175
+ ## What this does not do
176
+
177
+ - **It authenticates the handshake only.** A socket opened with a token that has two minutes left
178
+ stays open after the token expires. Renew near-expiry tokens before connecting, and close sockets
179
+ yourself if your app needs revocation to take effect immediately.
180
+ - **It is not encryption.** Use `wss://`. The point is that the token is not written to logs by
181
+ default, not that it is hidden from anything on the connection.
182
+ - **Check your own logging.** A proxy configured to log request headers will log this one too.
183
+
184
+ ## Tests
185
+
186
+ ```bash
187
+ pip install -e ".[test]"
188
+ pytest
189
+ ```
190
+
191
+ The suite includes live tests that start Daphne and Uvicorn on a local port and connect with the
192
+ `websockets` client, which is how the HTTP 403 for a close before `accept()`, and the `4401` that
193
+ arrives after accepting, are checked against real servers rather than assumed. They pass on Daphne
194
+ 4.0 and 4.2, Uvicorn 0.30 and 0.54, Channels 4.0 and 4.3.
195
+
196
+ ## Background
197
+
198
+ Written for the dashboard of [TheLoops](https://theloops.live), where one socket per tab pushes the
199
+ status of 24/7 streams. The long version, including the nginx and idle-timeout parts, is in
200
+ [Django Channels: JWT auth without the token in the URL](https://theloops.live/engineering/django-channels-jwt-websocket).
201
+
202
+ ## License
203
+
204
+ MIT
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/channels_jwt_subprotocol/__init__.py
5
+ src/channels_jwt_subprotocol/consumer.py
6
+ src/channels_jwt_subprotocol/middleware.py
7
+ src/channels_jwt_subprotocol.egg-info/PKG-INFO
8
+ src/channels_jwt_subprotocol.egg-info/SOURCES.txt
9
+ src/channels_jwt_subprotocol.egg-info/dependency_links.txt
10
+ src/channels_jwt_subprotocol.egg-info/requires.txt
11
+ src/channels_jwt_subprotocol.egg-info/top_level.txt
12
+ tests/test_live_servers.py
13
+ tests/test_middleware.py
@@ -0,0 +1,12 @@
1
+ channels>=4.0
2
+ Django>=4.2
3
+
4
+ [simplejwt]
5
+ djangorestframework-simplejwt>=5.0
6
+
7
+ [test]
8
+ pytest>=7
9
+ djangorestframework-simplejwt>=5.0
10
+ daphne>=4.0
11
+ uvicorn>=0.30
12
+ websockets>=14
@@ -0,0 +1,111 @@
1
+ """The same handshakes against real ASGI servers, over a real socket.
2
+
3
+ WebsocketCommunicator talks ASGI directly, so it cannot show what a browser
4
+ receives. These tests start Daphne and Uvicorn and connect with the
5
+ `websockets` client, which reports the HTTP status of a refused upgrade and
6
+ the close code of a refused connection the way a browser would see them.
7
+ """
8
+ import asyncio
9
+ import os
10
+ import socket
11
+ import subprocess
12
+ import sys
13
+ import time
14
+
15
+ import pytest
16
+ import websockets
17
+ from django.contrib.auth import get_user_model
18
+ from rest_framework_simplejwt.tokens import RefreshToken
19
+
20
+ pytestmark = pytest.mark.live
21
+
22
+ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
23
+
24
+
25
+ def free_port():
26
+ with socket.socket() as s:
27
+ s.bind(("127.0.0.1", 0))
28
+ return s.getsockname()[1]
29
+
30
+
31
+ def server_command(name, port):
32
+ if name == "daphne":
33
+ return [sys.executable, "-m", "daphne", "-b", "127.0.0.1", "-p", str(port), "tests.live_asgi:app"]
34
+ return [sys.executable, "-m", "uvicorn", "--host", "127.0.0.1", "--port", str(port),
35
+ "--log-level", "warning", "tests.live_asgi:app"]
36
+
37
+
38
+ @pytest.fixture(scope="module", params=["daphne", "uvicorn"])
39
+ def server(request):
40
+ port = free_port()
41
+ env = dict(os.environ, PYTHONPATH=os.pathsep.join([ROOT, os.path.join(ROOT, "src")]))
42
+ proc = subprocess.Popen(server_command(request.param, port), cwd=ROOT, env=env,
43
+ stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
44
+ deadline = time.time() + 15
45
+ while time.time() < deadline:
46
+ try:
47
+ socket.create_connection(("127.0.0.1", port), timeout=0.2).close()
48
+ break
49
+ except OSError:
50
+ if proc.poll() is not None:
51
+ raise RuntimeError(proc.stderr.read().decode())
52
+ time.sleep(0.1)
53
+ else:
54
+ proc.kill()
55
+ raise RuntimeError(f"{request.param} did not start")
56
+ yield request.param, f"ws://127.0.0.1:{port}"
57
+ proc.terminate()
58
+ proc.wait(timeout=10)
59
+
60
+
61
+ def token_for(username):
62
+ User = get_user_model()
63
+ User.objects.filter(username=username).delete()
64
+ user = User.objects.create_user(username=username, password="x")
65
+ return str(RefreshToken.for_user(user).access_token)
66
+
67
+
68
+ async def open_and_read(url, subprotocols):
69
+ async with websockets.connect(url, subprotocols=subprotocols, open_timeout=5) as ws:
70
+ try:
71
+ first = await asyncio.wait_for(ws.recv(), timeout=3)
72
+ except websockets.ConnectionClosed:
73
+ first = None
74
+ return ws.subprotocol, first, ws.close_code
75
+
76
+
77
+ @pytest.mark.parametrize("path", ["/ws/async/", "/ws/sync/"])
78
+ def test_valid_token_over_the_wire(server, path):
79
+ _, base = server
80
+ token = token_for("live-alice")
81
+ subprotocol, first, _ = asyncio.run(open_and_read(base + path, ["jwt", token]))
82
+ assert subprotocol == "jwt"
83
+ assert '"live-alice"' in first
84
+
85
+
86
+ @pytest.mark.parametrize("path", ["/ws/async/", "/ws/sync/"])
87
+ def test_refusal_code_reaches_the_client(server, path):
88
+ _, base = server
89
+
90
+ async def go():
91
+ async with websockets.connect(base + path, subprotocols=["jwt", "bad.token.value"], open_timeout=5) as ws:
92
+ with pytest.raises(websockets.ConnectionClosed):
93
+ await asyncio.wait_for(ws.recv(), timeout=3)
94
+ return ws.subprotocol, ws.close_code
95
+
96
+ subprotocol, code = asyncio.run(go())
97
+ assert subprotocol == "jwt"
98
+ assert code == 4401
99
+
100
+
101
+ def test_close_before_accept_is_an_http_403(server):
102
+ """The reason `refuse()` accepts first: this code never reaches a client."""
103
+ _, base = server
104
+
105
+ async def go():
106
+ with pytest.raises(websockets.InvalidStatus) as exc:
107
+ async with websockets.connect(base + "/ws/close-before-accept/", subprotocols=["jwt", "x"], open_timeout=5):
108
+ pass
109
+ return exc.value.response.status_code
110
+
111
+ assert asyncio.run(go()) == 403
@@ -0,0 +1,210 @@
1
+ import asyncio
2
+ from datetime import timedelta
3
+
4
+ import pytest
5
+ from asgiref.sync import sync_to_async
6
+ from channels.testing import WebsocketCommunicator
7
+ from django.contrib.auth import get_user_model
8
+ from django.contrib.auth.models import AnonymousUser
9
+ from rest_framework_simplejwt.tokens import AccessToken, RefreshToken
10
+
11
+ from channels_jwt_subprotocol import (
12
+ SCOPE_KEY,
13
+ JWTSubprotocolMiddleware,
14
+ token_from_subprotocols,
15
+ )
16
+
17
+ from .apps import application
18
+
19
+
20
+ def run(coro):
21
+ return asyncio.run(coro)
22
+
23
+
24
+ def make_user(username, active=True):
25
+ User = get_user_model()
26
+ User.objects.filter(username=username).delete()
27
+ return User.objects.create_user(username=username, password="x", is_active=active)
28
+
29
+
30
+ def access_for(user):
31
+ return str(RefreshToken.for_user(user).access_token)
32
+
33
+
34
+ async def handshake(path, subprotocols, app=None):
35
+ comm = WebsocketCommunicator(app or application(), path, subprotocols=subprotocols)
36
+ connected, subprotocol = await comm.connect()
37
+ frames = []
38
+ # receive_nothing() waits without cancelling the app; receive_output()
39
+ # would cancel it on a timeout.
40
+ while not await comm.receive_nothing(timeout=0.3):
41
+ frames.append(await comm.receive_output())
42
+ if frames[-1]["type"] == "websocket.close":
43
+ break
44
+ if not frames or frames[-1]["type"] != "websocket.close":
45
+ await comm.disconnect()
46
+ return connected, subprotocol, frames
47
+
48
+
49
+ def close_code(frames):
50
+ for f in frames:
51
+ if f["type"] == "websocket.close":
52
+ return f.get("code")
53
+ return None
54
+
55
+
56
+ # --- token_from_subprotocols -------------------------------------------------
57
+
58
+ @pytest.mark.parametrize(
59
+ "offered, expected",
60
+ [
61
+ (["jwt", "a.b.c"], "a.b.c"),
62
+ (["graphql-ws", "jwt", "a.b.c"], "a.b.c"),
63
+ (["jwt", "a.b.c", "extra"], "a.b.c"),
64
+ (["a.b.c", "jwt"], None),
65
+ (["jwt"], None),
66
+ (["jwt", "jwt"], None),
67
+ (["jwt", ""], None),
68
+ ([], None),
69
+ (None, None),
70
+ (["bearer", "a.b.c"], None),
71
+ ],
72
+ )
73
+ def test_token_is_the_value_after_the_marker(offered, expected):
74
+ assert token_from_subprotocols(offered) == expected
75
+
76
+
77
+ def test_custom_marker():
78
+ assert token_from_subprotocols(["bearer", "t"], marker="bearer") == "t"
79
+ assert token_from_subprotocols(["jwt", "t"], marker="bearer") is None
80
+
81
+
82
+ def test_empty_marker_is_refused():
83
+ with pytest.raises(ValueError):
84
+ JWTSubprotocolMiddleware(lambda *a: None, marker="", get_user=lambda t: None)
85
+
86
+
87
+ # --- with SimpleJWT, through a consumer ---------------------------------------
88
+
89
+ @pytest.mark.parametrize("path", ["/ws/async/", "/ws/sync/"])
90
+ def test_valid_token_is_accepted_and_only_the_marker_is_echoed(path):
91
+ user = make_user("alice")
92
+ token = access_for(user)
93
+ connected, subprotocol, frames = run(handshake(path, ["jwt", token]))
94
+ assert connected is True
95
+ assert subprotocol == "jwt"
96
+ assert token not in str(subprotocol)
97
+ sent = [f for f in frames if f["type"] == "websocket.send"]
98
+ assert sent and '"alice"' in sent[0]["text"]
99
+ assert close_code(frames) is None
100
+
101
+
102
+ @pytest.mark.parametrize("path", ["/ws/async/", "/ws/sync/"])
103
+ def test_bad_token_is_accepted_then_closed_with_4401(path):
104
+ connected, subprotocol, frames = run(handshake(path, ["jwt", "not.a.jwt"]))
105
+ assert connected is True
106
+ assert subprotocol == "jwt"
107
+ assert [f["type"] for f in frames] == ["websocket.close"]
108
+ assert close_code(frames) == 4401
109
+
110
+
111
+ def test_expired_token_is_refused():
112
+ user = make_user("bob")
113
+ token = AccessToken.for_user(user)
114
+ token.set_exp(lifetime=-timedelta(minutes=1))
115
+ _, _, frames = run(handshake("/ws/async/", ["jwt", str(token)]))
116
+ assert close_code(frames) == 4401
117
+
118
+
119
+ def test_refresh_token_is_not_an_access_token():
120
+ user = make_user("carol")
121
+ refresh = str(RefreshToken.for_user(user))
122
+ _, _, frames = run(handshake("/ws/async/", ["jwt", refresh]))
123
+ assert close_code(frames) == 4401
124
+
125
+
126
+ def test_inactive_user_is_refused():
127
+ user = make_user("dave", active=False)
128
+ _, _, frames = run(handshake("/ws/async/", ["jwt", access_for(user)]))
129
+ assert close_code(frames) == 4401
130
+
131
+
132
+ def test_marker_without_token_is_refused():
133
+ connected, subprotocol, frames = run(handshake("/ws/async/", ["jwt"]))
134
+ assert subprotocol == "jwt"
135
+ assert close_code(frames) == 4401
136
+
137
+
138
+ def test_no_subprotocols_refused_without_echo():
139
+ connected, subprotocol, frames = run(handshake("/ws/async/", None))
140
+ assert connected is True
141
+ assert subprotocol is None
142
+ assert close_code(frames) == 4401
143
+
144
+
145
+ # --- custom resolver and stacking ---------------------------------------------
146
+
147
+ def test_custom_get_user():
148
+ seen = []
149
+
150
+ async def get_user(token):
151
+ seen.append(token)
152
+ user = await sync_to_async(make_user)("erin")
153
+ return user if token == "letmein" else AnonymousUser()
154
+
155
+ app = application(get_user=get_user, marker="bearer")
156
+ ok = run(handshake("/ws/async/", ["bearer", "letmein"], app=app))
157
+ bad = run(handshake("/ws/async/", ["bearer", "nope"], app=app))
158
+ assert ok[1] == "bearer" and close_code(ok[2]) is None
159
+ assert bad[1] == "bearer" and close_code(bad[2]) == 4401
160
+ assert seen == ["letmein", "nope"]
161
+
162
+
163
+ def _capture_scope():
164
+ captured = {}
165
+
166
+ async def inner(scope, receive, send):
167
+ captured.update(scope)
168
+
169
+ return captured, inner
170
+
171
+
172
+ def test_existing_user_kept_when_marker_not_offered():
173
+ captured, inner = _capture_scope()
174
+ mw = JWTSubprotocolMiddleware(inner, get_user=lambda t: None)
175
+ sentinel = object()
176
+ run(mw({"type": "websocket", "subprotocols": [], "user": sentinel}, None, None))
177
+ assert captured["user"] is sentinel
178
+ assert SCOPE_KEY not in captured
179
+
180
+
181
+ def test_offered_marker_overrides_existing_user_even_when_refused():
182
+ captured, inner = _capture_scope()
183
+
184
+ async def refuse(token):
185
+ return AnonymousUser()
186
+
187
+ mw = JWTSubprotocolMiddleware(inner, get_user=refuse)
188
+ run(mw({"type": "websocket", "subprotocols": ["jwt", "x"], "user": object()}, None, None))
189
+ assert isinstance(captured["user"], AnonymousUser)
190
+ assert captured[SCOPE_KEY] == "jwt"
191
+
192
+
193
+ def test_http_scope_passes_through_untouched():
194
+ captured, inner = _capture_scope()
195
+ mw = JWTSubprotocolMiddleware(inner, get_user=lambda t: None)
196
+ scope = {"type": "http", "path": "/"}
197
+ run(mw(scope, None, None))
198
+ assert "user" not in captured
199
+
200
+
201
+ def test_caller_scope_is_not_mutated():
202
+ captured, inner = _capture_scope()
203
+
204
+ async def get_user(token):
205
+ return AnonymousUser()
206
+
207
+ mw = JWTSubprotocolMiddleware(inner, get_user=get_user)
208
+ scope = {"type": "websocket", "subprotocols": ["jwt", "x"]}
209
+ run(mw(scope, None, None))
210
+ assert "user" not in scope and SCOPE_KEY not in scope