|
1 | 1 | import asyncio |
| 2 | +import base64 |
2 | 3 | import datetime |
3 | 4 | from enum import Enum |
| 5 | +import json |
4 | 6 | import logging |
5 | | -import time |
6 | 7 | from typing import List |
7 | 8 |
|
8 | 9 | import aiohttp |
9 | 10 | import dateutil.parser |
10 | 11 | from inflection import underscore |
11 | | -import jwt |
12 | 12 |
|
13 | 13 | logger = logging.getLogger(__name__) |
14 | 14 |
|
15 | 15 |
|
16 | 16 | class SmartTub: |
17 | | - """Interface to the SmartTub API""" |
18 | | - |
19 | | - AUTH_AUDIENCE = "https://api.operation-link.com/" |
20 | | - AUTH_URL = "https://smarttub.auth0.com/oauth/token" |
21 | | - AUTH_CLIENT_ID = "dB7Rcp3rfKKh0vHw2uqkwOZmRb5WNjQC" |
22 | | - AUTH_REALM = "Username-Password-Authentication" |
23 | | - AUTH_ACCOUNT_ID_KEY = "http://operation-link.com/account_id" |
24 | | - AUTH_GRANT_TYPE = "http://auth0.com/oauth/grant-type/password-realm" |
25 | | - AUTH_SCOPE = "openid email offline_access User Admin" |
| 17 | + """Interface to the SmartTub API.""" |
26 | 18 |
|
| 19 | + AUTH_URL = "https://api.smarttub.io/idp/signin" |
27 | 20 | API_BASE = "https://api.smarttub.io" |
28 | 21 |
|
29 | 22 | def __init__(self, session: aiohttp.ClientSession = None): |
30 | | - self.logged_in = False |
31 | 23 | self._session = session or aiohttp.ClientSession() |
32 | | - |
33 | | - async def login(self, username: str, password: str): |
34 | | - """Authenticate to SmartTub |
| 24 | + self._access_token: str | None = None |
| 25 | + self._refresh_token: str | None = None |
| 26 | + self._id_token: str | None = None |
| 27 | + self._token_expires_at: datetime.datetime | None = None |
| 28 | + self.account_id: str | None = None |
| 29 | + # Store credentials for re-authentication (no refresh endpoint available) |
| 30 | + self._username: str | None = None |
| 31 | + self._password: str | None = None |
| 32 | + |
| 33 | + async def login(self, username: str, password: str) -> None: |
| 34 | + """Authenticate to SmartTub. |
35 | 35 |
|
36 | 36 | This method must be called before any useful work can be done. |
37 | 37 |
|
38 | 38 | username -- the email address for the SmartTub account |
39 | 39 | password -- the password for the SmartTub account |
40 | 40 | """ |
41 | | - |
42 | | - # https://auth0.com/docs/api-auth/tutorials/password-grant |
43 | | - r = await self._session.post( |
44 | | - self.AUTH_URL, |
45 | | - json={ |
46 | | - "audience": self.AUTH_AUDIENCE, |
47 | | - "client_id": self.AUTH_CLIENT_ID, |
48 | | - "grant_type": self.AUTH_GRANT_TYPE, |
49 | | - "realm": self.AUTH_REALM, |
50 | | - "scope": self.AUTH_SCOPE, |
51 | | - "username": username, |
52 | | - "password": password, |
53 | | - }, |
54 | | - ) |
55 | | - if r.status == 403: |
56 | | - raise LoginFailed(r.text) |
57 | | - |
58 | | - r.raise_for_status() |
59 | | - j = await r.json() |
60 | | - |
61 | | - self._set_access_token(j["access_token"]) |
62 | | - self.refresh_token = j["refresh_token"] |
63 | | - assert j["token_type"] == "Bearer" |
64 | | - |
65 | | - self.account_id = self.access_token_data[self.AUTH_ACCOUNT_ID_KEY] |
66 | | - self.logged_in = True |
67 | | - |
68 | | - logger.debug(f"login successful, username={username}") |
| 41 | + headers = { |
| 42 | + "Content-Type": "application/json", |
| 43 | + "Accept": "application/json", |
| 44 | + } |
| 45 | + body = {"username": username, "password": password} |
| 46 | + |
| 47 | + async with self._session.post( |
| 48 | + self.AUTH_URL, json=body, headers=headers |
| 49 | + ) as response: |
| 50 | + try: |
| 51 | + data = await response.json() |
| 52 | + except Exception: |
| 53 | + text = await response.text() |
| 54 | + raise LoginFailed(f"Login failed: {response.status} - {text}") |
| 55 | + |
| 56 | + if response.status != 201: |
| 57 | + if isinstance(data, list): |
| 58 | + error_msg = ", ".join(str(x) for x in data) |
| 59 | + else: |
| 60 | + error_msg = data.get("message", "Unknown error") |
| 61 | + raise LoginFailed(f"Login failed ({response.status}): {error_msg}") |
| 62 | + |
| 63 | + try: |
| 64 | + token_data = data["token"] |
| 65 | + self._access_token = token_data["access_token"] |
| 66 | + self._refresh_token = token_data.get("refresh_token") |
| 67 | + self._id_token = token_data.get("id_token") |
| 68 | + |
| 69 | + # Extract account_id from ID token |
| 70 | + if self._id_token: |
| 71 | + parts = self._id_token.split(".") |
| 72 | + if len(parts) > 1: |
| 73 | + payload_b64 = parts[1] |
| 74 | + # Fix Base64 padding |
| 75 | + padded = payload_b64 + "=" * (-len(payload_b64) % 4) |
| 76 | + decoded_bytes = base64.b64decode(padded) |
| 77 | + jwt_data = json.loads(decoded_bytes) |
| 78 | + self.account_id = jwt_data.get("custom:account_id") |
| 79 | + |
| 80 | + expires_in = token_data.get("expires_in", 86400) |
| 81 | + self._token_expires_at = datetime.datetime.now() + datetime.timedelta( |
| 82 | + seconds=expires_in |
| 83 | + ) |
| 84 | + |
| 85 | + # Store credentials for re-authentication when token expires |
| 86 | + self._username = username |
| 87 | + self._password = password |
| 88 | + |
| 89 | + logger.debug(f"login successful, username={username}") |
| 90 | + |
| 91 | + except KeyError as exc: |
| 92 | + raise LoginFailed( |
| 93 | + "Login successful but response format was unexpected" |
| 94 | + ) from exc |
69 | 95 |
|
70 | 96 | @property |
71 | 97 | def _headers(self): |
72 | | - return {"Authorization": f"Bearer {self.access_token}"} |
| 98 | + return {"Authorization": f"Bearer {self._access_token}"} |
73 | 99 |
|
74 | 100 | async def _require_login(self): |
75 | | - if not self.logged_in: |
| 101 | + """Ensure we have a valid access token, re-authenticating if needed.""" |
| 102 | + if not self._access_token: |
76 | 103 | raise RuntimeError("not logged in") |
77 | | - if self.token_expires_at <= time.time(): |
78 | | - await self._refresh_token() |
79 | | - |
80 | | - def _set_access_token(self, token): |
81 | | - self.access_token = token |
82 | | - self.access_token_data = jwt.decode( |
83 | | - self.access_token, |
84 | | - algorithms=["HS256"], |
85 | | - options={"verify_signature": False, "verify": False}, |
86 | | - ) |
87 | | - self.token_expires_at = self.access_token_data["exp"] |
88 | | - |
89 | | - async def _refresh_token(self): |
90 | | - # https://auth0.com/docs/tokens/guides/use-refresh-tokens |
91 | | - r = await self._session.post( |
92 | | - self.AUTH_URL, |
93 | | - json={ |
94 | | - "grant_type": "refresh_token", |
95 | | - "client_id": self.AUTH_CLIENT_ID, |
96 | | - "refresh_token": self.refresh_token, |
97 | | - }, |
98 | | - ) |
99 | | - r.raise_for_status() |
100 | | - j = await r.json() |
101 | | - self._set_access_token(j["access_token"]) |
102 | | - logger.debug("token refresh successful") |
| 104 | + if self._token_expires_at and datetime.datetime.now() > self._token_expires_at: |
| 105 | + # Token expired - re-authenticate using stored credentials |
| 106 | + if self._username and self._password: |
| 107 | + logger.debug("token expired, re-authenticating") |
| 108 | + await self.login(self._username, self._password) |
| 109 | + else: |
| 110 | + raise RuntimeError("token expired and no credentials available") |
103 | 111 |
|
104 | 112 | async def request(self, method, path, body=None): |
105 | 113 | """Generic method for making an authenticated request to the API |
|
0 commit comments