|
| 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