@@ -63,6 +63,19 @@ def _validate_api_token(raw_token: str) -> str | None:
6363 return None
6464 if len (raw_token ) < 12 or len (raw_token ) > 100 :
6565 return None
66+ match = _find_matching_token (raw_token )
67+ if match is None :
68+ return None
69+ row , _ = match
70+ scopes = [s .strip () for s in (row .scopes or "" ).split ("," ) if s .strip ()]
71+ if "notifications:read" not in scopes :
72+ return None
73+ return row .owner
74+
75+
76+ def _find_matching_token (raw_token : str ) -> tuple | None :
77+ """Find the ApiToken row matching *raw_token* among active rows with the
78+ same 8-char prefix. Returns ``(row, raw_token)`` on match or ``None``."""
6679 prefix = raw_token [:8 ]
6780 try :
6881 with get_db_session () as db :
@@ -73,35 +86,26 @@ def _validate_api_token(raw_token: str) -> str | None:
7386 )
7487 for row in rows :
7588 if bcrypt .checkpw (raw_token .encode (), row .token_hash .encode ()):
76- scopes = [s .strip () for s in (row .scopes or "" ).split ("," ) if s .strip ()]
77- if "notifications:read" not in scopes :
78- return None
79- return row .owner
89+ return row , raw_token
8090 except Exception :
81- logger .warning ("API token validation failed" , exc_info = True )
91+ logger .warning ("API token lookup failed" , exc_info = True )
8292 return None
8393
8494
8595def _revalidate_api_token (raw_token : str , expected_owner : str ) -> bool :
86- """Re-validate an API token is still active and maps to *expected_owner*
87- and still has the ``notifications:read`` scope."""
88- prefix = raw_token [:8 ]
89- try :
90- with get_db_session () as db :
91- row = (
92- db .query (ApiToken )
93- .filter (
94- ApiToken .token_prefix == prefix ,
95- ApiToken .is_active == True ,
96- )
97- .first ()
98- )
99- if row and bcrypt .checkpw (raw_token .encode (), row .token_hash .encode ()):
100- scopes = [s .strip () for s in (row .scopes or "" ).split ("," ) if s .strip ()]
101- return row .owner == expected_owner and "notifications:read" in scopes
102- except Exception :
103- logger .warning ("API token re-validation failed" , exc_info = True )
104- return False
96+ """Re-validate an API token is still active, maps to *expected_owner*,
97+ and still has the ``notifications:read`` scope.
98+
99+ Iterates *all* prefix-matched rows (same as ``_validate_api_token``) so
100+ that a valid token whose 8-char prefix collides with another active token
101+ is not wrongly rejected on the first per-delivery check.
102+ """
103+ match = _find_matching_token (raw_token )
104+ if match is None :
105+ return False
106+ row , _ = match
107+ scopes = [s .strip () for s in (row .scopes or "" ).split ("," ) if s .strip ()]
108+ return row .owner == expected_owner and "notifications:read" in scopes
105109
106110
107111def setup_ws_routes ():
@@ -158,13 +162,14 @@ async def ws_notifications(websocket: WebSocket):
158162 owner = auth_mgr .get_username_for_token (session_id )
159163 used_credential = session_id
160164
161- # Fallback: if auth is disabled, allow anonymous
162- if not owner and not (auth_mgr and auth_mgr .is_configured ):
163- owner = _ANONYMOUS_OWNER
164-
165- if owner is None :
166- await websocket .close (code = 4001 )
167- return
165+ # Fallback: if auth is explicitly disabled at the app level, allow anonymous
166+ if not owner :
167+ _auth_disabled = not getattr (websocket .app .state , "auth_enabled" , True )
168+ if _auth_disabled :
169+ owner = _ANONYMOUS_OWNER
170+ else :
171+ await websocket .close (code = 4001 )
172+ return
168173
169174 # ── Subscribe and stream ─────────────────────────────────────────
170175 q = _subscribe (owner )
@@ -188,7 +193,9 @@ async def ws_notifications(websocket: WebSocket):
188193 except WebSocketDisconnect :
189194 raise
190195 except Exception :
191- pass
196+ # Any send failure means the client is gone; exit the loop
197+ # so the finally block removes the subscriber queue.
198+ return
192199 except WebSocketDisconnect :
193200 pass
194201 finally :
0 commit comments