channels-jwt-subprotocol 1.0.0__py3-none-any.whl

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,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,8 @@
1
+ channels_jwt_subprotocol/__init__.py,sha256=uaqK00n3SUOgs34eXpjJ9X7LJ79-f6sS_iKmNGc9YwI,521
2
+ channels_jwt_subprotocol/consumer.py,sha256=1jIZ0tQb-LZAMQ5cFJIJ6Kq4Yj2mEO8vRtezudLpFew,2943
3
+ channels_jwt_subprotocol/middleware.py,sha256=Wk69tEwpVDGi448GSdU8VZD3Xarb5OyAStdIP1IwfBQ,4626
4
+ channels_jwt_subprotocol-1.0.0.dist-info/licenses/LICENSE,sha256=Hf_XVAg044frNSZSzzufuXD9PA3q78GX83yQA3rPKq4,1089
5
+ channels_jwt_subprotocol-1.0.0.dist-info/METADATA,sha256=IrWzyrbx_GroIZLRViCXvks8y5tTQKTgtKPhCSIbeVk,8460
6
+ channels_jwt_subprotocol-1.0.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
7
+ channels_jwt_subprotocol-1.0.0.dist-info/top_level.txt,sha256=SkRMTD4tOMas1_9hBT2nYidBmDlz2mNUB49WU6DcjG0,25
8
+ channels_jwt_subprotocol-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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 @@
1
+ channels_jwt_subprotocol