Skip to content

Commit 8a4cdfe

Browse files
committed
Merge branch 'fix/wall-display-digest-auth'
2 parents fb439fa + 95b2531 commit 8a4cdfe

8 files changed

Lines changed: 339 additions & 19 deletions

File tree

packages/api/src/api/presentation/handlers.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from datetime import datetime
77
from typing import Any
88

9+
from core.domain.entities.exceptions import DeviceAuthenticationError
910
from litestar.connection import Request
1011
from litestar.exceptions import HTTPException
1112
from litestar.response import Response
@@ -39,6 +40,20 @@ def handle_value_error(request: Request, exc: ValueError) -> Response:
3940
)
4041

4142

43+
def handle_device_authentication_error(
44+
request: Request, exc: DeviceAuthenticationError
45+
) -> Response:
46+
return Response(
47+
content={
48+
"error": "Authentication Required",
49+
"message": str(exc),
50+
"timestamp": datetime.now().isoformat(),
51+
},
52+
status_code=401,
53+
media_type="application/json",
54+
)
55+
56+
4257
def handle_device_not_found_exception(
4358
request: Request, exc: DeviceNotFoundHTTPException
4459
) -> Response:
@@ -84,6 +99,7 @@ def handle_generic_exception(request: Request, exc: Exception) -> Response:
8499
]
85100
| None
86101
) = {
102+
DeviceAuthenticationError: handle_device_authentication_error,
87103
DeviceNotFoundHTTPException: handle_device_not_found_exception,
88104
ValueError: handle_value_error,
89105
HTTPException: handle_http_exception,

packages/cli/src/cli/credential_commands.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import click
22
from rich.table import Table
33

4-
from cli.commands.common import async_command, common_options
4+
from cli.commands.common import async_command
55
from cli.presentation.styles import Messages
66

77

@@ -15,7 +15,6 @@ def credential_commands() -> None:
1515
@click.argument("mac")
1616
@click.argument("password")
1717
@click.option("--username", default="admin", help="Username (default: admin)")
18-
@common_options
1918
@click.pass_context
2019
@async_command
2120
async def set_credential(
@@ -40,7 +39,6 @@ async def set_credential(
4039
@credential_commands.command("set-global")
4140
@click.argument("password")
4241
@click.option("--username", default="admin", help="Username (default: admin)")
43-
@common_options
4442
@click.pass_context
4543
@async_command
4644
async def set_global_credential(
@@ -62,7 +60,6 @@ async def set_global_credential(
6260

6361

6462
@credential_commands.command("list")
65-
@common_options
6663
@click.pass_context
6764
@async_command
6865
async def list_credentials(ctx: click.Context) -> None:
@@ -95,7 +92,6 @@ async def list_credentials(ctx: click.Context) -> None:
9592

9693
@credential_commands.command("delete")
9794
@click.argument("mac")
98-
@common_options
9995
@click.pass_context
10096
@async_command
10197
async def delete_credential(ctx: click.Context, mac: str) -> None:

packages/core/src/core/gateways/device/shelly_device_gateway.py

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
)
1616
from ...domain.enums.enums import Status
1717
from ...domain.value_objects.action_result import ActionResult
18+
from ...utils.validation import normalize_mac
1819
from ..network.network import RpcNetworkGateway
1920
from .component_type_mapping import get_api_component_type
2021
from .device import DeviceGateway
@@ -54,16 +55,21 @@ async def discover_device(self, ip: str) -> DiscoveredDevice | None:
5455
)
5556
device_data = device_info.get("result", device_info)
5657

57-
# Check if auth is required from cache or if it was just marked during request
58-
auth_required = False
58+
auth_required = device_data.get("auth_en", False)
59+
5960
if (
6061
hasattr(self._rpc_client, "auth_state_cache")
6162
and self._rpc_client.auth_state_cache
6263
):
63-
device_id = device_data.get("id") or ip
64-
auth_required = self._rpc_client.auth_state_cache.requires_auth(
65-
device_id
66-
)
64+
if auth_required:
65+
self._rpc_client.auth_state_cache.mark_auth_required(
66+
normalize_mac(ip)
67+
)
68+
else:
69+
device_id = device_data.get("id") or ip
70+
auth_required = self._rpc_client.auth_state_cache.requires_auth(
71+
device_id
72+
)
6773

6874
device = DiscoveredDevice(
6975
ip=ip,
@@ -136,6 +142,16 @@ async def get_device_status(self, ip: str) -> DeviceStatus | None:
136142
"result", device_info_response
137143
)
138144
rpc_success = True
145+
146+
if (
147+
device_info_data
148+
and device_info_data.get("auth_en", False)
149+
and hasattr(self._rpc_client, "auth_state_cache")
150+
and self._rpc_client.auth_state_cache
151+
):
152+
self._rpc_client.auth_state_cache.mark_auth_required(
153+
normalize_mac(ip)
154+
)
139155
except Exception as e:
140156
logger.error(f"Error getting device info: {e}", exc_info=True)
141157

@@ -149,6 +165,8 @@ async def get_device_status(self, ip: str) -> DeviceStatus | None:
149165
)
150166
components_data = components_response.get("result", components_response)
151167
rpc_success = True
168+
except DeviceAuthenticationError:
169+
raise
152170
except Exception as e:
153171
logger.error(f"Error getting components: {e}", exc_info=True)
154172

@@ -159,6 +177,8 @@ async def get_device_status(self, ip: str) -> DeviceStatus | None:
159177
)
160178
status_response = status_response.get("result", status_response)
161179
rpc_success = True
180+
except DeviceAuthenticationError:
181+
raise
162182
except Exception as e:
163183
logger.error(f"Error getting device status: {e}", exc_info=True)
164184

@@ -175,6 +195,8 @@ async def get_device_status(self, ip: str) -> DeviceStatus | None:
175195
status_data=status_response,
176196
)
177197

198+
except DeviceAuthenticationError:
199+
raise
178200
except Exception as e:
179201
logger.error(
180202
f"Error getting device status via RPC, attempting legacy fallback: {e}",

packages/core/src/core/gateways/network/async_shelly_rpc_client.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from core.utils.validation import normalize_mac
1717

1818
from .network import RpcNetworkGateway
19+
from .shelly_digest_auth import ShellyDigestAuth
1920

2021
logger = logging.getLogger(__name__)
2122

@@ -38,7 +39,7 @@ def __init__(
3839
self.auth_state_cache = auth_state_cache
3940
self._closed = False
4041
self._ip_to_mac: dict[str, str] = {}
41-
self._digest_auth_cache: dict[str, httpx.DigestAuth] = {}
42+
self._digest_auth_cache: dict[str, ShellyDigestAuth] = {}
4243

4344
async def make_rpc_request(
4445
self,
@@ -138,7 +139,7 @@ async def _resolve_authentication(
138139

139140
async def _get_or_create_digest_auth(
140141
self, ip: str, mac: str
141-
) -> httpx.DigestAuth | None:
142+
) -> ShellyDigestAuth | None:
142143
"""Get cached or create new DigestAuth instance for a device.
143144
144145
Args:
@@ -163,7 +164,7 @@ async def _get_or_create_digest_auth(
163164
return None
164165

165166
logger.debug("Creating new DigestAuth for %s (MAC: %s)", ip, mac)
166-
digest_auth = httpx.DigestAuth(
167+
digest_auth = ShellyDigestAuth(
167168
username=credential.username, password=credential.password
168169
)
169170
self._digest_auth_cache[mac] = digest_auth
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""
2+
Shelly-specific DigestAuth that correctly handles empty opaque values.
3+
4+
httpx 0.28.1's DigestAuth uses ``if challenge.opaque:`` which treats b""
5+
as falsy, omitting opaque from the Authorization header. RFC 7616 requires
6+
clients to return opaque unchanged — even when empty. Shelly Wall Display
7+
devices send ``opaque=""`` and reject responses that omit it.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import httpx
13+
from httpx._auth import _DigestAuthChallenge
14+
from httpx._models import Request
15+
from httpx._utils import to_str
16+
17+
18+
class ShellyDigestAuth(httpx.DigestAuth):
19+
# Pinned to httpx 0.28.1 — verify on upgrades.
20+
def _build_auth_header(
21+
self, request: Request, challenge: _DigestAuthChallenge
22+
) -> str:
23+
hash_func = self._ALGORITHM_TO_HASH_FUNCTION[challenge.algorithm.upper()]
24+
25+
def digest(data: bytes) -> bytes:
26+
return hash_func(data).hexdigest().encode()
27+
28+
A1 = b":".join((self._username, challenge.realm, self._password))
29+
30+
path = request.url.raw_path
31+
A2 = b":".join((request.method.encode(), path))
32+
HA2 = digest(A2)
33+
34+
nc_value = b"%08x" % self._nonce_count
35+
cnonce = self._get_client_nonce(self._nonce_count, challenge.nonce)
36+
self._nonce_count += 1
37+
38+
HA1 = digest(A1)
39+
if challenge.algorithm.lower().endswith("-sess"):
40+
HA1 = digest(b":".join((HA1, challenge.nonce, cnonce)))
41+
42+
qop = self._resolve_qop(challenge.qop, request=request)
43+
if qop is None:
44+
digest_data = [HA1, challenge.nonce, HA2]
45+
else:
46+
digest_data = [HA1, challenge.nonce, nc_value, cnonce, qop, HA2]
47+
48+
format_args: dict[str, bytes] = {
49+
"username": self._username,
50+
"realm": challenge.realm,
51+
"nonce": challenge.nonce,
52+
"uri": path,
53+
"response": digest(b":".join(digest_data)),
54+
"algorithm": challenge.algorithm.encode(),
55+
}
56+
if challenge.opaque is not None:
57+
format_args["opaque"] = challenge.opaque
58+
if qop:
59+
format_args["qop"] = b"auth"
60+
format_args["nc"] = nc_value
61+
format_args["cnonce"] = cnonce
62+
63+
return "Digest " + self._get_header_value(format_args)
64+
65+
def _get_header_value(self, header_fields: dict[str, bytes]) -> str:
66+
NON_QUOTED_FIELDS = ("algorithm", "qop", "nc")
67+
QUOTED_TEMPLATE = '{}="{}"'
68+
NON_QUOTED_TEMPLATE = "{}={}"
69+
70+
header_value = ""
71+
for i, (field, value) in enumerate(header_fields.items()):
72+
if i > 0:
73+
header_value += ", "
74+
template = (
75+
QUOTED_TEMPLATE
76+
if field not in NON_QUOTED_FIELDS
77+
else NON_QUOTED_TEMPLATE
78+
)
79+
header_value += template.format(field, to_str(value))
80+
81+
return header_value

packages/core/src/core/use_cases/scan_devices.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ async def execute(self, request: ScanRequest) -> list[DiscoveredDevice]:
5656
Status.DETECTED,
5757
Status.UPDATE_AVAILABLE,
5858
Status.NO_UPDATE_NEEDED,
59+
Status.AUTH_REQUIRED,
5960
]:
6061
discovered_devices.append(result)
6162

@@ -124,11 +125,7 @@ def _validate_discovered_device(self, device: DiscoveredDevice) -> None:
124125
)
125126

126127
def _apply_device_status_rules(self, device: DiscoveredDevice) -> None:
127-
if device.auth_required and device.status in [
128-
Status.DETECTED,
129-
Status.UPDATE_AVAILABLE,
130-
Status.NO_UPDATE_NEEDED,
131-
]:
128+
if device.auth_required and device.status == Status.DETECTED:
132129
device.status = Status.AUTH_REQUIRED
133130

134131
async def _discover_devices_via_mdns(self, request: ScanRequest) -> list[str]:

0 commit comments

Comments
 (0)