spgroup 0.1.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.
spgroup/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """Typed async SDK for SP Group APIs."""
2
+
3
+ from .client import SpGroupClient
4
+ from .mobile import (
5
+ JarvisClient,
6
+ MfaRequired,
7
+ MobileAuthClient,
8
+ MobileSession,
9
+ SpGroupMobileError,
10
+ TokenMetadata,
11
+ )
12
+
13
+ __all__ = [
14
+ "JarvisClient",
15
+ "MfaRequired",
16
+ "MobileAuthClient",
17
+ "MobileSession",
18
+ "SpGroupClient",
19
+ "SpGroupMobileError",
20
+ "TokenMetadata",
21
+ ]
spgroup/auth.py ADDED
@@ -0,0 +1,14 @@
1
+ """Compatibility exports for SP Group mobile auth."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .mobile import MfaRequired, MobileAuthClient, MobileSession, TokenMetadata
6
+ from .mobile.errors import SpGroupAuthError
7
+
8
+ __all__ = [
9
+ "MfaRequired",
10
+ "MobileAuthClient",
11
+ "MobileSession",
12
+ "SpGroupAuthError",
13
+ "TokenMetadata",
14
+ ]
spgroup/client.py ADDED
@@ -0,0 +1,17 @@
1
+ """Compatibility exports for SP Group mobile API clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .mobile import JarvisClient, MobileAuthClient
6
+
7
+
8
+ class SpGroupClient:
9
+ """Compatibility facade for the current Home Assistant scaffold.
10
+
11
+ Real mobile API behavior lives in :class:`spgroup.mobile.JarvisClient` and
12
+ :class:`spgroup.mobile.MobileAuthClient`. This facade keeps the placeholder
13
+ HA integration importable until it is wired to an injected aiohttp session.
14
+ """
15
+
16
+
17
+ __all__ = ["JarvisClient", "MobileAuthClient", "SpGroupClient"]
@@ -0,0 +1,31 @@
1
+ """Async SP Group mobile SDK."""
2
+
3
+ from .auth import MobileAuthClient
4
+ from .constants import APK_USER_AGENT, DEFAULT_SCOPE, PROTOTYPE_USER_AGENT
5
+ from .errors import MfaRequired, SpGroupAuthError, SpGroupMobileError
6
+ from .jarvis import JarvisClient
7
+ from .profile import ProfileClient
8
+ from .session import (
9
+ IdentityMetadata,
10
+ MobileSession,
11
+ TokenMetadata,
12
+ extract_identity_metadata,
13
+ )
14
+ from .yggdrasil import YggdrasilClient
15
+
16
+ __all__ = [
17
+ "APK_USER_AGENT",
18
+ "DEFAULT_SCOPE",
19
+ "IdentityMetadata",
20
+ "MfaRequired",
21
+ "JarvisClient",
22
+ "MobileAuthClient",
23
+ "MobileSession",
24
+ "PROTOTYPE_USER_AGENT",
25
+ "ProfileClient",
26
+ "SpGroupAuthError",
27
+ "SpGroupMobileError",
28
+ "TokenMetadata",
29
+ "YggdrasilClient",
30
+ "extract_identity_metadata",
31
+ ]
spgroup/mobile/auth.py ADDED
@@ -0,0 +1,336 @@
1
+ """Auth0 client for SP Group mobile authentication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime
6
+ from typing import Any
7
+
8
+ from .constants import (
9
+ AUTH0_AUDIENCE,
10
+ AUTH0_BASE_URL,
11
+ AUTH0_CLIENT_ID,
12
+ AUTH0_GET_JSON_HEADERS,
13
+ AUTH0_JSON_HEADERS,
14
+ AUTH0_REALM,
15
+ DEFAULT_SCOPE,
16
+ MFA_OOB_GRANT_TYPE,
17
+ MFA_OTP_GRANT_TYPE,
18
+ PASSWORD_REALM_GRANT_TYPE,
19
+ REFRESH_TOKEN_GRANT_TYPE,
20
+ )
21
+ from .errors import MfaRequired, SpGroupAuthError
22
+ from .models import MfaChallenge, RawResponse
23
+ from .request import AuthPolicy, EndpointFamily, RequestSpec, prepare_request
24
+ from .resource import parse_mobile_api_error
25
+ from .session import MobileSession, extract_identity_metadata, extract_token_metadata
26
+ from .transport import MobileTransport, as_object
27
+
28
+
29
+ class MobileAuthClient:
30
+ """Async Auth0 client for the SP Group mobile flow."""
31
+
32
+ def __init__(
33
+ self,
34
+ session: object,
35
+ *,
36
+ base_url: str = AUTH0_BASE_URL,
37
+ client_id: str = AUTH0_CLIENT_ID,
38
+ audience: str = AUTH0_AUDIENCE,
39
+ default_scope: str = DEFAULT_SCOPE,
40
+ ) -> None:
41
+ """Initialize the auth client."""
42
+ self._transport = MobileTransport(session) # type: ignore[arg-type]
43
+ self._base_url = base_url.rstrip("/")
44
+ self._client_id = client_id
45
+ self._audience = audience
46
+ self._default_scope = default_scope
47
+
48
+ async def attempt_password_realm_login(
49
+ self,
50
+ username: str,
51
+ password: str,
52
+ *,
53
+ scope: str | None = None,
54
+ ) -> MobileSession:
55
+ """Attempt password-realm login and return a session."""
56
+ body: dict[str, object] = {
57
+ "grant_type": PASSWORD_REALM_GRANT_TYPE,
58
+ "client_id": self._client_id,
59
+ "audience": self._audience,
60
+ "username": username,
61
+ "password": password,
62
+ "scope": scope or self._default_scope,
63
+ "realm": AUTH0_REALM,
64
+ }
65
+ data = await self._post_oauth_token(body, context="password login")
66
+ if data.get("error") == "mfa_required":
67
+ mfa_token = data.get("mfa_token")
68
+ if not isinstance(mfa_token, str) or not mfa_token:
69
+ raise SpGroupAuthError(
70
+ "MFA required but response did not include mfa_token"
71
+ )
72
+ description = data.get("error_description")
73
+ raise MfaRequired(
74
+ mfa_token,
75
+ description if isinstance(description, str) else None,
76
+ )
77
+ self._raise_auth_error(data, context="password login")
78
+ return self._session_from_token_response(data, fallback_scope=scope)
79
+
80
+ async def get_mfa_authenticators(self, mfa_token: str) -> list[dict[str, Any]]:
81
+ """Return raw MFA authenticators for an MFA token."""
82
+ response = await self._request(
83
+ RequestSpec(
84
+ method="GET",
85
+ family=EndpointFamily.AUTH0,
86
+ path="/mfa/authenticators",
87
+ auth_policy=AuthPolicy.NO_AUTH,
88
+ static_headers={
89
+ **AUTH0_GET_JSON_HEADERS,
90
+ "Authorization": f"Bearer {mfa_token}",
91
+ },
92
+ )
93
+ )
94
+ if response.status >= 400:
95
+ self._raise_auth_http_error(
96
+ response.data,
97
+ context="MFA authenticators",
98
+ status=response.status,
99
+ )
100
+ if not isinstance(response.data, list):
101
+ raise SpGroupAuthError("MFA authenticators response was not a list")
102
+ return [item for item in response.data if isinstance(item, dict)]
103
+
104
+ async def trigger_mfa_challenge(
105
+ self,
106
+ mfa_token: str,
107
+ *,
108
+ authenticator_id: str | None = None,
109
+ ) -> MfaChallenge:
110
+ """Trigger an OOB MFA challenge and return the OOB code."""
111
+ body: dict[str, object] = {
112
+ "client_id": self._client_id,
113
+ "mfa_token": mfa_token,
114
+ "challenge_type": "oob",
115
+ }
116
+ if authenticator_id is not None:
117
+ body["authenticator_id"] = authenticator_id
118
+
119
+ response = await self._request(
120
+ RequestSpec(
121
+ method="POST",
122
+ family=EndpointFamily.AUTH0,
123
+ path="/mfa/challenge",
124
+ auth_policy=AuthPolicy.NO_AUTH,
125
+ static_headers=AUTH0_JSON_HEADERS,
126
+ json_body=body,
127
+ )
128
+ )
129
+ if response.status >= 400:
130
+ self._raise_auth_http_error(
131
+ response.data,
132
+ context="MFA challenge",
133
+ status=response.status,
134
+ )
135
+ data = as_object(response.data, context="MFA challenge")
136
+ oob_code = data.get("oob_code")
137
+ if not isinstance(oob_code, str) or not oob_code:
138
+ raise SpGroupAuthError("MFA challenge response missing oob_code")
139
+ return MfaChallenge(oob_code=oob_code, authenticator_id=authenticator_id)
140
+
141
+ async def complete_mfa_oob(
142
+ self,
143
+ mfa_token: str,
144
+ oob_code: str,
145
+ otp: str,
146
+ ) -> MobileSession:
147
+ """Complete an OOB MFA challenge with the OTP binding code."""
148
+ data = await self._post_oauth_token(
149
+ {
150
+ "grant_type": MFA_OOB_GRANT_TYPE,
151
+ "client_id": self._client_id,
152
+ "mfa_token": mfa_token,
153
+ "oob_code": oob_code,
154
+ "binding_code": otp,
155
+ },
156
+ context="MFA OOB completion",
157
+ )
158
+ self._raise_auth_error(data, context="MFA OOB completion")
159
+ return self._session_from_token_response(data)
160
+
161
+ async def complete_mfa_otp(self, mfa_token: str, otp: str) -> MobileSession:
162
+ """Complete a TOTP/authenticator-app MFA challenge."""
163
+ data = await self._post_oauth_token(
164
+ {
165
+ "grant_type": MFA_OTP_GRANT_TYPE,
166
+ "client_id": self._client_id,
167
+ "mfa_token": mfa_token,
168
+ "otp": otp,
169
+ },
170
+ context="MFA OTP completion",
171
+ )
172
+ self._raise_auth_error(data, context="MFA OTP completion")
173
+ return self._session_from_token_response(data)
174
+
175
+ async def refresh_session(self, session: MobileSession) -> MobileSession:
176
+ """Refresh a mobile session using its refresh token."""
177
+ if not session.refresh_token:
178
+ raise SpGroupAuthError("Cannot refresh session without refresh token")
179
+ data = await self._post_oauth_token(
180
+ {
181
+ "grant_type": REFRESH_TOKEN_GRANT_TYPE,
182
+ "client_id": self._client_id,
183
+ "refresh_token": session.refresh_token,
184
+ "scope": session.scope or self._default_scope,
185
+ },
186
+ context="refresh",
187
+ )
188
+ self._raise_auth_error(data, context="refresh")
189
+ return self._session_from_token_response(
190
+ data,
191
+ fallback_refresh_token=session.refresh_token,
192
+ fallback_scope=session.scope,
193
+ )
194
+
195
+ async def request_force_mfa_challenge_token(
196
+ self,
197
+ refresh_token: str,
198
+ ) -> dict[str, Any]:
199
+ """Request the APK force-MFA refresh flow using a refresh token."""
200
+ if not refresh_token:
201
+ raise SpGroupAuthError("Cannot request force MFA without refresh token")
202
+ response = await self._request(
203
+ RequestSpec(
204
+ method="POST",
205
+ family=EndpointFamily.AUTH0,
206
+ path="/oauth/token",
207
+ auth_policy=AuthPolicy.NO_AUTH,
208
+ static_headers=AUTH0_JSON_HEADERS,
209
+ json_body={
210
+ "grant_type": REFRESH_TOKEN_GRANT_TYPE,
211
+ "client_id": self._client_id,
212
+ "refresh_token": refresh_token,
213
+ "force_mfa": True,
214
+ },
215
+ )
216
+ )
217
+ if response.status >= 400:
218
+ self._raise_auth_http_error(
219
+ response.data,
220
+ context="force MFA refresh",
221
+ status=response.status,
222
+ )
223
+ if response.data is None:
224
+ data: dict[str, Any] = {}
225
+ else:
226
+ data = as_object(response.data, context="force MFA refresh")
227
+ return data
228
+
229
+ async def _post_oauth_token(
230
+ self,
231
+ body: dict[str, object],
232
+ *,
233
+ context: str,
234
+ ) -> dict[str, Any]:
235
+ response = await self._request(
236
+ RequestSpec(
237
+ method="POST",
238
+ family=EndpointFamily.AUTH0,
239
+ path="/oauth/token",
240
+ auth_policy=AuthPolicy.NO_AUTH,
241
+ static_headers=AUTH0_JSON_HEADERS,
242
+ json_body=body,
243
+ )
244
+ )
245
+ if response.status >= 400:
246
+ if (
247
+ isinstance(response.data, dict)
248
+ and response.data.get("error") == "mfa_required"
249
+ ):
250
+ return as_object(response.data, context=context)
251
+ self._raise_auth_http_error(
252
+ response.data,
253
+ context=context,
254
+ status=response.status,
255
+ )
256
+ data = as_object(response.data, context=context)
257
+ return data
258
+
259
+ async def _request(self, spec: RequestSpec) -> RawResponse:
260
+ request = prepare_request(
261
+ spec,
262
+ user_agent=None,
263
+ base_url_overrides={EndpointFamily.AUTH0: self._base_url},
264
+ )
265
+ return await self._transport.request_json(
266
+ request.method,
267
+ request.url,
268
+ headers=request.headers,
269
+ json_body=request.json_body,
270
+ )
271
+
272
+ def _session_from_token_response(
273
+ self,
274
+ data: dict[str, Any],
275
+ *,
276
+ fallback_refresh_token: str = "",
277
+ fallback_scope: str | None = None,
278
+ ) -> MobileSession:
279
+ access_token = data.get("access_token")
280
+ id_token = data.get("id_token")
281
+ if not isinstance(access_token, str) or not isinstance(id_token, str):
282
+ raise SpGroupAuthError("Token response missing access_token or id_token")
283
+ refresh_token = data.get("refresh_token")
284
+ scope = data.get("scope")
285
+ return MobileSession(
286
+ access_token=access_token,
287
+ id_token=id_token,
288
+ refresh_token=(
289
+ refresh_token
290
+ if isinstance(refresh_token, str)
291
+ else fallback_refresh_token
292
+ ),
293
+ scope=scope if isinstance(scope, str) else fallback_scope,
294
+ token_metadata=extract_token_metadata(access_token),
295
+ identity_metadata=extract_identity_metadata(id_token),
296
+ created_at=datetime.now(UTC),
297
+ )
298
+
299
+ @staticmethod
300
+ def _raise_auth_error(
301
+ data: dict[str, Any],
302
+ *,
303
+ context: str,
304
+ status: int | None = None,
305
+ ) -> None:
306
+ error = data.get("error")
307
+ if not error:
308
+ return
309
+ description = data.get("error_description")
310
+ detail = f"{error}"
311
+ if isinstance(description, str) and description:
312
+ detail = f"{detail} - {description}"
313
+ api_error = parse_mobile_api_error(status or 0, data)
314
+ raise SpGroupAuthError(
315
+ f"{context} failed: {detail}; response={api_error.redacted_body}",
316
+ status=status,
317
+ body=api_error.redacted_body,
318
+ detail=api_error,
319
+ )
320
+
321
+ @staticmethod
322
+ def _raise_auth_http_error(
323
+ data: object,
324
+ *,
325
+ context: str,
326
+ status: int,
327
+ ) -> None:
328
+ if isinstance(data, dict):
329
+ MobileAuthClient._raise_auth_error(data, context=context, status=status)
330
+ detail = parse_mobile_api_error(status, data)
331
+ raise SpGroupAuthError(
332
+ f"{context} failed: HTTP {status}; response={detail.redacted_body}",
333
+ status=status,
334
+ body=detail.redacted_body,
335
+ detail=detail,
336
+ )
@@ -0,0 +1,51 @@
1
+ """Constants for SP Group mobile API clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ AUTH0_BASE_URL = "https://identity.spdigital.sg"
6
+ MULESOFT_AUTH_BASE_URL = "https://b2c.api.spdigital.sg"
7
+ MULESOFT_PUBLIC_BASE_URL = "https://public.api.spdigital.sg"
8
+ JARVIS_BASE_URL = MULESOFT_AUTH_BASE_URL
9
+ JARVIS_PUBLIC_BASE_URL = MULESOFT_PUBLIC_BASE_URL
10
+
11
+ AUTH0_CLIENT_ID = "z1tl6I1V6HI201ule9tmSALb97hw8Biu"
12
+ AUTH0_AUDIENCE = "https://profile.up.spdigital.sg/"
13
+ AUTH0_REALM = "Username-Password-Authentication"
14
+ AUTH0_CLIENT_HEADER = "eyJuYW1lIjoiYXV0aDAtYW5kcm9pZCIsInZlcnNpb24iOiIxLjEyLjAifQ=="
15
+
16
+ PASSWORD_REALM_GRANT_TYPE = "http://auth0.com/oauth/grant-type/password-realm"
17
+ MFA_OOB_GRANT_TYPE = "http://auth0.com/oauth/grant-type/mfa-oob"
18
+ MFA_OTP_GRANT_TYPE = "http://auth0.com/oauth/grant-type/mfa-otp"
19
+ REFRESH_TOKEN_GRANT_TYPE = "refresh_token"
20
+ TOKEN_EXPIRY_BUFFER_SECONDS = 60
21
+
22
+ DEFAULT_SCOPE = (
23
+ "openid email profile offline_access user_metadata enroll "
24
+ "read:authenticators me:rbac me"
25
+ )
26
+ APK_SCOPE = "openid profile email user_metadata offline_access"
27
+
28
+ APK_USER_AGENT = "Infinity/15.9.0 (Android)"
29
+ PROTOTYPE_USER_AGENT = "SP-Services-Android/15.9.0 okhttp/4.12.0"
30
+
31
+ AUTH0_JSON_HEADERS = {
32
+ "Content-Type": "application/json; charset=utf-8",
33
+ "Accept-Language": "en_US",
34
+ "Auth0-Client": AUTH0_CLIENT_HEADER,
35
+ }
36
+
37
+ AUTH0_GET_JSON_HEADERS = {
38
+ "Content-Type": "application/json",
39
+ }
40
+
41
+ JARVIS_ACCEPT = "application/json, */*"
42
+
43
+ AMI_GROUP_HOURLY = "day"
44
+ AMI_GROUP_WEEKLY = "week"
45
+ AMI_GROUP_DAILY = "month"
46
+ AMI_GROUP_YEAR = "year"
47
+
48
+ AMI_GROUPS = frozenset(
49
+ {AMI_GROUP_HOURLY, AMI_GROUP_WEEKLY, AMI_GROUP_DAILY, AMI_GROUP_YEAR}
50
+ )
51
+ UTILITY_TYPES = frozenset({"electric", "water", "gas"})
@@ -0,0 +1,65 @@
1
+ """Error types for the SP Group mobile SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from .resource import MobileApiError
9
+
10
+
11
+ class SpGroupMobileError(Exception):
12
+ """Base error for SP Group mobile SDK failures."""
13
+
14
+
15
+ class SpGroupTransportError(SpGroupMobileError):
16
+ """Raised when an HTTP request or response parse fails."""
17
+
18
+
19
+ class SpGroupAuthError(SpGroupMobileError):
20
+ """Raised when Auth0 authentication fails."""
21
+
22
+ def __init__(
23
+ self,
24
+ message: str,
25
+ *,
26
+ status: int | None = None,
27
+ body: object | None = None,
28
+ detail: MobileApiError | None = None,
29
+ ) -> None:
30
+ """Initialize an auth error."""
31
+ self.status = status
32
+ self.body = body
33
+ self.detail = detail
34
+ super().__init__(message)
35
+
36
+
37
+ class MfaRequired(SpGroupAuthError):
38
+ """Raised when password-realm login requires MFA completion."""
39
+
40
+ def __init__(self, mfa_token: str, description: str | None = None) -> None:
41
+ """Initialize the MFA challenge error."""
42
+ self.mfa_token = mfa_token
43
+ self.description = description
44
+ message = "MFA required"
45
+ if description:
46
+ message = f"{message}: {description}"
47
+ super().__init__(message)
48
+
49
+
50
+ class SpGroupApiError(SpGroupMobileError):
51
+ """Raised when a SP Group API endpoint returns an error."""
52
+
53
+ def __init__(
54
+ self,
55
+ message: str,
56
+ *,
57
+ status: int | None = None,
58
+ body: object | None = None,
59
+ detail: MobileApiError | None = None,
60
+ ) -> None:
61
+ """Initialize an API error."""
62
+ self.status = status
63
+ self.body = body
64
+ self.detail = detail
65
+ super().__init__(message)