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.
- channels_jwt_subprotocol/__init__.py +20 -0
- channels_jwt_subprotocol/consumer.py +72 -0
- channels_jwt_subprotocol/middleware.py +117 -0
- channels_jwt_subprotocol-1.0.0.dist-info/METADATA +204 -0
- channels_jwt_subprotocol-1.0.0.dist-info/RECORD +8 -0
- channels_jwt_subprotocol-1.0.0.dist-info/WHEEL +5 -0
- channels_jwt_subprotocol-1.0.0.dist-info/licenses/LICENSE +21 -0
- channels_jwt_subprotocol-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -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,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
|