-
-
Notifications
You must be signed in to change notification settings - Fork 149
WebSocket auth handshake: tokens off URLs, in-band refresh #1502
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
JSv4
wants to merge
17
commits into
main
Choose a base branch
from
feature/websocket-auth-handshake
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 15 commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
ad8fb88
Rewrite WS JWTAuthMiddleware to read token from Sec-WebSocket-Protoco…
JSv4 48a0244
Add AuthHandshakeMixin for in-band WS token refresh + security guards
JSv4 850507b
Wire AuthHandshakeMixin into UnifiedAgentConsumer; refactor resource …
JSv4 b72f2f2
Wire AuthHandshakeMixin into ThreadUpdatesConsumer
JSv4 d40e46d
Wire AuthHandshakeMixin into NotificationUpdatesConsumer
JSv4 b7b326b
Add frontend websocketAuth helpers (subprotocol + AUTH frame builders)
JSv4 603d3f1
Add useWebSocketAuth shared hook (subprotocol + in-band refresh)
JSv4 32a4799
Strip token from WS URL builders; delete deprecated DocumentQuery/Cor…
JSv4 ca9ba15
Refactor useNotificationWebSocket to compose useWebSocketAuth
JSv4 368238e
Refactor useAgentChat to compose useWebSocketAuth (no token in URL)
JSv4 08bb116
Migrate CorpusChat and document utils to tokenless WS URLs
JSv4 8012cb3
Add WS auth handshake manual test script + CHANGELOG entry
JSv4 7c0a1c6
Merge remote-tracking branch 'origin/main' into feature/websocket-aut…
JSv4 1d0a2f4
Ratchet any-baseline after WS auth refactor (460→454)
JSv4 3886d9f
Address PR #1502 review: migrate CorpusChat/ChatTray, harden refresh
JSv4 dbfbc95
Merge remote-tracking branch 'origin/main' into feature/websocket-aut…
JSv4 7a5178d
WS auth: address codecov gaps + silence React 18 act warnings
JSv4 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,253 @@ | ||
| """ | ||
| AuthHandshakeMixin — adds in-band token-refresh and re-validation behavior to | ||
| any AsyncWebsocketConsumer. | ||
|
|
||
| Wire protocol (frames in addition to whatever the consumer already speaks): | ||
|
|
||
| Client -> Server: | ||
| {"type": "AUTH", "token": "<jwt>"} | ||
|
|
||
| Server -> Client: | ||
| {"type": "AUTH_OK", "user_id": int|null, "username": str|null, | ||
| "anonymous": bool, "refreshed": bool} | ||
| {"type": "AUTH_FAILED", "reason": | ||
| "EXPIRED" | "INVALID" | "USER_MISMATCH" | "PERMISSION_REVOKED"} | ||
| {"type": "AUTH_REFRESH_REQUIRED", "grace_seconds": float} | ||
|
|
||
| Security guarantees enforced by handle_auth_message(): | ||
| 1. A live socket bound to user A cannot be re-bound to user B (USER_MISMATCH). | ||
| 2. If the user has lost access to a bound resource since connect, the next | ||
| AUTH frame closes 4003 (PERMISSION_REVOKED). | ||
| 3. An expired/invalid AUTH frame closes the socket (4001/4002) and never | ||
| leaves the consumer in an inconsistent state. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import asyncio | ||
| import json | ||
| import logging | ||
| import time | ||
| from typing import Any | ||
|
|
||
| from channels.db import database_sync_to_async | ||
| from django.contrib.auth.models import AnonymousUser | ||
| from graphql_jwt.exceptions import JSONWebTokenError, JSONWebTokenExpired | ||
|
|
||
| from config.jwt_utils import get_user_from_jwt_token | ||
| from config.websocket.middleware import ( | ||
| WS_CLOSE_TOKEN_EXPIRED, | ||
| WS_CLOSE_TOKEN_INVALID, | ||
| ) | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| # Permission-denied close code (consistent with existing consumer usage). | ||
| WS_CLOSE_PERMISSION_DENIED = 4003 | ||
|
|
||
| # Minimum interval between accepted AUTH frames on a single connection. | ||
| # Auth0 silent renewal happens on the order of every 50 minutes, so a 1-second | ||
| # floor cannot interfere with legitimate refreshes but stops a malicious client | ||
| # from spamming AUTH frames to burn DB queries (issue raised in PR #1502 review). | ||
| _MIN_AUTH_FRAME_INTERVAL_SEC = 1.0 | ||
|
|
||
|
|
||
| @database_sync_to_async | ||
| def _get_user_from_token(token: str): | ||
| return get_user_from_jwt_token(token) | ||
|
|
||
|
|
||
| class AuthHandshakeMixin: | ||
| """ | ||
| Mix this into an AsyncWebsocketConsumer to opt into in-band auth refresh. | ||
|
|
||
| Consumers using this mixin should: | ||
| 1. Replace ``await self.accept()`` with ``await self.accept_with_auth()``. | ||
| 2. In ``receive()``, dispatch frames whose top-level "type" == "AUTH" to | ||
| ``await self.handle_auth_message(payload)`` BEFORE any other handling. | ||
| 3. Optionally override ``_validate_resource_permissions(user)`` to re-run | ||
| resource-level access checks on refresh; default is permissive. | ||
| 4. Optionally call ``await self.request_token_refresh()`` from streaming | ||
| code that catches a JSONWebTokenExpired mid-flight. | ||
| """ | ||
|
|
||
| # Populated by accept_with_auth() and updated by handle_auth_message(). | ||
| _refresh_grace_task: asyncio.Task | None = None | ||
| _initial_auth_sent: bool = False | ||
| # Monotonic timestamp of the last AUTH frame we accepted; used to throttle | ||
| # spam at the per-connection level before any DB work runs. | ||
| _last_auth_frame_at: float = 0.0 | ||
|
|
||
| @property | ||
| def current_user(self): | ||
| return self.scope.get("user") # type: ignore[attr-defined] | ||
|
|
||
| # ------------------------------------------------------------------ # | ||
| # Connection accept | ||
| # ------------------------------------------------------------------ # | ||
|
|
||
| async def accept_with_auth(self) -> None: | ||
| """Accept the connection echoing the negotiated subprotocol.""" | ||
| subprotocol = self.scope.get("accepted_subprotocol") # type: ignore[attr-defined] | ||
| await self.accept(subprotocol=subprotocol) # type: ignore[attr-defined] | ||
| await self._send_initial_auth_ok() | ||
|
|
||
| async def _send_initial_auth_ok(self) -> None: | ||
| if self._initial_auth_sent: | ||
| return | ||
| user = self.current_user | ||
| is_anon = ( | ||
| isinstance(user, AnonymousUser) | ||
| or user is None | ||
| or not getattr(user, "is_authenticated", False) | ||
| ) | ||
| await self.send( # type: ignore[attr-defined] | ||
| text_data=json.dumps( | ||
| { | ||
| "type": "AUTH_OK", | ||
| "user_id": None if is_anon else user.pk, | ||
| "username": None if is_anon else user.username, | ||
| "anonymous": is_anon, | ||
| "refreshed": False, | ||
| } | ||
| ) | ||
| ) | ||
| self._initial_auth_sent = True | ||
|
|
||
| # ------------------------------------------------------------------ # | ||
| # Refresh: client-driven | ||
| # ------------------------------------------------------------------ # | ||
|
|
||
| async def handle_auth_message(self, payload: dict[str, Any]) -> None: | ||
| """ | ||
| Process a ``{"type":"AUTH","token":...}`` frame from the client. | ||
|
|
||
| Validates the token, refuses user-pk swap, re-validates resource | ||
| permissions, swaps scope["user"] on success, and cancels any pending | ||
| server-nudge grace timer. | ||
|
|
||
| Enforces a per-connection cooldown so a malicious client cannot spam | ||
| AUTH frames to burn DB queries on token validation + permission checks. | ||
| Frames arriving inside the cooldown window are silently dropped without | ||
| touching the database. | ||
| """ | ||
| now = time.monotonic() | ||
| if now - self._last_auth_frame_at < _MIN_AUTH_FRAME_INTERVAL_SEC: | ||
| logger.debug("Dropping AUTH frame: per-connection cooldown active") | ||
| return | ||
| self._last_auth_frame_at = now | ||
|
|
||
| token = payload.get("token") | ||
| if not token or not isinstance(token, str): | ||
| await self._fail_auth("INVALID", WS_CLOSE_TOKEN_INVALID) | ||
| return | ||
|
|
||
| try: | ||
| new_user = await _get_user_from_token(token) | ||
| except JSONWebTokenExpired: | ||
| await self._fail_auth("EXPIRED", WS_CLOSE_TOKEN_EXPIRED) | ||
| return | ||
| except JSONWebTokenError: | ||
| await self._fail_auth("INVALID", WS_CLOSE_TOKEN_INVALID) | ||
| return | ||
| except Exception: | ||
| logger.exception("Unexpected error validating refresh token") | ||
| await self._fail_auth("INVALID", WS_CLOSE_TOKEN_INVALID) | ||
| return | ||
|
|
||
| # User-pk swap is forbidden — defense in depth. | ||
| current = self.current_user | ||
| current_is_anon = ( | ||
| isinstance(current, AnonymousUser) | ||
| or current is None | ||
| or not getattr(current, "is_authenticated", False) | ||
| ) | ||
| if not current_is_anon and current.pk != new_user.pk: | ||
| await self._fail_auth("USER_MISMATCH", WS_CLOSE_TOKEN_INVALID) | ||
| return | ||
|
|
||
| # Re-validate resource permissions. | ||
| if not await self._validate_resource_permissions(new_user): | ||
| await self._fail_auth("PERMISSION_REVOKED", WS_CLOSE_PERMISSION_DENIED) | ||
| return | ||
|
|
||
| # Success — swap, ack, cancel any pending grace timer. | ||
| self.scope["user"] = new_user # type: ignore[attr-defined] | ||
| self._cancel_refresh_grace_timer() | ||
| await self.send( # type: ignore[attr-defined] | ||
| text_data=json.dumps( | ||
| { | ||
| "type": "AUTH_OK", | ||
| "user_id": new_user.pk, | ||
| "username": new_user.username, | ||
| "anonymous": False, | ||
| "refreshed": True, | ||
| } | ||
| ) | ||
| ) | ||
|
|
||
| async def _validate_resource_permissions(self, user) -> bool: | ||
| """ | ||
| Override in consumers that have resource-level access requirements | ||
| (e.g., document/corpus/conversation membership). Default permits. | ||
| """ | ||
| return True | ||
|
|
||
| async def _fail_auth(self, reason: str, close_code: int) -> None: | ||
| try: | ||
| await self.send( # type: ignore[attr-defined] | ||
| text_data=json.dumps( | ||
| { | ||
| "type": "AUTH_FAILED", | ||
| "reason": reason, | ||
| } | ||
| ) | ||
| ) | ||
| except Exception: | ||
|
|
||
| pass | ||
| await self.close(code=close_code) # type: ignore[attr-defined] | ||
|
|
||
| # ------------------------------------------------------------------ # | ||
| # Refresh: server-nudged | ||
| # ------------------------------------------------------------------ # | ||
|
|
||
| async def request_token_refresh(self, grace_seconds: float = 30.0) -> None: | ||
| """ | ||
| Ask the client to send a fresh token. If the client doesn't respond | ||
| with a successful AUTH frame within ``grace_seconds``, close 4001. | ||
| """ | ||
| await self.send( # type: ignore[attr-defined] | ||
| text_data=json.dumps( | ||
| { | ||
| "type": "AUTH_REFRESH_REQUIRED", | ||
| "grace_seconds": grace_seconds, | ||
| } | ||
| ) | ||
| ) | ||
| self._cancel_refresh_grace_timer() | ||
| self._refresh_grace_task = asyncio.create_task( | ||
| self._refresh_grace_timeout(grace_seconds) | ||
| ) | ||
|
|
||
| async def _refresh_grace_timeout(self, grace_seconds: float) -> None: | ||
| try: | ||
| await asyncio.sleep(grace_seconds) | ||
| except asyncio.CancelledError: | ||
| return | ||
| if getattr(self, "_is_connected", True): | ||
| logger.info("Refresh grace timer expired; closing 4001") | ||
| await self.close(code=WS_CLOSE_TOKEN_EXPIRED) # type: ignore[attr-defined] | ||
|
|
||
| def _cancel_refresh_grace_timer(self) -> None: | ||
| task = self._refresh_grace_task | ||
| if task is not None and not task.done(): | ||
| task.cancel() | ||
| self._refresh_grace_task = None | ||
|
|
||
| # ------------------------------------------------------------------ # | ||
| # Cleanup | ||
| # ------------------------------------------------------------------ # | ||
|
|
||
| async def cleanup_auth_handshake(self) -> None: | ||
| """Consumers should call this from their ``disconnect()``.""" | ||
| self._cancel_refresh_grace_timer() | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.