python-casahome 0.2.0__py3-none-any.whl

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.
casahome/auth.py ADDED
@@ -0,0 +1,199 @@
1
+ """Firebase refresh-token auth for the Casa API.
2
+
3
+ Casa signs members in with Firebase Phone Auth. SMS sign-in can't be reproduced
4
+ off-device (Firebase requires APNs app verification), so this library starts
5
+ from a refresh token taken from the member's own app session and exchanges it for short-lived
6
+ ID tokens via the standard Firebase secure token endpoint.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import json
13
+ import logging
14
+ import time
15
+ from collections.abc import Awaitable, Callable
16
+
17
+ import aiohttp
18
+ from pydantic import ValidationError
19
+
20
+ from ._base_http import wrap_connection_errors
21
+ from .const import (
22
+ DEFAULT_TIMEOUT_SECONDS,
23
+ FIREBASE_API_KEY,
24
+ FIREBASE_BUNDLE_ID,
25
+ FIREBASE_CLIENT_VERSION,
26
+ FIREBASE_GMP_ID,
27
+ SECURETOKEN_URL,
28
+ TOKEN_REFRESH_LEEWAY_SECONDS,
29
+ )
30
+ from .exceptions import CasaAuthError, CasaConnectionError, CasaError
31
+ from .models._base import CasaModel
32
+
33
+ _LOGGER = logging.getLogger("casahome")
34
+
35
+ _FATAL_REFRESH_ERRORS = frozenset(
36
+ {"TOKEN_EXPIRED", "USER_DISABLED", "USER_NOT_FOUND", "INVALID_REFRESH_TOKEN"}
37
+ )
38
+
39
+
40
+ class TokenResponse(CasaModel):
41
+ """Response from ``securetoken.googleapis.com/v1/token``."""
42
+
43
+ access_token: str
44
+ expires_in: str
45
+ token_type: str
46
+ refresh_token: str
47
+ id_token: str
48
+ user_id: str
49
+ project_id: str
50
+
51
+
52
+ class FirebaseAuth:
53
+ """Exchanges a Firebase refresh token for ID tokens, caching until near expiry.
54
+
55
+ Firebase may rotate the refresh token on any refresh. Pass
56
+ ``on_refresh_token_rotated`` (sync or async) to persist the new one, or read
57
+ :attr:`refresh_token` after use.
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ session: aiohttp.ClientSession,
63
+ refresh_token: str,
64
+ *,
65
+ api_key: str = FIREBASE_API_KEY,
66
+ token_url: str = SECURETOKEN_URL,
67
+ on_refresh_token_rotated: Callable[[str], Awaitable[None] | None] | None = None,
68
+ ) -> None:
69
+ if not refresh_token:
70
+ raise CasaAuthError("refresh_token is required")
71
+ self._session = session
72
+ self._refresh_token = refresh_token
73
+ self._api_key = api_key
74
+ self._token_url = token_url
75
+ self._id_token: str | None = None
76
+ self._expires_at: float = 0.0
77
+ self._user_id: str | None = None
78
+ self._lock = asyncio.Lock()
79
+ self._on_rotated = on_refresh_token_rotated
80
+
81
+ @property
82
+ def refresh_token(self) -> str:
83
+ """The current refresh token, possibly rotated by Firebase."""
84
+ return self._refresh_token
85
+
86
+ @property
87
+ def user_id(self) -> str | None:
88
+ """Firebase uid, known after the first refresh."""
89
+ return self._user_id
90
+
91
+ def _token_is_fresh(self) -> bool:
92
+ return (
93
+ self._id_token is not None
94
+ and self._expires_at - time.monotonic() > TOKEN_REFRESH_LEEWAY_SECONDS
95
+ )
96
+
97
+ async def get_id_token(self) -> str:
98
+ """Return a valid ID token, refreshing if it expires within the leeway."""
99
+ if self._token_is_fresh():
100
+ assert self._id_token is not None
101
+ return self._id_token
102
+ async with self._lock:
103
+ if not self._token_is_fresh():
104
+ await self._refresh_locked()
105
+ assert self._id_token is not None
106
+ return self._id_token
107
+
108
+ def invalidate(self, token: str | None = None) -> None:
109
+ """Drop the cached ID token so the next call refreshes.
110
+
111
+ Pass the token that was rejected. If another request already replaced
112
+ it, this is a no-op, so a burst of concurrent 401s costs one refresh.
113
+ """
114
+ if token is not None and token != self._id_token:
115
+ return
116
+ self._id_token = None
117
+ self._expires_at = 0.0
118
+
119
+ async def refresh(self) -> TokenResponse:
120
+ """Force a refresh regardless of cached token state."""
121
+ async with self._lock:
122
+ return await self._refresh_locked()
123
+
124
+ async def _refresh_locked(self) -> TokenResponse:
125
+ _LOGGER.debug("Refreshing Firebase ID token")
126
+ headers = {
127
+ "X-Ios-Bundle-Identifier": FIREBASE_BUNDLE_ID,
128
+ "X-Firebase-GMPID": FIREBASE_GMP_ID,
129
+ "X-Client-Version": FIREBASE_CLIENT_VERSION,
130
+ }
131
+ data = {"grant_type": "refresh_token", "refresh_token": self._refresh_token}
132
+ async with (
133
+ wrap_connection_errors(),
134
+ self._session.post(
135
+ self._token_url,
136
+ params={"key": self._api_key},
137
+ data=data,
138
+ headers=headers,
139
+ timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT_SECONDS),
140
+ ) as resp,
141
+ ):
142
+ text = await resp.text()
143
+ if resp.status >= 400:
144
+ raise _auth_error(resp.status, text)
145
+ try:
146
+ token = TokenResponse.model_validate_json(text)
147
+ except ValidationError as err:
148
+ # A 200 that isn't a token response is a proxy or portal, not a bad credential.
149
+ raise CasaConnectionError(
150
+ f"Token endpoint returned an unexpected body: {text[:200]}"
151
+ ) from err
152
+
153
+ self._id_token = token.id_token
154
+ previous = self._refresh_token
155
+ self._refresh_token = token.refresh_token or self._refresh_token
156
+ self._user_id = token.user_id
157
+ try:
158
+ lifetime = int(token.expires_in)
159
+ except ValueError:
160
+ lifetime = 3600
161
+ self._expires_at = time.monotonic() + lifetime
162
+ if self._refresh_token != previous and self._on_rotated is not None:
163
+ await self._notify_rotated(self._refresh_token)
164
+ return token
165
+
166
+ async def _notify_rotated(self, new_token: str) -> None:
167
+ """Run the rotation callback. Its failures are logged, never raised.
168
+
169
+ Auth already succeeded at this point. A broken persistence hook must
170
+ not fail the request that triggered the refresh.
171
+ """
172
+ assert self._on_rotated is not None
173
+ try:
174
+ result = self._on_rotated(new_token)
175
+ if result is not None:
176
+ await result
177
+ except Exception:
178
+ _LOGGER.exception("on_refresh_token_rotated callback failed; new token not persisted")
179
+
180
+
181
+ def _auth_error(status: int, body: str) -> CasaError:
182
+ """Map a token endpoint failure.
183
+
184
+ Only 4xx means the credential itself is bad. 5xx is Google having a bad
185
+ day and must not trigger reauth in Home Assistant.
186
+ """
187
+ message = ""
188
+ try:
189
+ payload = json.loads(body)
190
+ error = payload.get("error", {})
191
+ message = error.get("message", "") if isinstance(error, dict) else str(error)
192
+ except (ValueError, AttributeError):
193
+ pass
194
+ if message in _FATAL_REFRESH_ERRORS:
195
+ return CasaAuthError(message)
196
+ excerpt = message or body[:200]
197
+ if status >= 500 or status == 429:
198
+ return CasaConnectionError(f"Token endpoint unavailable (HTTP {status}): {excerpt}")
199
+ return CasaAuthError(f"Token refresh failed (HTTP {status}): {excerpt}")
casahome/client.py ADDED
@@ -0,0 +1,168 @@
1
+ """Low-level authenticated HTTP client for the Casa foundation API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json as jsonlib
6
+ import logging
7
+ from collections.abc import AsyncIterator, Mapping
8
+ from contextlib import asynccontextmanager
9
+ from typing import Any
10
+
11
+ import aiohttp
12
+
13
+ from ._base_http import wrap_connection_errors
14
+ from .auth import FirebaseAuth
15
+ from .const import (
16
+ CASA_APP,
17
+ CASA_APP_VERSION,
18
+ DEFAULT_TIMEOUT_SECONDS,
19
+ FOUNDATION_BASE_URL,
20
+ USER_AGENT,
21
+ )
22
+ from .exceptions import CasaApiError, CasaAuthError
23
+
24
+ _LOGGER = logging.getLogger("casahome")
25
+
26
+
27
+ class CasaClient:
28
+ """Sends authenticated requests and turns Casa error envelopes into exceptions.
29
+
30
+ The client never owns the aiohttp session. Callers create and close it, which
31
+ is what Home Assistant expects. For scripts, use :meth:`create`.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ session: aiohttp.ClientSession,
37
+ auth: FirebaseAuth,
38
+ *,
39
+ base_url: str = FOUNDATION_BASE_URL,
40
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
41
+ ) -> None:
42
+ self._session = session
43
+ self.auth = auth
44
+ self._base_url = base_url.rstrip("/")
45
+ self._timeout = aiohttp.ClientTimeout(total=timeout)
46
+
47
+ @classmethod
48
+ @asynccontextmanager
49
+ async def create(
50
+ cls,
51
+ refresh_token: str,
52
+ *,
53
+ base_url: str = FOUNDATION_BASE_URL,
54
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
55
+ ) -> AsyncIterator[CasaClient]:
56
+ """Build a client with its own session. Convenience for scripts."""
57
+ async with aiohttp.ClientSession() as session:
58
+ auth = FirebaseAuth(session, refresh_token)
59
+ yield cls(session, auth, base_url=base_url, timeout=timeout)
60
+
61
+ async def request(
62
+ self,
63
+ method: str,
64
+ path: str,
65
+ *,
66
+ json: Any | None = None,
67
+ params: Mapping[str, str] | None = None,
68
+ retry_on_401: bool | None = None,
69
+ ) -> Any:
70
+ """Send a request and return decoded JSON (or None for empty bodies).
71
+
72
+ ``retry_on_401`` defaults to True for GET only. Writes (POST, PATCH,
73
+ DELETE) default to a single attempt: a 401 after the server may have
74
+ acted would turn a retry into a duplicate write. Non-retrying requests
75
+ start with a force-refreshed ID token instead, so an expiring token
76
+ can't trigger the 401 in the first place. A 401 still invalidates the
77
+ rejected token and raises ``CasaAuthError``.
78
+
79
+ Non-GET error bodies never fall back to raw response text. Only the
80
+ structured error envelope fields are kept, since a write's error body
81
+ could echo the private content that was sent.
82
+ """
83
+ retry = method == "GET" if retry_on_401 is None else retry_on_401
84
+ scrub = method != "GET"
85
+ _LOGGER.debug("%s %s", method, path)
86
+ if not retry:
87
+ await self.auth.refresh()
88
+ status, body, used = await self._send(method, path, json=json, params=params)
89
+ if status == 401:
90
+ self.auth.invalidate(used)
91
+ if not retry:
92
+ raise CasaAuthError(f"Casa rejected the ID token (HTTP 401) on {method}")
93
+ _LOGGER.debug("401 on %s %s, refreshing token and retrying once", method, path)
94
+ status, body, _ = await self._send(method, path, json=json, params=params)
95
+ if status == 401:
96
+ raise CasaAuthError("Casa rejected the refreshed token (HTTP 401)")
97
+
98
+ if status == 304 or (200 <= status < 300 and not body.strip()):
99
+ return None
100
+ if 200 <= status < 300:
101
+ try:
102
+ return jsonlib.loads(body)
103
+ except ValueError:
104
+ display = "" if scrub else body[:200]
105
+ # Raised outside the handler so the decode error's context is dropped.
106
+ raise CasaApiError(status, "invalid_json", display_message=display)
107
+ raise _api_error(status, body, raw_fallback=not scrub)
108
+
109
+ async def _send(
110
+ self,
111
+ method: str,
112
+ path: str,
113
+ *,
114
+ json: Any | None,
115
+ params: Mapping[str, str] | None,
116
+ ) -> tuple[int, str, str]:
117
+ token = await self.auth.get_id_token()
118
+ headers = {
119
+ "Authorization": f"Bearer {token}",
120
+ "X-Casa-App": CASA_APP,
121
+ "X-Casa-App-Version": CASA_APP_VERSION,
122
+ "User-Agent": USER_AGENT,
123
+ "Accept": "application/json",
124
+ }
125
+ kwargs: dict[str, Any] = {"headers": headers, "timeout": self._timeout}
126
+ if json is not None:
127
+ headers["Content-Type"] = "application/json"
128
+ kwargs["data"] = jsonlib.dumps(json)
129
+ if params:
130
+ kwargs["params"] = dict(params)
131
+ url = self._base_url + path
132
+ async with (
133
+ wrap_connection_errors(),
134
+ self._session.request(method, url, **kwargs) as resp,
135
+ ):
136
+ return resp.status, await resp.text(), token
137
+
138
+ async def get(self, path: str, *, params: Mapping[str, str] | None = None) -> Any:
139
+ return await self.request("GET", path, params=params)
140
+
141
+ async def post(self, path: str, *, json: Any | None = None, retry_on_401: bool = False) -> Any:
142
+ return await self.request("POST", path, json=json, retry_on_401=retry_on_401)
143
+
144
+ async def patch(self, path: str, *, json: Any | None = None) -> Any:
145
+ return await self.request("PATCH", path, json=json, retry_on_401=False)
146
+
147
+ async def delete(self, path: str) -> Any:
148
+ return await self.request("DELETE", path, retry_on_401=False)
149
+
150
+
151
+ def _api_error(status: int, body: str, *, raw_fallback: bool = True) -> CasaApiError:
152
+ try:
153
+ payload = jsonlib.loads(body)
154
+ except ValueError:
155
+ payload = None
156
+ if isinstance(payload, dict) and isinstance(payload.get("error"), dict):
157
+ err = payload["error"]
158
+ return CasaApiError(
159
+ status,
160
+ str(err.get("code", "unknown")),
161
+ str(err.get("displayTitle", "")),
162
+ str(err.get("displayMessage", "")),
163
+ str(err.get("requestId", "")),
164
+ )
165
+ if isinstance(payload, dict) and isinstance(payload.get("error"), str):
166
+ # Second observed shape: {"error": "Invalid user device info"}
167
+ return CasaApiError(status, "unknown", display_message=payload["error"])
168
+ return CasaApiError(status, "unknown", display_message=body[:500] if raw_fallback else "")
casahome/const.py ADDED
@@ -0,0 +1,34 @@
1
+ """Constants observed in Casa iOS app traffic and bundle metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ FOUNDATION_BASE_URL = "https://foundation.platform.casa"
6
+ UTILITY_CONNECT_BASE_URL = "https://utility-connect.platform.casa" # not used yet
7
+
8
+ # Firebase project the app authenticates against. The API key is a public client
9
+ # identifier shipped inside the iOS bundle (GoogleService-Info.plist), not a secret.
10
+ FIREBASE_API_KEY = "AIzaSyA4XygCbi-oW1M8lP5z51NSX19O2AX2j14"
11
+ FIREBASE_PROJECT_ID = "casa-technologies-inc"
12
+ FIREBASE_GMP_ID = "1:312296672090:ios:5a8ecec7caa297a05b4163"
13
+ FIREBASE_BUNDLE_ID = "casa-technologies.member-ios"
14
+ FIREBASE_CLIENT_VERSION = "iOS/FirebaseSDK/12.18.0/FirebaseCore-iOS"
15
+ SECURETOKEN_URL = "https://securetoken.googleapis.com/v1/token"
16
+
17
+ # App identity headers the Casa backend expects.
18
+ CASA_APP = "member-ios"
19
+ # The bootstrap body spells it with an underscore. Header uses a dash. Both VERIFIED.
20
+ CASA_DEVICE_APP = "member_ios"
21
+ CASA_APP_VERSION = "698"
22
+ # Same build as an integer. The app sends it as the feed's ``app_build`` and
23
+ # release notes' ``currentBuild`` query values (CFBundleVersion parsed as Int32).
24
+ CASA_APP_BUILD = int(CASA_APP_VERSION)
25
+ # The app's Int32 ceiling, also its fallback when the bundle build is missing.
26
+ MAX_APP_BUILD = 2_147_483_647
27
+ APP_MARKETING_VERSION = "2026.38.2"
28
+ USER_AGENT = (
29
+ f"Casa/{APP_MARKETING_VERSION} ({FIREBASE_BUNDLE_ID}; build:{CASA_APP_VERSION}; "
30
+ "iOS 27.0.0) Alamofire/5.12.0"
31
+ )
32
+
33
+ TOKEN_REFRESH_LEEWAY_SECONDS = 120
34
+ DEFAULT_TIMEOUT_SECONDS = 30
casahome/exceptions.py ADDED
@@ -0,0 +1,52 @@
1
+ """Exception hierarchy for python-casahome."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class CasaError(Exception):
7
+ """Base class for all library errors."""
8
+
9
+
10
+ class CasaAuthError(CasaError):
11
+ """Authentication failed or the refresh token is no longer valid."""
12
+
13
+
14
+ class CasaConnectionError(CasaError):
15
+ """Network failure or timeout talking to Casa or Firebase."""
16
+
17
+
18
+ class CasaResponseError(CasaError):
19
+ """Casa answered, but the body didn't match the expected schema.
20
+
21
+ Usually means Casa changed a response shape. ``__cause__`` carries the
22
+ underlying pydantic ValidationError.
23
+ """
24
+
25
+
26
+ class CasaRequestError(CasaError, ValueError):
27
+ """A write body failed validation before any I/O.
28
+
29
+ The message carries only the model name and error count. Pydantic's own
30
+ error would echo the rejected input, which can be private message text.
31
+ Subclasses ``ValueError`` so existing ``except ValueError`` callers still work.
32
+ """
33
+
34
+
35
+ class CasaApiError(CasaError):
36
+ """Casa returned a non-success response."""
37
+
38
+ def __init__(
39
+ self,
40
+ status: int,
41
+ code: str,
42
+ display_title: str = "",
43
+ display_message: str = "",
44
+ request_id: str = "",
45
+ ) -> None:
46
+ self.status = status
47
+ self.code = code
48
+ self.display_title = display_title
49
+ self.display_message = display_message
50
+ self.request_id = request_id
51
+ detail = display_message or display_title or code
52
+ super().__init__(f"HTTP {status} [{code}] {detail}".strip())