Skip to content

Commit 8012cb3

Browse files
committed
Add WS auth handshake manual test script + CHANGELOG entry
1 parent 08bb116 commit 8012cb3

2 files changed

Lines changed: 205 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Security
11+
12+
- **WebSocket authentication tokens no longer travel in URL query strings**
13+
(issue: JWT leakage via nginx logs, browser history, `Referer` headers,
14+
Sentry breadcrumbs, and CDN/WAF logs). All WS connections now authenticate
15+
via the standard `Sec-WebSocket-Protocol` handshake header
16+
(`opencontracts.jwt.v1, <jwt>`). Token rotation is in-band via
17+
`{"type":"AUTH","token":"..."}` frames — no socket churn on refresh. Hard
18+
cutover: `?token=` URL parameters are stripped by the new middleware
19+
(`config/websocket/middleware.py`) and ignored. `Authorization: Bearer`
20+
headers are also no longer consulted for WS auth (browsers cannot set them
21+
on the WebSocket constructor anyway). Stale browser tabs from before the
22+
deploy must reload to recover.
23+
24+
- **`AuthHandshakeMixin`** (`config/websocket/auth_handshake.py`) — added
25+
to all three consumers. Refuses user-pk swap mid-connection
26+
(`USER_MISMATCH` → close 4002), re-runs resource permission checks on
27+
refresh (`PERMISSION_REVOKED` → close 4003), supports server-nudged
28+
refresh via `AUTH_REFRESH_REQUIRED` with a grace timer that closes 4001
29+
on timeout.
30+
31+
- **Frontend `useWebSocketAuth` hook**
32+
(`frontend/src/hooks/useWebSocketAuth.ts`) — single shared lifecycle
33+
owner used by `useAgentChat`, `useNotificationWebSocket`, and
34+
`CorpusChat`. Token rotation no longer reconnects the socket.
35+
36+
- **Removed**: deprecated `getDocumentQueryWebSocket` and
37+
`getCorpusQueryWebSocket` URL helpers (forwarded to the unified
38+
endpoint and now gone per the no-dead-code rule). Removed the
39+
`autoReconnect` / `reconnectDelay` options on `useNotificationWebSocket`
40+
— reconnect is owned by the shared hook.
41+
42+
- **Tests**: `opencontractserver/tests/test_websocket_auth.py` gains four
43+
new test classes (`JWTAuthMiddlewareSubprotocolTests`,
44+
`AuthHandshakeMixinTests`, `UnifiedAgentHandshakeTests`,
45+
`ThreadUpdatesHandshakeTests`, `NotificationUpdatesHandshakeTests`) —
46+
~30 cases covering middleware, mixin, per-consumer integration,
47+
user-mismatch refusal, and `?token=` regression. Frontend adds
48+
`useWebSocketAuth.test.ts` and `websocketAuth.test.ts`; the existing
49+
`useNotificationWebSocket.auth.test.ts` is rewritten as a no-token-in-URL
50+
regression suite.
51+
52+
- **Manual test script**:
53+
`docs/test_scripts/websocket-auth-handshake.md` — verifies subprotocol
54+
transport, in-band refresh, user-pk-swap refusal, and DevTools sanity
55+
check that the JWT never appears in the request URL.
56+
1057
### Added
1158

1259
- **Loud guardrail against the `system_prompt=` foot-gun in pydantic-ai** (Issue #1451): `pydantic_ai.Agent` accepts both `system_prompt=` and `instructions=`, but the `system_prompt` value is *only* materialised into the model request when `message_history` is `None`. OpenContracts' `chat()` flow always persists the user's HUMAN message before calling `Agent.run()`, so `message_history` is never empty in practice and any `system_prompt=` argument is silently dropped — the LLM runs without any system instruction. CLAUDE.md pitfall #14 documented the workaround (use `instructions=`), but a future pydantic-ai bump that renames or re-precedences these parameters could re-introduce the regression silently.
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
# Test: WebSocket Auth Handshake (subprotocol + in-band refresh)
2+
3+
## Purpose
4+
5+
Verify that:
6+
7+
1. WebSocket auth carries the JWT only via `Sec-WebSocket-Protocol`, never URL.
8+
2. Token rotation is in-band and does not churn the socket.
9+
3. Old `?token=` transport no longer authenticates (hard cutover).
10+
4. User-pk swap on a live socket is refused.
11+
12+
## Prerequisites
13+
14+
- Local stack running: `docker compose -f local.yml up`.
15+
- A Django superuser with a known password. To create or reset one:
16+
17+
```bash
18+
docker compose -f local.yml exec django python manage.py shell -c "
19+
from django.contrib.auth import get_user_model
20+
u = get_user_model().objects.filter(is_superuser=True).first()
21+
u.set_password('testpass123')
22+
u.save()
23+
print(f'Password set for {u.username}')
24+
"
25+
```
26+
27+
## Steps
28+
29+
### 1. Mint a fresh JWT
30+
31+
```bash
32+
TOKEN=$(docker compose -f local.yml exec django python manage.py shell -c "
33+
from graphql_jwt.shortcuts import get_token
34+
from django.contrib.auth import get_user_model
35+
print(get_token(get_user_model().objects.filter(is_superuser=True).first()))
36+
" | tr -d '\r' | tail -1)
37+
echo "$TOKEN"
38+
```
39+
40+
**Expected:** A long JWT string (3 dot-separated segments).
41+
42+
### 2. Regression — query-string token must be ignored
43+
44+
```bash
45+
python3 -c "
46+
import asyncio, websockets
47+
async def main():
48+
try:
49+
async with websockets.connect(
50+
f'ws://localhost:8000/ws/notification-updates/?token=$TOKEN'
51+
) as ws:
52+
msg = await asyncio.wait_for(ws.recv(), 5)
53+
print('UNEXPECTED frame:', msg)
54+
except Exception as e:
55+
print('OK — connection rejected:', type(e).__name__, e)
56+
asyncio.run(main())
57+
"
58+
```
59+
60+
**Expected:** Connection rejected. `NotificationUpdatesConsumer` requires auth and the URL token is not consulted, so the consumer closes 4001 (or the handshake fails because no subprotocol was negotiated).
61+
62+
### 3. Subprotocol-based handshake succeeds
63+
64+
```bash
65+
python3 -c "
66+
import asyncio, json, websockets
67+
async def main():
68+
async with websockets.connect(
69+
'ws://localhost:8000/ws/notification-updates/',
70+
subprotocols=['opencontracts.jwt.v1', '$TOKEN']
71+
) as ws:
72+
msg = json.loads(await asyncio.wait_for(ws.recv(), 5))
73+
print('Frame 1:', msg)
74+
msg = json.loads(await asyncio.wait_for(ws.recv(), 5))
75+
print('Frame 2:', msg)
76+
asyncio.run(main())
77+
"
78+
```
79+
80+
**Expected:**
81+
- Frame 1: `{"type":"AUTH_OK", "user_id":..., "anonymous":false, "refreshed":false, ...}`
82+
- Frame 2: `{"type":"CONNECTED", ...}`
83+
84+
### 4. In-band refresh (no reconnect)
85+
86+
```bash
87+
python3 -c "
88+
import asyncio, json, websockets
89+
async def main():
90+
async with websockets.connect(
91+
'ws://localhost:8000/ws/notification-updates/',
92+
subprotocols=['opencontracts.jwt.v1', '$TOKEN']
93+
) as ws:
94+
await asyncio.wait_for(ws.recv(), 5) # AUTH_OK
95+
await asyncio.wait_for(ws.recv(), 5) # CONNECTED
96+
await ws.send(json.dumps({'type':'AUTH','token':'$TOKEN'}))
97+
msg = json.loads(await asyncio.wait_for(ws.recv(), 5))
98+
print('Refresh result:', msg)
99+
asyncio.run(main())
100+
"
101+
```
102+
103+
**Expected:** `{"type":"AUTH_OK","refreshed":true,...}`. No reconnect occurred.
104+
105+
### 5. User-pk swap is refused
106+
107+
```bash
108+
OTHER_TOKEN=$(docker compose -f local.yml exec django python manage.py shell -c "
109+
from django.contrib.auth import get_user_model
110+
from graphql_jwt.shortcuts import get_token
111+
User = get_user_model()
112+
u, _ = User.objects.get_or_create(username='wsswaptest', defaults={'is_active': True})
113+
print(get_token(u))
114+
" | tr -d '\r' | tail -1)
115+
116+
python3 -c "
117+
import asyncio, json, websockets
118+
async def main():
119+
try:
120+
async with websockets.connect(
121+
'ws://localhost:8000/ws/notification-updates/',
122+
subprotocols=['opencontracts.jwt.v1', '$TOKEN']
123+
) as ws:
124+
await asyncio.wait_for(ws.recv(), 5) # AUTH_OK
125+
await asyncio.wait_for(ws.recv(), 5) # CONNECTED
126+
await ws.send(json.dumps({'type':'AUTH','token':'$OTHER_TOKEN'}))
127+
msg = await asyncio.wait_for(ws.recv(), 5)
128+
print('Swap response:', msg)
129+
await asyncio.wait_for(ws.recv(), 5)
130+
except websockets.ConnectionClosed as e:
131+
print(f'OK — server closed (code {e.code}, reason: {e.reason!r})')
132+
asyncio.run(main())
133+
"
134+
```
135+
136+
**Expected:** Server emits `{"type":"AUTH_FAILED","reason":"USER_MISMATCH"}` then closes 4002.
137+
138+
### 6. Browser DevTools sanity
139+
140+
1. `cd frontend && yarn start` and log in.
141+
2. Open Chrome DevTools → Network → WS filter.
142+
3. Inspect the `notification-updates/` WebSocket request:
143+
- **Request URL:** must NOT contain `token=` query parameter.
144+
- **Request Headers:** must show `Sec-WebSocket-Protocol: opencontracts.jwt.v1, <jwt>`.
145+
- **Response Headers:** must show `Sec-WebSocket-Protocol: opencontracts.jwt.v1`.
146+
4. In the WS Messages tab, confirm the FIRST frame is `AUTH_OK`.
147+
5. (Optional) trigger an Auth0 silent renewal (or wait for one). Confirm the WS connection is NOT torn down — only a single `{"type":"AUTH","token":"..."}` frame should appear in the message list, followed by `AUTH_OK refreshed:true`.
148+
149+
## Cleanup
150+
151+
None required for steps 1-4. Step 5 creates a `wsswaptest` user; remove it if desired:
152+
153+
```bash
154+
docker compose -f local.yml exec django python manage.py shell -c "
155+
from django.contrib.auth import get_user_model
156+
get_user_model().objects.filter(username='wsswaptest').delete()
157+
"
158+
```

0 commit comments

Comments
 (0)