sonicwall-sdk 0.1.0__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 (37) hide show
  1. sonicwall_sdk-0.1.0/.gitignore +73 -0
  2. sonicwall_sdk-0.1.0/PKG-INFO +86 -0
  3. sonicwall_sdk-0.1.0/README.md +53 -0
  4. sonicwall_sdk-0.1.0/pyproject.toml +81 -0
  5. sonicwall_sdk-0.1.0/src/sonicwall/__init__.py +71 -0
  6. sonicwall_sdk-0.1.0/src/sonicwall/_auth.py +316 -0
  7. sonicwall_sdk-0.1.0/src/sonicwall/_client.py +393 -0
  8. sonicwall_sdk-0.1.0/src/sonicwall/_commit.py +121 -0
  9. sonicwall_sdk-0.1.0/src/sonicwall/_exceptions.py +97 -0
  10. sonicwall_sdk-0.1.0/src/sonicwall/_http.py +229 -0
  11. sonicwall_sdk-0.1.0/src/sonicwall/models/__init__.py +32 -0
  12. sonicwall_sdk-0.1.0/src/sonicwall/models/access_rule.py +173 -0
  13. sonicwall_sdk-0.1.0/src/sonicwall/models/address_object.py +176 -0
  14. sonicwall_sdk-0.1.0/src/sonicwall/models/dhcp.py +37 -0
  15. sonicwall_sdk-0.1.0/src/sonicwall/models/interface.py +71 -0
  16. sonicwall_sdk-0.1.0/src/sonicwall/models/nat_policy.py +122 -0
  17. sonicwall_sdk-0.1.0/src/sonicwall/models/service_object.py +85 -0
  18. sonicwall_sdk-0.1.0/src/sonicwall/resources/__init__.py +17 -0
  19. sonicwall_sdk-0.1.0/src/sonicwall/resources/_base.py +74 -0
  20. sonicwall_sdk-0.1.0/src/sonicwall/resources/_normalize.py +54 -0
  21. sonicwall_sdk-0.1.0/src/sonicwall/resources/access_rules.py +232 -0
  22. sonicwall_sdk-0.1.0/src/sonicwall/resources/address_objects.py +162 -0
  23. sonicwall_sdk-0.1.0/src/sonicwall/resources/dhcp.py +74 -0
  24. sonicwall_sdk-0.1.0/src/sonicwall/resources/interfaces.py +48 -0
  25. sonicwall_sdk-0.1.0/src/sonicwall/resources/nat_policies.py +205 -0
  26. sonicwall_sdk-0.1.0/src/sonicwall/resources/service_objects.py +174 -0
  27. sonicwall_sdk-0.1.0/tests/__init__.py +1 -0
  28. sonicwall_sdk-0.1.0/tests/conftest.py +181 -0
  29. sonicwall_sdk-0.1.0/tests/test_address_objects.py +417 -0
  30. sonicwall_sdk-0.1.0/tests/test_auth.py +266 -0
  31. sonicwall_sdk-0.1.0/tests/test_commit_context.py +56 -0
  32. sonicwall_sdk-0.1.0/tests/test_dhcp.py +69 -0
  33. sonicwall_sdk-0.1.0/tests/test_model_parsing_quirks.py +44 -0
  34. sonicwall_sdk-0.1.0/tests/test_normalize_utils.py +28 -0
  35. sonicwall_sdk-0.1.0/tests/test_resource_get_fallbacks.py +129 -0
  36. sonicwall_sdk-0.1.0/tests/test_write_schema_fallbacks.py +143 -0
  37. sonicwall_sdk-0.1.0/uv.lock +751 -0
@@ -0,0 +1,73 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ .venv/
8
+ venv/
9
+ ENV/
10
+ env/
11
+ dist/
12
+ build/
13
+ *.egg-info/
14
+ .eggs/
15
+ *.egg
16
+ .mypy_cache/
17
+ .ruff_cache/
18
+ .pytest_cache/
19
+ htmlcov/
20
+ .coverage
21
+ coverage.xml
22
+ *.cover
23
+ .hypothesis/
24
+
25
+ # Local pnpm tool cache (CI uses PNPM_CACHE_DIR)
26
+ .pnpm-home/
27
+
28
+ # Project-local uv binary (shell CI; UV_INSTALL_DIR)
29
+ .uv-install/
30
+
31
+ # Contract captures from local devices (optional tooling)
32
+ packages/python/contract-captures/
33
+
34
+ # Node / TypeScript
35
+ node_modules/
36
+ packages/typescript/dist/
37
+ packages/typescript/coverage/
38
+ packages/typescript/*.tsbuildinfo
39
+ .turbo/
40
+ .pnpm-store/
41
+ .pnpm-tool/
42
+ .pnpm-home/
43
+
44
+ # Local contract captures (live device debugging)
45
+ packages/python/contract-captures/
46
+
47
+ # Go
48
+ packages/go/bin/
49
+ packages/go/vendor/
50
+ .golangci-bin/
51
+ .go-toolchain/
52
+
53
+ # IDE
54
+ .idea/
55
+ .vscode/
56
+ *.swp
57
+ *.swo
58
+ .DS_Store
59
+
60
+ # Secrets
61
+ .env
62
+ .env.*
63
+ !.env.example
64
+ *.key
65
+ *.pem
66
+ *.p12
67
+ *.pfx
68
+ secrets/
69
+
70
+ # Build artifacts
71
+ *.tar.gz
72
+ *.whl
73
+ dist-info/
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.4
2
+ Name: sonicwall-sdk
3
+ Version: 0.1.0
4
+ Summary: Python SDK for the SonicOS REST API
5
+ Project-URL: Homepage, https://gitlab.com/gandiva-tech/sonicwall-sdk
6
+ Project-URL: Documentation, https://gitlab.com/gandiva-tech/sonicwall-sdk/-/tree/main/docs
7
+ Project-URL: Repository, https://gitlab.com/gandiva-tech/sonicwall-sdk
8
+ Project-URL: Bug Tracker, https://gitlab.com/gandiva-tech/sonicwall-sdk/-/issues
9
+ Author-email: Gandiva Tech <engineering@gandiva.tech>
10
+ License: Apache-2.0
11
+ Keywords: api,firewall,sdk,sonicos,sonicwall
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: Apache Software License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: System :: Networking :: Firewalls
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: anyio>=4.0
23
+ Requires-Dist: httpx>=0.27
24
+ Requires-Dist: pydantic>=2.0
25
+ Provides-Extra: dev
26
+ Requires-Dist: mypy>=1.10; extra == 'dev'
27
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
28
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
29
+ Requires-Dist: pytest>=8.0; extra == 'dev'
30
+ Requires-Dist: respx>=0.21; extra == 'dev'
31
+ Requires-Dist: ruff>=0.6; extra == 'dev'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # SonicWall SDK for Python
35
+
36
+ Python client for SonicOS REST API with async-first APIs, sync wrapper, pending
37
+ config transaction helpers, and typed exceptions.
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ pip install sonicwall-sdk
43
+ # or
44
+ uv add sonicwall-sdk
45
+ ```
46
+
47
+ ## Quick start (async)
48
+
49
+ ```python
50
+ import asyncio
51
+ from sonicwall import SonicWallClient
52
+
53
+ async def main() -> None:
54
+ async with SonicWallClient(
55
+ host="192.168.1.1",
56
+ username="admin",
57
+ password="secret",
58
+ verify_ssl=False,
59
+ ) as client:
60
+ objs = await client.address_objects.list()
61
+ print(f"address objects: {len(objs)}")
62
+
63
+ asyncio.run(main())
64
+ ```
65
+
66
+ ## Authentication
67
+
68
+ On SonicOS 7.x, the SDK performs Digest `auth-int` login handshake on
69
+ `POST /auth`, then sends `Authorization: Bearer <token>` for authenticated API
70
+ calls. This is automatic; no manual auth header or cookie handling is needed.
71
+
72
+ ## Transactions
73
+
74
+ SonicOS stages writes in pending config. Use `pending()` to auto-commit on
75
+ success and auto-rollback on exceptions:
76
+
77
+ ```python
78
+ async with client.pending():
79
+ await client.address_objects.create(obj)
80
+ ```
81
+
82
+ ## More docs
83
+
84
+ - Root guide: `README.md`
85
+ - Python guide: `docs/python.md`
86
+ - SonicOS quirks: `docs/sonicwall-quirks.md`
@@ -0,0 +1,53 @@
1
+ # SonicWall SDK for Python
2
+
3
+ Python client for SonicOS REST API with async-first APIs, sync wrapper, pending
4
+ config transaction helpers, and typed exceptions.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ pip install sonicwall-sdk
10
+ # or
11
+ uv add sonicwall-sdk
12
+ ```
13
+
14
+ ## Quick start (async)
15
+
16
+ ```python
17
+ import asyncio
18
+ from sonicwall import SonicWallClient
19
+
20
+ async def main() -> None:
21
+ async with SonicWallClient(
22
+ host="192.168.1.1",
23
+ username="admin",
24
+ password="secret",
25
+ verify_ssl=False,
26
+ ) as client:
27
+ objs = await client.address_objects.list()
28
+ print(f"address objects: {len(objs)}")
29
+
30
+ asyncio.run(main())
31
+ ```
32
+
33
+ ## Authentication
34
+
35
+ On SonicOS 7.x, the SDK performs Digest `auth-int` login handshake on
36
+ `POST /auth`, then sends `Authorization: Bearer <token>` for authenticated API
37
+ calls. This is automatic; no manual auth header or cookie handling is needed.
38
+
39
+ ## Transactions
40
+
41
+ SonicOS stages writes in pending config. Use `pending()` to auto-commit on
42
+ success and auto-rollback on exceptions:
43
+
44
+ ```python
45
+ async with client.pending():
46
+ await client.address_objects.create(obj)
47
+ ```
48
+
49
+ ## More docs
50
+
51
+ - Root guide: `README.md`
52
+ - Python guide: `docs/python.md`
53
+ - SonicOS quirks: `docs/sonicwall-quirks.md`
@@ -0,0 +1,81 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "sonicwall-sdk"
7
+ version = "0.1.0"
8
+ description = "Python SDK for the SonicOS REST API"
9
+ readme = "README.md"
10
+ license = { text = "Apache-2.0" }
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ { name = "Gandiva Tech", email = "engineering@gandiva.tech" },
14
+ ]
15
+ keywords = ["sonicwall", "sonicos", "firewall", "sdk", "api"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: Apache Software License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Topic :: System :: Networking :: Firewalls",
25
+ "Typing :: Typed",
26
+ ]
27
+ dependencies = [
28
+ "httpx>=0.27",
29
+ "pydantic>=2.0",
30
+ "anyio>=4.0",
31
+ ]
32
+
33
+ [project.optional-dependencies]
34
+ dev = [
35
+ "pytest>=8.0",
36
+ "pytest-asyncio>=0.23",
37
+ "pytest-cov>=5.0",
38
+ "respx>=0.21",
39
+ "ruff>=0.6",
40
+ "mypy>=1.10",
41
+ ]
42
+
43
+ [project.urls]
44
+ Homepage = "https://gitlab.com/gandiva-tech/sonicwall-sdk"
45
+ Documentation = "https://gitlab.com/gandiva-tech/sonicwall-sdk/-/tree/main/docs"
46
+ Repository = "https://gitlab.com/gandiva-tech/sonicwall-sdk"
47
+ "Bug Tracker" = "https://gitlab.com/gandiva-tech/sonicwall-sdk/-/issues"
48
+
49
+ [tool.hatch.build.targets.wheel]
50
+ packages = ["src/sonicwall"]
51
+
52
+ [tool.ruff]
53
+ line-length = 100
54
+ target-version = "py310"
55
+ src = ["src", "tests"]
56
+
57
+ [tool.ruff.lint]
58
+ select = ["E", "F", "I", "UP", "B", "N", "ANN", "RUF"]
59
+ ignore = ["ANN401", "E501", "RUF022", "RUF100", "UP037", "E402"]
60
+
61
+ [tool.ruff.lint.per-file-ignores]
62
+ "tests/**" = ["ANN", "S"]
63
+
64
+ [tool.mypy]
65
+ python_version = "3.10"
66
+ strict = true
67
+ warn_return_any = true
68
+ warn_unused_configs = true
69
+ plugins = ["pydantic.mypy"]
70
+
71
+ [tool.pytest.ini_options]
72
+ asyncio_mode = "auto"
73
+ testpaths = ["tests"]
74
+ markers = ["integration: tests requiring a real SonicWall device"]
75
+
76
+ [tool.coverage.run]
77
+ source = ["src/sonicwall"]
78
+ omit = ["*/tests/*"]
79
+
80
+ [tool.coverage.report]
81
+ show_missing = true
@@ -0,0 +1,71 @@
1
+ """SonicWall SDK — Python client for the SonicOS REST API."""
2
+
3
+ from ._client import SonicWallClient, SonicWallClientSync
4
+ from ._exceptions import (
5
+ AuthenticationError,
6
+ AuthorizationError,
7
+ CommitError,
8
+ ConflictError,
9
+ ConnectionError,
10
+ NotFoundError,
11
+ RateLimitError,
12
+ RollbackError,
13
+ SessionExpiredError,
14
+ SonicWallError,
15
+ SonicWallHTTPError,
16
+ )
17
+ from .models import (
18
+ AccessRule,
19
+ AccessRuleAction,
20
+ AddressObject,
21
+ AddressObjectType,
22
+ DhcpLease,
23
+ IcmpSpec,
24
+ Interface,
25
+ IPAssignment,
26
+ NatPolicy,
27
+ PortRange,
28
+ RuleAddress,
29
+ RulePriority,
30
+ RuleService,
31
+ ServiceObject,
32
+ ServiceProtocol,
33
+ )
34
+
35
+ __version__ = "0.1.0"
36
+
37
+ __all__ = [
38
+ # Version
39
+ "__version__",
40
+ # Clients
41
+ "SonicWallClient",
42
+ "SonicWallClientSync",
43
+ # Exceptions
44
+ "SonicWallError",
45
+ "SonicWallHTTPError",
46
+ "AuthenticationError",
47
+ "AuthorizationError",
48
+ "SessionExpiredError",
49
+ "NotFoundError",
50
+ "ConflictError",
51
+ "RateLimitError",
52
+ "CommitError",
53
+ "RollbackError",
54
+ "ConnectionError",
55
+ # Models
56
+ "AddressObject",
57
+ "AddressObjectType",
58
+ "AccessRule",
59
+ "AccessRuleAction",
60
+ "RuleAddress",
61
+ "RulePriority",
62
+ "RuleService",
63
+ "Interface",
64
+ "IPAssignment",
65
+ "NatPolicy",
66
+ "ServiceObject",
67
+ "ServiceProtocol",
68
+ "PortRange",
69
+ "IcmpSpec",
70
+ "DhcpLease",
71
+ ]
@@ -0,0 +1,316 @@
1
+ """Session authentication manager for the SonicOS REST API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import hashlib
7
+ import logging
8
+ import os
9
+ import re
10
+ from urllib.parse import urlparse
11
+
12
+ import httpx
13
+
14
+ from ._exceptions import AuthenticationError, SessionExpiredError
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # SonicOS status code indicating the session has expired
19
+ _SESSION_EXPIRED_CODE = 1085
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Digest auth-int implementation
24
+ # httpx supports qop=auth but NOT qop=auth-int (body-included integrity).
25
+ # SonicWall TZ270 (SonicOS 7.x) requires auth-int, so we implement it here.
26
+ # ---------------------------------------------------------------------------
27
+
28
+
29
+ def _parse_digest_challenge(www_auth: str) -> dict[str, str]:
30
+ """Parse a single WWW-Authenticate: Digest ... header into a dict."""
31
+ # Strip leading 'Digest ' token
32
+ body = re.sub(r"^[Dd]igest\s+", "", www_auth.strip())
33
+ params: dict[str, str] = {}
34
+ for m in re.finditer(r'(\w+)=(?:"([^"]*?)"|([^,\s]+))', body):
35
+ params[m.group(1)] = m.group(2) if m.group(2) is not None else m.group(3)
36
+ return params
37
+
38
+
39
+ def _pick_challenge(response: httpx.Response) -> dict[str, str] | None:
40
+ """Return the best Digest challenge from WWW-Authenticate headers.
41
+
42
+ Prefers SHA-256 over SHA-256-sess over MD5. Requires qop to include
43
+ auth-int (SonicOS requirement).
44
+ """
45
+ challenges: list[dict[str, str]] = []
46
+ for value in response.headers.get_list("www-authenticate"):
47
+ if value.lower().startswith("digest"):
48
+ c = _parse_digest_challenge(value)
49
+ if "auth-int" in c.get("qop", ""):
50
+ challenges.append(c)
51
+
52
+ if not challenges:
53
+ return None
54
+
55
+ def _priority(c: dict[str, str]) -> int:
56
+ alg = c.get("algorithm", "MD5").upper()
57
+ if alg == "SHA-256":
58
+ return 0
59
+ if alg == "SHA-256-SESS":
60
+ return 1
61
+ return 2
62
+
63
+ return min(challenges, key=_priority)
64
+
65
+
66
+ def _build_digest_auth_header(
67
+ method: str,
68
+ url: str,
69
+ body: bytes,
70
+ username: str,
71
+ password: str,
72
+ challenge: dict[str, str],
73
+ ) -> str:
74
+ """Build an Authorization: Digest header for qop=auth-int."""
75
+ algorithm = challenge.get("algorithm", "MD5").upper()
76
+ realm = challenge["realm"]
77
+ nonce = challenge["nonce"]
78
+ opaque = challenge.get("opaque", "")
79
+
80
+ # URI is path + query only
81
+ parsed = urlparse(url)
82
+ uri = parsed.path + (f"?{parsed.query}" if parsed.query else "")
83
+
84
+ # Choose hash function
85
+ if "SHA-256" in algorithm:
86
+
87
+ def h(s: str) -> str:
88
+ return hashlib.sha256(s.encode()).hexdigest()
89
+
90
+ def hb(b: bytes) -> str:
91
+ return hashlib.sha256(b).hexdigest()
92
+ else:
93
+
94
+ def h(s: str) -> str:
95
+ return hashlib.md5(s.encode()).hexdigest() # noqa: S324
96
+
97
+ def hb(b: bytes) -> str:
98
+ return hashlib.md5(b).hexdigest() # noqa: S324
99
+
100
+ cnonce = os.urandom(8).hex()
101
+ nc = "00000001"
102
+
103
+ # HA1
104
+ ha1 = h(f"{username}:{realm}:{password}")
105
+ if "SESS" in algorithm:
106
+ ha1 = h(f"{ha1}:{nonce}:{cnonce}")
107
+
108
+ # HA2 — auth-int includes hash of request body
109
+ ha2 = h(f"{method}:{uri}:{hb(body)}")
110
+
111
+ # Final response
112
+ digest_response = h(f"{ha1}:{nonce}:{nc}:{cnonce}:auth-int:{ha2}")
113
+
114
+ header = (
115
+ f'Digest username="{username}", realm="{realm}", '
116
+ f'nonce="{nonce}", uri="{uri}", '
117
+ f"algorithm={algorithm}, "
118
+ f'qop=auth-int, nc={nc}, cnonce="{cnonce}", '
119
+ f'response="{digest_response}"'
120
+ )
121
+ if opaque:
122
+ header += f', opaque="{opaque}"'
123
+ return header
124
+
125
+
126
+ # ---------------------------------------------------------------------------
127
+ # Cookie / session helpers
128
+ # ---------------------------------------------------------------------------
129
+
130
+
131
+ def _extract_bearer_token(response: httpx.Response) -> str | None:
132
+ """Extract the bearer_token from a successful SonicOS /auth response body."""
133
+ try:
134
+ body = response.json()
135
+ info_list = body.get("status", {}).get("info", [])
136
+ for item in info_list:
137
+ token = item.get("bearer_token")
138
+ if token:
139
+ return str(token)
140
+ except Exception: # noqa: BLE001
141
+ pass
142
+ return None
143
+
144
+
145
+ def _is_session_expired(response: httpx.Response) -> bool:
146
+ """Return True if the response body indicates SonicOS session expiry."""
147
+ try:
148
+ body = response.json()
149
+ info_list = body.get("status", {}).get("info", [])
150
+ for item in info_list:
151
+ if item.get("code") == _SESSION_EXPIRED_CODE:
152
+ return True
153
+ except Exception: # noqa: BLE001
154
+ pass
155
+ return False
156
+
157
+
158
+ # ---------------------------------------------------------------------------
159
+ # AuthManager
160
+ # ---------------------------------------------------------------------------
161
+
162
+
163
+ class AuthManager:
164
+ """Manages SonicOS session authentication.
165
+
166
+ SonicOS 7.x requires HTTP Digest auth with qop=auth-int (body integrity).
167
+ httpx does not support auth-int, so we implement the handshake manually:
168
+ 1. POST /auth without credentials → 401 with Digest challenge
169
+ 2. Compute Authorization header with auth-int
170
+ 3. POST /auth again with the computed header → 200 + JWT bearer_token in body
171
+ Subsequent requests use Authorization: Bearer <token>.
172
+ """
173
+
174
+ def __init__(self, base_url: str, username: str, password: str) -> None:
175
+ self._base_url = base_url.rstrip("/")
176
+ self._username = username
177
+ self._password = password
178
+ self._bearer_token: str | None = None
179
+ self._lock = asyncio.Lock()
180
+ self._authenticated = False
181
+
182
+ @property
183
+ def is_authenticated(self) -> bool:
184
+ return self._authenticated and self._bearer_token is not None
185
+
186
+ async def authenticate(self, client: httpx.AsyncClient) -> None:
187
+ """Perform Digest auth-int handshake against POST /auth."""
188
+ auth_url = f"{self._base_url}/auth"
189
+ body = b"{}"
190
+ headers = {"Content-Type": "application/json", "Accept": "application/json"}
191
+
192
+ logger.debug("Authenticating to %s (step 1 — get challenge)", auth_url)
193
+
194
+ # Step 1: unauthenticated request to get the Digest challenge
195
+ challenge_response = await client.post(auth_url, headers=headers, content=body)
196
+
197
+ if challenge_response.status_code != 401:
198
+ raise AuthenticationError(
199
+ status_code=challenge_response.status_code,
200
+ message=f"Expected 401 Digest challenge, got {challenge_response.status_code}",
201
+ response_body=self._safe_json(challenge_response),
202
+ )
203
+
204
+ challenge = _pick_challenge(challenge_response)
205
+ if not challenge:
206
+ raise AuthenticationError(
207
+ status_code=401,
208
+ message="Server returned 401 but no usable Digest auth-int challenge",
209
+ response_body=self._safe_json(challenge_response),
210
+ )
211
+
212
+ logger.debug(
213
+ "Got Digest challenge: algorithm=%s realm=%s",
214
+ challenge.get("algorithm"),
215
+ challenge.get("realm"),
216
+ )
217
+
218
+ # Step 2: authenticated request with computed Digest header
219
+ auth_header = _build_digest_auth_header(
220
+ method="POST",
221
+ url=auth_url,
222
+ body=body,
223
+ username=self._username,
224
+ password=self._password,
225
+ challenge=challenge,
226
+ )
227
+ authed_headers = {**headers, "Authorization": auth_header}
228
+
229
+ logger.debug("Authenticating to %s (step 2 — send credentials)", auth_url)
230
+ response = await client.post(auth_url, headers=authed_headers, content=body)
231
+
232
+ if response.status_code == 401:
233
+ raise AuthenticationError(
234
+ status_code=401,
235
+ message="Authentication failed: invalid username or password",
236
+ response_body=self._safe_json(response),
237
+ )
238
+
239
+ if not response.is_success:
240
+ raise AuthenticationError(
241
+ status_code=response.status_code,
242
+ message=f"Authentication failed with status {response.status_code}",
243
+ response_body=self._safe_json(response),
244
+ )
245
+
246
+ # SonicOS 7.x returns a JWT bearer_token in the response body (not a cookie)
247
+ token = _extract_bearer_token(response)
248
+ if not token:
249
+ raise AuthenticationError(
250
+ status_code=response.status_code,
251
+ message="Authentication succeeded but no bearer_token in response body",
252
+ response_body=self._safe_json(response),
253
+ )
254
+
255
+ self._bearer_token = token
256
+ self._authenticated = True
257
+ logger.debug("Authenticated; bearer token acquired")
258
+
259
+ async def ensure_authenticated(self, client: httpx.AsyncClient) -> None:
260
+ """Ensure a valid session exists, re-authenticating if necessary."""
261
+ if self.is_authenticated:
262
+ return
263
+ async with self._lock:
264
+ if not self.is_authenticated:
265
+ await self.authenticate(client)
266
+
267
+ async def reauthenticate(self, client: httpx.AsyncClient) -> None:
268
+ """Force re-authentication, discarding the existing token."""
269
+ async with self._lock:
270
+ self._authenticated = False
271
+ self._bearer_token = None
272
+ await self.authenticate(client)
273
+
274
+ async def logout(self, client: httpx.AsyncClient) -> None:
275
+ """Destroy the current session via DELETE /auth."""
276
+ if not self.is_authenticated:
277
+ return
278
+ auth_url = f"{self._base_url}/auth"
279
+ try:
280
+ await client.delete(
281
+ auth_url,
282
+ headers={**self._auth_headers(), "Accept": "application/json"},
283
+ )
284
+ logger.debug("Logged out successfully")
285
+ except Exception as exc: # noqa: BLE001
286
+ logger.warning("Logout request failed (ignoring): %s", exc)
287
+ finally:
288
+ self._bearer_token = None
289
+ self._authenticated = False
290
+
291
+ def _auth_headers(self) -> dict[str, str]:
292
+ if self._bearer_token:
293
+ return {"Authorization": f"Bearer {self._bearer_token}"}
294
+ return {}
295
+
296
+ def check_response_for_session_expiry(self, response: httpx.Response) -> bool:
297
+ if response.status_code == 401 and _is_session_expired(response):
298
+ return True
299
+ if response.is_success and _is_session_expired(response):
300
+ return True
301
+ return False
302
+
303
+ def raise_if_session_expired(self, response: httpx.Response) -> None:
304
+ if self.check_response_for_session_expiry(response):
305
+ raise SessionExpiredError(
306
+ status_code=response.status_code,
307
+ message="SonicOS session expired; re-authentication required",
308
+ response_body=self._safe_json(response),
309
+ )
310
+
311
+ @staticmethod
312
+ def _safe_json(response: httpx.Response) -> dict: # type: ignore[type-arg]
313
+ try:
314
+ return response.json() # type: ignore[no-any-return]
315
+ except Exception: # noqa: BLE001
316
+ return {}