phoenix-channels-python-client 0.3.0__tar.gz → 0.3.1__tar.gz

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.
Files changed (41) hide show
  1. {phoenix_channels_python_client-0.3.0/phoenix_channels_python_client.egg-info → phoenix_channels_python_client-0.3.1}/PKG-INFO +23 -3
  2. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/README.md +22 -2
  3. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/__init__.py +1 -1
  4. phoenix_channels_python_client-0.3.1/phoenix_channels_python_client/authentication.py +43 -0
  5. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/client.py +67 -35
  6. phoenix_channels_python_client-0.3.1/phoenix_channels_python_client/diagnostics.py +131 -0
  7. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/protocol_handler.py +6 -1
  8. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/reconnect_controller.py +15 -12
  9. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/supervisor.py +9 -2
  10. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1/phoenix_channels_python_client.egg-info}/PKG-INFO +23 -3
  11. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client.egg-info/SOURCES.txt +3 -0
  12. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/pyproject.toml +1 -1
  13. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/tests/test_channel_recovery.py +2 -2
  14. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/tests/test_connection.py +1 -2
  15. phoenix_channels_python_client-0.3.1/tests/test_security.py +316 -0
  16. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/LICENSE +0 -0
  17. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/MANIFEST.in +0 -0
  18. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/client_state_machine.py +0 -0
  19. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/client_types.py +0 -0
  20. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/exceptions.py +0 -0
  21. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/phx_messages.py +0 -0
  22. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/shutdown_signals.py +0 -0
  23. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/topic_runtime.py +0 -0
  24. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/topic_subscription.py +0 -0
  25. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client/utils.py +0 -0
  26. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client.egg-info/dependency_links.txt +0 -0
  27. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client.egg-info/requires.txt +0 -0
  28. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/phoenix_channels_python_client.egg-info/top_level.txt +0 -0
  29. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/setup.cfg +0 -0
  30. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/tests/test_cancel_and_wait.py +0 -0
  31. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/tests/test_client_state.py +0 -0
  32. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/tests/test_heartbeat.py +0 -0
  33. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/tests/test_protocol_handler.py +0 -0
  34. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/tests/test_reconnect.py +0 -0
  35. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/tests/test_reconnect_policy_invariants.py +0 -0
  36. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/tests/test_reconnect_stress.py +0 -0
  37. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/tests/test_run_forever_signals.py +0 -0
  38. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/tests/test_shutdown_races.py +0 -0
  39. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/tests/test_subscriptions.py +0 -0
  40. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/tests/test_supervisor_internals.py +0 -0
  41. {phoenix_channels_python_client-0.3.0 → phoenix_channels_python_client-0.3.1}/tests/test_topic_runtime_internals.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: phoenix-channels-python-client
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: A Python client library for connecting to Phoenix Channels
5
5
  Author: Phoenix Channels Python Client
6
6
  License-Expression: MIT
@@ -365,7 +365,7 @@ setup_logging(logging.DEBUG) # loggers live under "phoenix_channels_python_clie
365
365
 
366
366
  ## Security Considerations
367
367
 
368
- **API Key in URL**: This client passes the API key as a URL query parameter when connecting to the WebSocket (e.g., `wss://server.com/socket?api_key=xxx`). Even over WSS (encrypted in transit), the API key may be logged by:
368
+ **API Key in URL**: Without an authentication header, this client passes the API key as a URL query parameter when connecting to the WebSocket (e.g., `wss://server.com/socket?api_key=xxx`). Even over WSS (encrypted in transit), the API key may be logged by:
369
369
 
370
370
  - Server access logs
371
371
  - Reverse proxies and load balancers
@@ -376,8 +376,28 @@ setup_logging(logging.DEBUG) # loggers live under "phoenix_channels_python_clie
376
376
  - Use short-lived, rotatable tokens rather than long-lived API keys
377
377
  - Consider this limitation when evaluating this client for sensitive environments
378
378
 
379
- **Note:** The official Phoenix JS client (v1.8+) supports header-based authentication via the `authToken` option, which avoids URL logging. This client always sends `api_key` in the URL (logged URLs show `api_key=***`). Pass `additional_headers={"x-api-key": key}` to also send it as a handshake header on every (re)connect, e.g. for a proxy that reads it from there.
379
+ Pass `additional_headers={"x-api-key": key}` or `{"x-auth-token": jwt}` to use header-only authentication on every connection and reconnect.
380
380
 
381
381
  ---
382
382
 
383
383
  **Need help?** Open an issue on GitHub or check the [Phoenix Channels documentation](https://hexdocs.pm/phoenix/channels.html) for more information about the Phoenix Channels protocol.
384
+
385
+ ### Authentication and diagnostic safety
386
+
387
+ A nonempty `x-api-key` or `x-auth-token` handshake header selects header-only authentication on
388
+ every connection and reconnect. Embedded API-key parameters are removed; empty
389
+ or conflicting authentication headers and simultaneous token-query authentication
390
+ are rejected before connecting. A JWT header overrides an embedded token and takes
391
+ precedence over an API-key header, matching the platform. Without an authentication header, legacy query authentication
392
+ is preserved and remains visible to servers and proxies. No fallback is attempted.
393
+
394
+ `client.logger` forwards sanitized connection records without raw arguments,
395
+ exception objects, or socket metadata. `client.redact_diagnostic(text)` applies
396
+ the credential policy to transport errors a caller needs to retain. Caller-visible
397
+ errors may still contain personal data and should not be logged outside this policy.
398
+
399
+ Connection diagnostics omit dynamic strings and transport payloads, including URLs,
400
+ headers, frame bodies, user/topic identities, and untrusted server error details.
401
+ Logs retain static lifecycle descriptions, native connection states,
402
+ and exception types. Application data and caller-visible errors are not changed;
403
+ applications remain responsible for logs outside this connection-owned boundary.
@@ -334,7 +334,7 @@ setup_logging(logging.DEBUG) # loggers live under "phoenix_channels_python_clie
334
334
 
335
335
  ## Security Considerations
336
336
 
337
- **API Key in URL**: This client passes the API key as a URL query parameter when connecting to the WebSocket (e.g., `wss://server.com/socket?api_key=xxx`). Even over WSS (encrypted in transit), the API key may be logged by:
337
+ **API Key in URL**: Without an authentication header, this client passes the API key as a URL query parameter when connecting to the WebSocket (e.g., `wss://server.com/socket?api_key=xxx`). Even over WSS (encrypted in transit), the API key may be logged by:
338
338
 
339
339
  - Server access logs
340
340
  - Reverse proxies and load balancers
@@ -345,8 +345,28 @@ setup_logging(logging.DEBUG) # loggers live under "phoenix_channels_python_clie
345
345
  - Use short-lived, rotatable tokens rather than long-lived API keys
346
346
  - Consider this limitation when evaluating this client for sensitive environments
347
347
 
348
- **Note:** The official Phoenix JS client (v1.8+) supports header-based authentication via the `authToken` option, which avoids URL logging. This client always sends `api_key` in the URL (logged URLs show `api_key=***`). Pass `additional_headers={"x-api-key": key}` to also send it as a handshake header on every (re)connect, e.g. for a proxy that reads it from there.
348
+ Pass `additional_headers={"x-api-key": key}` or `{"x-auth-token": jwt}` to use header-only authentication on every connection and reconnect.
349
349
 
350
350
  ---
351
351
 
352
352
  **Need help?** Open an issue on GitHub or check the [Phoenix Channels documentation](https://hexdocs.pm/phoenix/channels.html) for more information about the Phoenix Channels protocol.
353
+
354
+ ### Authentication and diagnostic safety
355
+
356
+ A nonempty `x-api-key` or `x-auth-token` handshake header selects header-only authentication on
357
+ every connection and reconnect. Embedded API-key parameters are removed; empty
358
+ or conflicting authentication headers and simultaneous token-query authentication
359
+ are rejected before connecting. A JWT header overrides an embedded token and takes
360
+ precedence over an API-key header, matching the platform. Without an authentication header, legacy query authentication
361
+ is preserved and remains visible to servers and proxies. No fallback is attempted.
362
+
363
+ `client.logger` forwards sanitized connection records without raw arguments,
364
+ exception objects, or socket metadata. `client.redact_diagnostic(text)` applies
365
+ the credential policy to transport errors a caller needs to retain. Caller-visible
366
+ errors may still contain personal data and should not be logged outside this policy.
367
+
368
+ Connection diagnostics omit dynamic strings and transport payloads, including URLs,
369
+ headers, frame bodies, user/topic identities, and untrusted server error details.
370
+ Logs retain static lifecycle descriptions, native connection states,
371
+ and exception types. Application data and caller-visible errors are not changed;
372
+ applications remain responsible for logs outside this connection-owned boundary.
@@ -19,7 +19,7 @@ from phoenix_channels_python_client.protocol_handler import (
19
19
  )
20
20
  from phoenix_channels_python_client.utils import setup_logging
21
21
 
22
- __version__ = "0.3.0"
22
+ __version__ = "0.3.1"
23
23
  __author__ = "Phoenix Channels Python Client"
24
24
 
25
25
  __all__ = [
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import StrEnum
4
+
5
+ API_KEY_PARAMETER = "api_key"
6
+ TOKEN_PARAMETER = "token" # noqa: S105 -- protocol parameter name
7
+
8
+
9
+ class AuthenticationHeader(StrEnum):
10
+ API_KEY = "x-api-key"
11
+ TOKEN = "x-auth-token" # noqa: S105 -- protocol header name
12
+
13
+
14
+ def normalize_headers(headers: dict[str, str] | None) -> dict[str, str]:
15
+ normalized = dict(headers or {})
16
+ for header in AuthenticationHeader:
17
+ credentials = [
18
+ value for name, value in normalized.items() if name.casefold() == header
19
+ ]
20
+ if not credentials:
21
+ continue
22
+ if (
23
+ any(not value.strip() for value in credentials)
24
+ or len(set(credentials)) != 1
25
+ ):
26
+ raise ValueError(f"{header} headers must contain one nonempty credential")
27
+ normalized = {
28
+ name: value
29
+ for name, value in normalized.items()
30
+ if name.casefold() != header
31
+ }
32
+ normalized[header] = credentials[0]
33
+ return normalized
34
+
35
+
36
+ def select_authentication_header(
37
+ headers: dict[str, str],
38
+ ) -> AuthenticationHeader | None:
39
+ # The platform gives a supplied JWT precedence over an API key.
40
+ for header in (AuthenticationHeader.TOKEN, AuthenticationHeader.API_KEY):
41
+ if header in headers:
42
+ return header
43
+ return None
@@ -5,13 +5,24 @@ import logging
5
5
  from collections import deque
6
6
  from collections.abc import Awaitable, Callable
7
7
  from types import TracebackType
8
- from typing import Self
8
+ from typing import Self, cast
9
9
  from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
10
10
 
11
11
  from websockets import ClientConnection
12
12
 
13
+ from phoenix_channels_python_client.authentication import (
14
+ API_KEY_PARAMETER,
15
+ TOKEN_PARAMETER,
16
+ AuthenticationHeader,
17
+ normalize_headers,
18
+ select_authentication_header,
19
+ )
13
20
  from phoenix_channels_python_client.client_state_machine import transition_client_state
14
21
  from phoenix_channels_python_client.client_types import ClientState, ReconnectPolicy
22
+ from phoenix_channels_python_client.diagnostics import (
23
+ ConnectionLogger,
24
+ diagnostic_secrets,
25
+ )
15
26
  from phoenix_channels_python_client.exceptions import PHXConnectionError
16
27
  from phoenix_channels_python_client.protocol_handler import (
17
28
  DEFAULT_PROTOCOL_VERSION,
@@ -36,39 +47,56 @@ TopicLostCallback = Callable[[str, Exception], Awaitable[None]]
36
47
 
37
48
 
38
49
  def _build_channel_socket_urls(
39
- websocket_url: str, api_key: str, vsn: str
50
+ websocket_url: str,
51
+ api_key: str,
52
+ vsn: str,
53
+ *,
54
+ header_auth: AuthenticationHeader | None = None,
40
55
  ) -> tuple[str, str]:
41
56
  split_url = urlsplit(websocket_url)
42
57
  query_params = parse_qsl(split_url.query, keep_blank_values=True)
58
+ if header_auth is AuthenticationHeader.API_KEY and any(
59
+ key.casefold() == TOKEN_PARAMETER and value for key, value in query_params
60
+ ):
61
+ raise ValueError("token query authentication cannot be combined with x-api-key")
43
62
  filtered = [
44
- (key, value) for key, value in query_params if key not in {"api_key", "vsn"}
45
- ]
46
- with_auth = [*filtered, ("api_key", api_key), ("vsn", vsn)]
47
-
48
- connect_url = urlunsplit(
49
- (
50
- split_url.scheme,
51
- split_url.netloc,
52
- split_url.path,
53
- urlencode(with_auth),
54
- split_url.fragment,
63
+ (key, value)
64
+ for key, value in query_params
65
+ if key.casefold()
66
+ not in (
67
+ {API_KEY_PARAMETER, "vsn", TOKEN_PARAMETER}
68
+ if header_auth
69
+ else {API_KEY_PARAMETER, "vsn"}
55
70
  )
56
- )
57
- redacted_url = urlunsplit(
58
- (
59
- split_url.scheme,
60
- split_url.netloc,
61
- split_url.path,
62
- urlencode(
63
- [
64
- (key, "***" if key == "api_key" else value)
65
- for key, value in with_auth
66
- ]
67
- ),
68
- split_url.fragment,
71
+ ]
72
+ with_auth = [
73
+ *filtered,
74
+ *([] if header_auth else [(API_KEY_PARAMETER, api_key)]),
75
+ ("vsn", vsn),
76
+ ]
77
+
78
+ def build(params: list[tuple[str, str]]) -> str:
79
+ return urlunsplit(
80
+ (
81
+ split_url.scheme,
82
+ split_url.netloc,
83
+ split_url.path,
84
+ urlencode(params),
85
+ split_url.fragment,
86
+ )
69
87
  )
88
+
89
+ return build(with_auth), build(
90
+ [
91
+ (
92
+ key,
93
+ "***"
94
+ if key.casefold() in {API_KEY_PARAMETER, TOKEN_PARAMETER}
95
+ else value,
96
+ )
97
+ for key, value in with_auth
98
+ ]
70
99
  )
71
- return connect_url, redacted_url
72
100
 
73
101
 
74
102
  class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerMixin):
@@ -92,7 +120,10 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
92
120
  on_topic_lost: TopicLostCallback | None = None,
93
121
  additional_headers: dict[str, str] | None = None,
94
122
  ) -> None:
95
- self.logger = logger
123
+ self.additional_headers = normalize_headers(additional_headers)
124
+ self.logger = ConnectionLogger(
125
+ logger, diagnostic_secrets(websocket_url, api_key, self.additional_headers)
126
+ )
96
127
 
97
128
  if heartbeat_interval_s is not None and heartbeat_interval_s <= 0:
98
129
  raise ValueError("heartbeat_interval_s must be > 0 or None to disable")
@@ -106,21 +137,18 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
106
137
  if callback_drain_timeout_s <= 0:
107
138
  raise ValueError("callback_drain_timeout_s must be > 0")
108
139
 
109
- self._protocol_handler = PHXProtocolHandler(protocol_version)
140
+ self._protocol_handler = PHXProtocolHandler(
141
+ protocol_version, diagnostic_logger=self.logger
142
+ )
110
143
  connect_url, redacted_url = _build_channel_socket_urls(
111
144
  websocket_url=websocket_url,
112
145
  api_key=api_key,
113
146
  vsn=self._protocol_handler.protocol_version.vsn,
147
+ header_auth=select_authentication_header(self.additional_headers),
114
148
  )
115
149
  self.channel_socket_url = connect_url
116
150
  self.channel_socket_url_redacted = redacted_url
117
151
 
118
- # Extra WebSocket handshake headers, re-sent on every (re)connect. Lets a
119
- # caller send the API key as an ``x-api-key`` header instead of (or
120
- # alongside) the URL query, so a trusted proxy can inject/replace it
121
- # in-header and the credential stays out of URL/proxy logs.
122
- self.additional_headers = dict(additional_headers or {})
123
-
124
152
  self.auto_reconnect = auto_reconnect
125
153
  self.reconnect_policy = reconnect_policy or ReconnectPolicy()
126
154
  self.join_timeout_s = join_timeout_s
@@ -152,6 +180,10 @@ class PHXChannelsClient(SupervisorMixin, TopicRuntimeMixin, ReconnectControllerM
152
180
  self._lifecycle_tasks: set[asyncio.Task[None]] = set()
153
181
  self._forced_close_pending = False
154
182
 
183
+ def redact_diagnostic(self, text: str) -> str:
184
+ """Sanitize untrusted transport error text using this connection's policy."""
185
+ return cast(ConnectionLogger, self.logger).redact(text)
186
+
155
187
  @property
156
188
  def _active_shutdown(self) -> asyncio.Task[None] | None:
157
189
  task = self._shutdown_task
@@ -0,0 +1,131 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import re
6
+ from urllib.parse import parse_qsl, quote, quote_plus, urlsplit
7
+
8
+ from websockets.protocol import State
9
+
10
+ from phoenix_channels_python_client.authentication import (
11
+ API_KEY_PARAMETER,
12
+ TOKEN_PARAMETER,
13
+ AuthenticationHeader,
14
+ )
15
+
16
+ REDACTED = "[REDACTED]"
17
+ PERCENT_ESCAPE = re.compile(r"%[0-9a-fA-F]{2}")
18
+
19
+
20
+ AUTHENTICATION_NAMES = frozenset(
21
+ {
22
+ API_KEY_PARAMETER,
23
+ TOKEN_PARAMETER,
24
+ *AuthenticationHeader,
25
+ "x-token",
26
+ "authorization",
27
+ "cookie",
28
+ }
29
+ )
30
+ AUTHENTICATION_LABEL = re.compile(
31
+ r"(?i)((?:"
32
+ + "|".join(
33
+ re.escape(name) for name in sorted(AUTHENTICATION_NAMES, key=len, reverse=True)
34
+ )
35
+ + r")[\"\']?\s*[:=]\s*[\"\']?(?:(?:Bearer|Basic)\s+)?)([^\s&\"\']+)"
36
+ )
37
+
38
+
39
+ class ConnectionLogger(logging.Logger):
40
+ """Forward only sanitized records; raw transport objects never reach handlers."""
41
+
42
+ def __init__(self, target: logging.Logger, secrets: set[str]) -> None:
43
+ super().__init__(target.name)
44
+ self.target = target
45
+ self.parent = target
46
+ variants = sorted(
47
+ {
48
+ encoded
49
+ for secret in secrets
50
+ if secret
51
+ for encoded in (
52
+ secret,
53
+ quote(secret, safe=""),
54
+ quote(secret),
55
+ quote(secret, safe=";/?:@&=+$,#-_.!~*'()"),
56
+ quote_plus(secret, safe=""),
57
+ json.dumps(secret)[1:-1],
58
+ )
59
+ },
60
+ key=len,
61
+ reverse=True,
62
+ )
63
+
64
+ self.patterns = [
65
+ re.compile(
66
+ PERCENT_ESCAPE.sub(lambda match: f"(?i:{match[0]})", re.escape(secret))
67
+ )
68
+ for secret in variants
69
+ ]
70
+
71
+ def isEnabledFor(self, level: int) -> bool: # noqa: N802 -- logging API
72
+ # Connection loggers are intentionally absent from the global registry,
73
+ # so its cache invalidation cannot track their effective level.
74
+ return (
75
+ not self.disabled
76
+ and not self.target.disabled
77
+ and self.manager.disable < level
78
+ and level >= self.getEffectiveLevel()
79
+ )
80
+
81
+ def redact(self, text: str) -> str:
82
+ for pattern in self.patterns:
83
+ text = pattern.sub(REDACTED, text)
84
+ return AUTHENTICATION_LABEL.sub(lambda match: match[1] + REDACTED, text)
85
+
86
+ def handle(self, record: logging.LogRecord) -> None:
87
+ if self.target.disabled:
88
+ return
89
+ message = str(record.msg)
90
+ if (
91
+ record.msg == "= connection is %s"
92
+ and isinstance(record.args, tuple)
93
+ and len(record.args) == 1
94
+ and isinstance(record.args[0], str)
95
+ and record.args[0] in State.__members__
96
+ ):
97
+ message = record.msg % record.args
98
+ if record.exc_info and record.exc_info[0]:
99
+ message += " [" + record.exc_info[0].__name__ + "]"
100
+ safe_record = logging.LogRecord(
101
+ record.name,
102
+ record.levelno,
103
+ record.pathname,
104
+ record.lineno,
105
+ self.redact(message),
106
+ (),
107
+ None,
108
+ record.funcName,
109
+ )
110
+ filtered = self.target.filter(safe_record)
111
+ if filtered:
112
+ if isinstance(filtered, logging.LogRecord):
113
+ safe_record = filtered
114
+ super().handle(safe_record)
115
+
116
+
117
+ def diagnostic_secrets(url: str, api_key: str, headers: dict[str, str]) -> set[str]:
118
+ """Collect connection credentials without changing authentication bytes."""
119
+ return {
120
+ api_key,
121
+ *(
122
+ value
123
+ for name, value in parse_qsl(urlsplit(url).query)
124
+ if name.casefold() in AUTHENTICATION_NAMES
125
+ ),
126
+ *(
127
+ value
128
+ for name, value in headers.items()
129
+ if name.casefold() in AUTHENTICATION_NAMES
130
+ ),
131
+ }
@@ -9,6 +9,7 @@ from typing import Any, NamedTuple
9
9
 
10
10
  from websockets import ClientConnection
11
11
 
12
+ from phoenix_channels_python_client.diagnostics import ConnectionLogger
12
13
  from phoenix_channels_python_client.phx_messages import (
13
14
  PHOENIX_TOPIC,
14
15
  ChannelMessage,
@@ -110,6 +111,8 @@ class PHXProtocolHandler:
110
111
  self,
111
112
  protocol_version: PhoenixChannelsProtocolVersion
112
113
  | str = DEFAULT_PROTOCOL_VERSION,
114
+ *,
115
+ diagnostic_logger: logging.Logger | None = None,
113
116
  ) -> None:
114
117
  # Non-v2 values select v1 for compatibility with the original client.
115
118
  self.protocol_version = (
@@ -117,7 +120,9 @@ class PHXProtocolHandler:
117
120
  if protocol_version == PhoenixChannelsProtocolVersion.V2
118
121
  else PhoenixChannelsProtocolVersion.V1
119
122
  )
120
- self.logger = logger.getChild("ProtocolHandler")
123
+ self.logger = diagnostic_logger or ConnectionLogger(
124
+ logger.getChild("ProtocolHandler"), set()
125
+ )
121
126
  self.logger.debug(
122
127
  "Initialized PHXProtocolHandler for protocol version %s",
123
128
  self.protocol_version,
@@ -15,10 +15,13 @@ from phoenix_channels_python_client.client_types import (
15
15
  )
16
16
  from phoenix_channels_python_client.exceptions import PHXConnectionError
17
17
 
18
- logger = logging.getLogger(__name__)
19
-
20
18
 
21
19
  class ReconnectControllerMixin:
20
+ logger: logging.Logger
21
+
22
+ def redact_diagnostic(self, text: str) -> str:
23
+ raise NotImplementedError
24
+
22
25
  reconnect_policy: ReconnectPolicy
23
26
  _rapid_disconnects: deque[float]
24
27
 
@@ -34,7 +37,7 @@ class ReconnectControllerMixin:
34
37
  if connection_uptime_s < self.reconnect_policy.rapid_disconnect_uptime_s:
35
38
  self._rapid_disconnects.append(now)
36
39
  self._prune_rapid_disconnects(now)
37
- logger.debug(
40
+ self.logger.debug(
38
41
  "Recorded disconnect. uptime_s=%s rapid_count=%s rapid_window_s=%s",
39
42
  connection_uptime_s,
40
43
  len(self._rapid_disconnects),
@@ -50,7 +53,7 @@ class ReconnectControllerMixin:
50
53
  threshold = self.reconnect_policy.rapid_suppress_disconnect_count
51
54
  should_suppress = threshold > 0 and len(self._rapid_disconnects) >= threshold
52
55
  if should_suppress:
53
- logger.debug(
56
+ self.logger.debug(
54
57
  "Reconnect suppression threshold met. rapid_count=%s threshold=%s",
55
58
  len(self._rapid_disconnects),
56
59
  threshold,
@@ -115,7 +118,7 @@ class ReconnectControllerMixin:
115
118
  min_jittered = delay * low_ratio
116
119
  max_jittered = delay * high_ratio
117
120
  computed = self._random_between(min_jittered, max_jittered)
118
- logger.debug(
121
+ self.logger.debug(
119
122
  "Computed reconnect delay with hold-down jitter. attempt=%s "
120
123
  "rapid_count=%s base_delay_s=%s min_delay_s=%s computed_delay_s=%s",
121
124
  attempt,
@@ -134,7 +137,7 @@ class ReconnectControllerMixin:
134
137
  return computed
135
138
 
136
139
  computed = self._equal_jitter(delay)
137
- logger.debug(
140
+ self.logger.debug(
138
141
  "Computed reconnect delay with equal jitter. attempt=%s rapid_count=%s "
139
142
  "base_delay_s=%s min_delay_s=%s computed_delay_s=%s",
140
143
  attempt,
@@ -170,7 +173,7 @@ class ReconnectControllerMixin:
170
173
  ) -> ReconnectDecision:
171
174
  if close_code is None:
172
175
  decision = ReconnectDecision(should_reconnect=True)
173
- logger.debug(
176
+ self.logger.debug(
174
177
  "Disconnect classification result. close_code=%s close_reason=%s "
175
178
  "should_reconnect=%s",
176
179
  close_code,
@@ -189,7 +192,7 @@ class ReconnectControllerMixin:
189
192
  and not self.reconnect_policy.reconnect_on_normal_close
190
193
  ):
191
194
  decision = ReconnectDecision(should_reconnect=False)
192
- logger.debug(
195
+ self.logger.debug(
193
196
  "Disconnect classification result. close_code=%s close_reason=%s "
194
197
  "should_reconnect=%s",
195
198
  close_code,
@@ -215,7 +218,7 @@ class ReconnectControllerMixin:
215
218
  f"{CloseCode.POLICY_VIOLATION} ({reason})"
216
219
  ),
217
220
  )
218
- logger.debug(
221
+ self.logger.debug(
219
222
  "Disconnect classification result. close_code=%s close_reason=%s "
220
223
  "should_reconnect=%s terminal=%s",
221
224
  close_code,
@@ -237,7 +240,7 @@ class ReconnectControllerMixin:
237
240
  min_delay_s=self.reconnect_policy.service_restart_min_delay_s,
238
241
  max_delay_s=self.reconnect_policy.service_restart_max_delay_s,
239
242
  )
240
- logger.debug(
243
+ self.logger.debug(
241
244
  "Disconnect classification result. close_code=%s close_reason=%s "
242
245
  "should_reconnect=%s min_delay_s=%s max_delay_s=%s",
243
246
  close_code,
@@ -261,7 +264,7 @@ class ReconnectControllerMixin:
261
264
  min_delay_s=self.reconnect_policy.try_again_later_min_delay_s,
262
265
  max_delay_s=self.reconnect_policy.try_again_later_max_delay_s,
263
266
  )
264
- logger.debug(
267
+ self.logger.debug(
265
268
  "Disconnect classification result. close_code=%s close_reason=%s "
266
269
  "should_reconnect=%s min_delay_s=%s max_delay_s=%s",
267
270
  close_code,
@@ -280,7 +283,7 @@ class ReconnectControllerMixin:
280
283
  return decision
281
284
 
282
285
  decision = ReconnectDecision(should_reconnect=True)
283
- logger.debug(
286
+ self.logger.debug(
284
287
  "Disconnect classification result. close_code=%s close_reason=%s "
285
288
  "should_reconnect=%s",
286
289
  close_code,
@@ -52,6 +52,8 @@ class _CloseDetails(NamedTuple):
52
52
 
53
53
 
54
54
  class _SupervisorRuntimeDeps(Protocol):
55
+ def redact_diagnostic(self, text: str) -> str: ...
56
+
55
57
  def _generate_ref(self) -> str: ...
56
58
  async def _rejoin_topics(self, generation: int) -> None: ...
57
59
  def _fail_pending_joins(self, error: Exception) -> None: ...
@@ -243,14 +245,19 @@ class SupervisorMixin:
243
245
 
244
246
  async def _connect(self) -> ClientConnection:
245
247
  return await connect(
246
- self.channel_socket_url, additional_headers=self.additional_headers
248
+ self.channel_socket_url,
249
+ additional_headers=self.additional_headers,
250
+ logger=self.logger,
247
251
  )
248
252
 
249
253
  def _retry_after_connect_failure(self, exc: Exception) -> bool:
250
254
  if not self.auto_reconnect:
251
255
  self._settle_initial_connection(
252
256
  PHXConnectionError(
253
- f"Failed to connect to {self.channel_socket_url_redacted}: {exc}"
257
+ self._deps.redact_diagnostic(
258
+ "Failed to connect to "
259
+ f"{self.channel_socket_url_redacted}: {exc}"
260
+ )
254
261
  )
255
262
  )
256
263
  self.logger.error("Connection failed and auto_reconnect=False: %s", exc)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: phoenix-channels-python-client
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: A Python client library for connecting to Phoenix Channels
5
5
  Author: Phoenix Channels Python Client
6
6
  License-Expression: MIT
@@ -365,7 +365,7 @@ setup_logging(logging.DEBUG) # loggers live under "phoenix_channels_python_clie
365
365
 
366
366
  ## Security Considerations
367
367
 
368
- **API Key in URL**: This client passes the API key as a URL query parameter when connecting to the WebSocket (e.g., `wss://server.com/socket?api_key=xxx`). Even over WSS (encrypted in transit), the API key may be logged by:
368
+ **API Key in URL**: Without an authentication header, this client passes the API key as a URL query parameter when connecting to the WebSocket (e.g., `wss://server.com/socket?api_key=xxx`). Even over WSS (encrypted in transit), the API key may be logged by:
369
369
 
370
370
  - Server access logs
371
371
  - Reverse proxies and load balancers
@@ -376,8 +376,28 @@ setup_logging(logging.DEBUG) # loggers live under "phoenix_channels_python_clie
376
376
  - Use short-lived, rotatable tokens rather than long-lived API keys
377
377
  - Consider this limitation when evaluating this client for sensitive environments
378
378
 
379
- **Note:** The official Phoenix JS client (v1.8+) supports header-based authentication via the `authToken` option, which avoids URL logging. This client always sends `api_key` in the URL (logged URLs show `api_key=***`). Pass `additional_headers={"x-api-key": key}` to also send it as a handshake header on every (re)connect, e.g. for a proxy that reads it from there.
379
+ Pass `additional_headers={"x-api-key": key}` or `{"x-auth-token": jwt}` to use header-only authentication on every connection and reconnect.
380
380
 
381
381
  ---
382
382
 
383
383
  **Need help?** Open an issue on GitHub or check the [Phoenix Channels documentation](https://hexdocs.pm/phoenix/channels.html) for more information about the Phoenix Channels protocol.
384
+
385
+ ### Authentication and diagnostic safety
386
+
387
+ A nonempty `x-api-key` or `x-auth-token` handshake header selects header-only authentication on
388
+ every connection and reconnect. Embedded API-key parameters are removed; empty
389
+ or conflicting authentication headers and simultaneous token-query authentication
390
+ are rejected before connecting. A JWT header overrides an embedded token and takes
391
+ precedence over an API-key header, matching the platform. Without an authentication header, legacy query authentication
392
+ is preserved and remains visible to servers and proxies. No fallback is attempted.
393
+
394
+ `client.logger` forwards sanitized connection records without raw arguments,
395
+ exception objects, or socket metadata. `client.redact_diagnostic(text)` applies
396
+ the credential policy to transport errors a caller needs to retain. Caller-visible
397
+ errors may still contain personal data and should not be logged outside this policy.
398
+
399
+ Connection diagnostics omit dynamic strings and transport payloads, including URLs,
400
+ headers, frame bodies, user/topic identities, and untrusted server error details.
401
+ Logs retain static lifecycle descriptions, native connection states,
402
+ and exception types. Application data and caller-visible errors are not changed;
403
+ applications remain responsible for logs outside this connection-owned boundary.
@@ -3,9 +3,11 @@ MANIFEST.in
3
3
  README.md
4
4
  pyproject.toml
5
5
  phoenix_channels_python_client/__init__.py
6
+ phoenix_channels_python_client/authentication.py
6
7
  phoenix_channels_python_client/client.py
7
8
  phoenix_channels_python_client/client_state_machine.py
8
9
  phoenix_channels_python_client/client_types.py
10
+ phoenix_channels_python_client/diagnostics.py
9
11
  phoenix_channels_python_client/exceptions.py
10
12
  phoenix_channels_python_client/phx_messages.py
11
13
  phoenix_channels_python_client/protocol_handler.py
@@ -30,6 +32,7 @@ tests/test_reconnect.py
30
32
  tests/test_reconnect_policy_invariants.py
31
33
  tests/test_reconnect_stress.py
32
34
  tests/test_run_forever_signals.py
35
+ tests/test_security.py
33
36
  tests/test_shutdown_races.py
34
37
  tests/test_subscriptions.py
35
38
  tests/test_supervisor_internals.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "phoenix-channels-python-client"
7
- version = "0.3.0"
7
+ version = "0.3.1"
8
8
  description = "A Python client library for connecting to Phoenix Channels"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -90,7 +90,7 @@ async def test_a_crashed_channel_rejoins_on_the_live_socket(
90
90
  assert (await expect_message(received)).event == EVENT
91
91
  assert reconnects.count == 0
92
92
 
93
- assert f"Recovered channel for topic {TOPIC}" in caplog.text
93
+ assert "Recovered channel for topic %s" in caplog.text
94
94
 
95
95
 
96
96
  @each_protocol
@@ -155,7 +155,7 @@ async def test_a_rejected_channel_rejoin_loses_the_topic(
155
155
  lost.only_error()
156
156
  assert TOPIC not in client.get_current_subscriptions()
157
157
  assert joins(phoenix_server) == REJOINED_ONCE
158
- assert f"Lost topic {TOPIC}" in caplog.text
158
+ assert "Lost topic %s" in caplog.text
159
159
 
160
160
 
161
161
  @each_protocol
@@ -155,8 +155,7 @@ async def test_additional_headers_are_sent_on_the_ws_handshake(
155
155
  assert server_ws.request is not None
156
156
  assert server_ws.request.headers["x-api-key"] == headers["x-api-key"]
157
157
 
158
- # the header is additive; the existing api_key query param is untouched
159
- assert f"api_key={API_KEY}" in client.channel_socket_url
158
+ assert "api_key=" not in client.channel_socket_url
160
159
 
161
160
 
162
161
  async def test_entering_fails_when_the_server_is_unreachable(
@@ -0,0 +1,316 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ from http import HTTPStatus
7
+ from logging.handlers import BufferingHandler
8
+ from urllib.parse import parse_qsl, quote, urlsplit
9
+
10
+ import pytest
11
+ from websockets.asyncio.server import ServerConnection, serve
12
+ from websockets.http11 import Request, Response
13
+
14
+ from phoenix_channels_python_client import PHXChannelsClient
15
+ from phoenix_channels_python_client.client_types import ReconnectPolicy
16
+ from phoenix_channels_python_client.diagnostics import ConnectionLogger
17
+ from phoenix_channels_python_client.exceptions import PHXConnectionError
18
+
19
+
20
+ class TestConnectionSecurity:
21
+ @pytest.mark.parametrize(
22
+ "headers",
23
+ [
24
+ {"X-API-Key": ""},
25
+ {"x-api-key": " "},
26
+ {"x-api-key": "one", "X-Api-Key": "two"},
27
+ {"x-auth-token": ""},
28
+ {"X-Auth-Token": " "},
29
+ {"x-auth-token": "one", "X-Auth-Token": "two"},
30
+ ],
31
+ )
32
+ def test_invalid_headers_are_rejected_before_network(
33
+ self, headers: dict[str, str]
34
+ ) -> None:
35
+ with pytest.raises(ValueError, match="nonempty credential"):
36
+ PHXChannelsClient(
37
+ "ws://127.0.0.1:1/socket", "query-secret", additional_headers=headers
38
+ )
39
+
40
+ def test_mixed_authentication_is_rejected(self) -> None:
41
+ with pytest.raises(ValueError, match="token query authentication"):
42
+ PHXChannelsClient(
43
+ "ws://127.0.0.1:1/socket?token=jwt",
44
+ "query-secret",
45
+ additional_headers={"x-api-key": "header-secret"},
46
+ )
47
+
48
+ def test_diagnostic_levels_follow_live_configuration(self) -> None:
49
+ target = logging.Logger("synthetic-target", logging.INFO) # noqa: LOG001 -- isolated target avoids global state
50
+ logger = ConnectionLogger(target, {"synthetic-credential"})
51
+ collector = BufferingHandler(100)
52
+ logger.addHandler(collector)
53
+ logger.debug("initially suppressed")
54
+ target.setLevel(logging.DEBUG)
55
+ logger.debug("inherited debug")
56
+ target.setLevel(logging.WARNING)
57
+ logger.debug("inherited suppression")
58
+ logger.setLevel(logging.DEBUG)
59
+ logger.debug("local debug")
60
+ logger.setLevel(logging.WARNING)
61
+ logger.debug("local suppression")
62
+ logger.setLevel(logging.DEBUG)
63
+ previous_disable = logging.root.manager.disable
64
+ try:
65
+ logging.disable(logging.CRITICAL)
66
+ logger.debug("globally suppressed")
67
+ logging.disable(logging.NOTSET)
68
+ logger.debug("globally restored")
69
+ finally:
70
+ logging.disable(previous_disable)
71
+ assert [record.getMessage() for record in collector.buffer] == [
72
+ "inherited debug",
73
+ "local debug",
74
+ "globally restored",
75
+ ]
76
+
77
+ def test_connections_keep_independent_diagnostic_policies(self) -> None:
78
+ first = PHXChannelsClient("ws://127.0.0.1:1/socket", "first-credential")
79
+ second = PHXChannelsClient("ws://127.0.0.1:1/socket", "second-credential")
80
+ assert first.redact_diagnostic("first-credential") == "[REDACTED]"
81
+ assert second.redact_diagnostic("first-credential") == "first-credential"
82
+ assert second.redact_diagnostic("second-credential") == "[REDACTED]"
83
+ assert first.redact_diagnostic("correlation%2fother") == "correlation%2fother"
84
+
85
+ @pytest.mark.parametrize("policy", ["disabled", "filtered"])
86
+ def test_connection_records_respect_registered_logger_policy(
87
+ self, policy: str
88
+ ) -> None:
89
+ client = PHXChannelsClient("ws://127.0.0.1:1/socket", "synthetic-key")
90
+ target = logging.getLogger("phoenix_channels_python_client.client")
91
+ collector = BufferingHandler(100)
92
+ client.logger.addHandler(collector)
93
+ client.logger.setLevel(logging.WARNING)
94
+ previous_disabled = target.disabled
95
+
96
+ def reject(_record: logging.LogRecord) -> bool:
97
+ return False
98
+
99
+ try:
100
+ target.disabled = policy == "disabled"
101
+ if policy == "filtered":
102
+ target.addFilter(reject)
103
+ client.logger.warning("suppressed connection record")
104
+ assert collector.buffer == []
105
+
106
+ target.disabled = False
107
+ target.removeFilter(reject)
108
+ client.logger.warning("enabled connection record")
109
+ assert [record.getMessage() for record in collector.buffer] == [
110
+ "enabled connection record"
111
+ ]
112
+ finally:
113
+ target.disabled = previous_disabled
114
+ target.removeFilter(reject)
115
+
116
+ @pytest.mark.parametrize(
117
+ "header_names",
118
+ [
119
+ ("X-Api-Key",),
120
+ ("x-api-key", "X-API-KEY"),
121
+ ("X-Auth-Token",),
122
+ ("x-auth-token", "X-AUTH-TOKEN"),
123
+ ("x-api-key", "x-auth-token"),
124
+ ],
125
+ )
126
+ async def test_real_reconnect_uses_only_header_and_safe_raw_records(
127
+ self, caplog: pytest.LogCaptureFixture, header_names: tuple[str, ...]
128
+ ) -> None:
129
+ secret = "synthetic/private+ credential?x=1&z=#[]" # noqa: S105 -- synthetic test credential
130
+ positional = "different-positional-secret"
131
+ requests = []
132
+ reconnected = asyncio.Event()
133
+
134
+ async def peer(connection: ServerConnection) -> None:
135
+ requests.append(connection.request)
136
+ if len(requests) == 1:
137
+ await connection.close(code=1012, reason=secret)
138
+ else:
139
+ reconnected.set()
140
+ await connection.wait_closed()
141
+
142
+ caplog.set_level(logging.DEBUG, logger="phoenix_channels_python_client.client")
143
+ async with serve(peer, "127.0.0.1", 0) as server:
144
+ port = server.sockets[0].getsockname()[1]
145
+ token = (
146
+ "legacy-jwt"
147
+ if any(name.casefold() == "x-auth-token" for name in header_names)
148
+ else ""
149
+ )
150
+ url = f"ws://127.0.0.1:{port}/socket?API%5FKEY=embedded-secret&api_key=embedded-secret&aPi_KeY=embedded-secret&token={token}&keep=one&keep=&vsn=old"
151
+ async with PHXChannelsClient(
152
+ url,
153
+ positional,
154
+ additional_headers={
155
+ **dict.fromkeys(header_names, secret),
156
+ "x-token": "synthetic-additional-token",
157
+ },
158
+ heartbeat_interval_s=None,
159
+ reconnect_policy=ReconnectPolicy(
160
+ base_delay_s=0.01,
161
+ service_restart_min_delay_s=0.01,
162
+ service_restart_max_delay_s=0.01,
163
+ rapid_first_min_delay_s=0.01,
164
+ ),
165
+ ) as client:
166
+ collector = BufferingHandler(1000)
167
+ client.logger.addHandler(collector)
168
+ await asyncio.wait_for(reconnected.wait(), 5)
169
+ client.logger.error(
170
+ "authorization: Bearer %s", "unconfigured-bearer-value"
171
+ )
172
+ client.logger.error(
173
+ '"authorization": "Basic %s"', "unconfigured-basic-value"
174
+ )
175
+ client.logger.error(
176
+ "untrusted %s", secret, extra={"websocket": {"secret": secret}}
177
+ )
178
+ assert client.redact_diagnostic(quote(secret, safe="")) == "[REDACTED]"
179
+ assert client.redact_diagnostic(quote(secret)) == "[REDACTED]"
180
+ assert (
181
+ client.redact_diagnostic(
182
+ quote(secret, safe="").replace("%2F", "%2f")
183
+ )
184
+ == "[REDACTED]"
185
+ )
186
+ try:
187
+ raise RuntimeError(secret) # noqa: TRY301 -- exercise exception records
188
+ except RuntimeError:
189
+ client.logger.exception(
190
+ "refused credential", extra={"websocket": secret}
191
+ )
192
+ for request in requests:
193
+ assert parse_qsl(urlsplit(request.path).query, keep_blank_values=True) == [
194
+ ("keep", "one"),
195
+ ("keep", ""),
196
+ ("vsn", "2.0.0"),
197
+ ]
198
+ for name in header_names:
199
+ assert request.headers[name] == secret
200
+ assert request.headers["x-token"] == "synthetic-additional-token"
201
+ records = [
202
+ record
203
+ for record in caplog.records
204
+ if record.name == "phoenix_channels_python_client.client"
205
+ ]
206
+ assert records
207
+ assert collector.buffer
208
+ for record in [*records, *collector.buffer]:
209
+ assert record.args == ()
210
+ assert record.exc_info is None
211
+ assert not hasattr(record, "websocket")
212
+ for credential in (
213
+ secret,
214
+ positional,
215
+ "embedded-secret",
216
+ "synthetic-additional-token",
217
+ "unconfigured-bearer-value",
218
+ "unconfigured-basic-value",
219
+ quote(secret, safe=""),
220
+ ):
221
+ assert credential not in repr(record.__dict__)
222
+ assert any("OPEN" in record.getMessage() for record in records)
223
+
224
+ async def test_refused_jwt_handshake_keeps_token_out_of_query_and_records(
225
+ self, caplog: pytest.LogCaptureFixture
226
+ ) -> None:
227
+ secret = "synthetic.jwt/private+ token" # noqa: S105 -- synthetic credential
228
+ requests = []
229
+
230
+ def refuse(connection: ServerConnection, request: Request) -> Response:
231
+ requests.append(request)
232
+ return connection.respond(HTTPStatus.UNAUTHORIZED, secret)
233
+
234
+ async def peer(connection: ServerConnection) -> None:
235
+ await connection.wait_closed()
236
+
237
+ caplog.set_level(logging.DEBUG, logger="phoenix_channels_python_client.client")
238
+ async with serve(peer, "127.0.0.1", 0, process_request=refuse) as server:
239
+ port = server.sockets[0].getsockname()[1]
240
+ client = PHXChannelsClient(
241
+ f"ws://127.0.0.1:{port}/socket?token=old-jwt&api_key=old-key",
242
+ secret,
243
+ additional_headers={"X-Auth-Token": secret},
244
+ auto_reconnect=False,
245
+ )
246
+ with pytest.raises(PHXConnectionError, match="401"):
247
+ await client.__aenter__()
248
+ [request] = requests
249
+ assert parse_qsl(urlsplit(request.path).query) == [("vsn", "2.0.0")]
250
+ assert request.headers["x-auth-token"] == secret
251
+ records = [
252
+ record
253
+ for record in caplog.records
254
+ if record.name == "phoenix_channels_python_client.client"
255
+ ]
256
+ assert records
257
+ assert all(secret not in repr(record.__dict__) for record in records)
258
+
259
+ async def test_transport_and_protocol_records_omit_personal_data(
260
+ self, caplog: pytest.LogCaptureFixture
261
+ ) -> None:
262
+ identity = "synthetic-person@example.invalid"
263
+ session = "synthetic-cookie-session"
264
+ delivered = asyncio.Event()
265
+
266
+ def response(
267
+ _connection: ServerConnection, _request: Request, reply: Response
268
+ ) -> Response:
269
+ reply.headers["Set-Cookie"] = "session=" + session + "; HttpOnly"
270
+ return reply
271
+
272
+ async def peer(connection: ServerConnection) -> None:
273
+ await connection.send(
274
+ json.dumps([None, None, identity, "event", {"email": identity}])
275
+ )
276
+ delivered.set()
277
+ await connection.wait_closed()
278
+
279
+ caplog.set_level(logging.DEBUG, logger="phoenix_channels_python_client.client")
280
+ async with serve(peer, "127.0.0.1", 0, process_response=response) as server:
281
+ port = server.sockets[0].getsockname()[1]
282
+ client = PHXChannelsClient(
283
+ f"ws://127.0.0.1:{port}/socket?email={identity}",
284
+ "synthetic-key",
285
+ additional_headers={
286
+ "x-api-key": "synthetic-key",
287
+ "Cookie": "session=" + session,
288
+ },
289
+ auto_reconnect=False,
290
+ heartbeat_interval_s=None,
291
+ )
292
+ collector = BufferingHandler(1000)
293
+ client.logger.addHandler(collector)
294
+ async with client:
295
+ await asyncio.wait_for(delivered.wait(), 5)
296
+ await asyncio.sleep(0.01)
297
+ try:
298
+ raise RuntimeError(identity) # noqa: TRY301 -- exercise exception records
299
+ except RuntimeError:
300
+ client.logger.exception("Connection callback failed: %s", session)
301
+ client.logger.warning(
302
+ "Untrusted numeric identity: %d %.2f %s",
303
+ 15555550123,
304
+ 15555550123.0,
305
+ identity,
306
+ )
307
+ assert any(
308
+ "Parsing raw message:" in record.getMessage() for record in collector.buffer
309
+ )
310
+ assert any("OPEN" in record.getMessage() for record in collector.buffer)
311
+ for record in collector.buffer:
312
+ assert identity not in repr(record.__dict__)
313
+ assert session not in repr(record.__dict__)
314
+ assert "15555550123" not in repr(record.__dict__)
315
+ assert record.args == ()
316
+ assert record.exc_info is None