Skip to content

Commit 8b9460e

Browse files
authored
Migrate authentication from Auth0 to SmartTub IDP (#66)
SmartTub moved new accounts to their own IDP endpoint. This change: - Uses new /idp/signin endpoint instead of Auth0 - Parses new token response format - Re-authenticates on token expiry (no refresh endpoint available) - Removes jwt dependency (manually decode ID token for account_id) Fixes home-assistant/core#156317
1 parent f469c3d commit 8b9460e

File tree

2 files changed

+173
-106
lines changed

2 files changed

+173
-106
lines changed

smarttub/api.py

Lines changed: 79 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,105 +1,113 @@
11
import asyncio
2+
import base64
23
import datetime
34
from enum import Enum
5+
import json
46
import logging
5-
import time
67
from typing import List
78

89
import aiohttp
910
import dateutil.parser
1011
from inflection import underscore
11-
import jwt
1212

1313
logger = logging.getLogger(__name__)
1414

1515

1616
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."""
2618

19+
AUTH_URL = "https://api.smarttub.io/idp/signin"
2720
API_BASE = "https://api.smarttub.io"
2821

2922
def __init__(self, session: aiohttp.ClientSession = None):
30-
self.logged_in = False
3123
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.
3535
3636
This method must be called before any useful work can be done.
3737
3838
username -- the email address for the SmartTub account
3939
password -- the password for the SmartTub account
4040
"""
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
6995

7096
@property
7197
def _headers(self):
72-
return {"Authorization": f"Bearer {self.access_token}"}
98+
return {"Authorization": f"Bearer {self._access_token}"}
7399

74100
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:
76103
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")
103111

104112
async def request(self, method, path, body=None):
105113
"""Generic method for making an authenticated request to the API

tests/test_api.py

Lines changed: 94 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import aiohttp
2-
import time
1+
import base64
2+
import datetime
3+
import json
34

4-
import jwt
5+
import aiohttp
56
import pytest
67

78
import smarttub
@@ -11,8 +12,32 @@
1112
pytestmark = pytest.mark.asyncio
1213

1314

15+
def make_id_token(account_id: str) -> str:
16+
"""Create a mock ID token with the account_id claim."""
17+
header = base64.urlsafe_b64encode(json.dumps({"alg": "HS256"}).encode()).rstrip(
18+
b"="
19+
)
20+
payload = base64.urlsafe_b64encode(
21+
json.dumps({"custom:account_id": account_id}).encode()
22+
).rstrip(b"=")
23+
signature = base64.urlsafe_b64encode(b"fakesignature").rstrip(b"=")
24+
return f"{header.decode()}.{payload.decode()}.{signature.decode()}"
25+
26+
27+
def make_login_response(account_id: str, expires_in: int = 86400) -> dict:
28+
"""Create a mock login response."""
29+
return {
30+
"token": {
31+
"access_token": "access_token_123",
32+
"refresh_token": "refresh_token_123",
33+
"id_token": make_id_token(account_id),
34+
"expires_in": expires_in,
35+
}
36+
}
37+
38+
1439
@pytest.fixture(name="unauthenticated_api")
15-
async def unauthenticated_api(aresponses):
40+
async def unauthenticated_api():
1641
async with aiohttp.ClientSession() as session:
1742
yield smarttub.SmartTub(session)
1843

@@ -21,53 +46,81 @@ async def unauthenticated_api(aresponses):
2146
async def api(unauthenticated_api, aresponses):
2247
api = unauthenticated_api
2348
aresponses.add(
24-
response={
25-
"access_token": jwt.encode(
26-
{api.AUTH_ACCOUNT_ID_KEY: ACCOUNT_ID, "exp": time.time() + 3600},
27-
"secret",
28-
),
29-
"token_type": "Bearer",
30-
"refresh_token": "refresh1",
31-
}
49+
response=aresponses.Response(
50+
body=json.dumps(make_login_response(ACCOUNT_ID)),
51+
status=201,
52+
content_type="application/json",
53+
)
3254
)
3355
await api.login("username1", "password1")
3456
return api
3557

3658

37-
async def test_login(api, aresponses):
59+
async def test_login(api):
3860
assert api.account_id == ACCOUNT_ID
39-
assert api.logged_in is True
61+
assert api._access_token == "access_token_123"
62+
assert api._username == "username1"
63+
assert api._password == "password1"
4064

4165

42-
async def test_login_failed(api, aresponses):
43-
aresponses.add(response=aresponses.Response(status=403))
66+
async def test_login_failed_400(unauthenticated_api, aresponses):
67+
aresponses.add(
68+
response=aresponses.Response(
69+
body=json.dumps({"message": "Invalid credentials"}),
70+
status=400,
71+
content_type="application/json",
72+
)
73+
)
4474
with pytest.raises(smarttub.LoginFailed):
45-
await api.login("username", "password")
75+
await unauthenticated_api.login("username", "password")
4676

4777

48-
async def test_refresh_token(api, aresponses):
49-
now = time.time()
50-
api.token_expires_at = now
78+
async def test_login_failed_401(unauthenticated_api, aresponses):
5179
aresponses.add(
52-
response={
53-
"access_token": jwt.encode(
54-
{api.AUTH_ACCOUNT_ID_KEY: ACCOUNT_ID, "exp": now + 3601},
55-
"secret",
56-
),
57-
}
80+
response=aresponses.Response(
81+
body=json.dumps([{"description": "Bad request", "type": "ERROR"}]),
82+
status=401,
83+
content_type="application/json",
84+
)
85+
)
86+
with pytest.raises(smarttub.LoginFailed):
87+
await unauthenticated_api.login("username", "password")
88+
89+
90+
async def test_token_reauth_on_expiry(api, aresponses):
91+
"""Test that we re-authenticate when the token expires."""
92+
# Expire the token
93+
api._token_expires_at = datetime.datetime.now() - datetime.timedelta(seconds=1)
94+
95+
# Mock the re-login response
96+
aresponses.add(
97+
response=aresponses.Response(
98+
body=json.dumps(make_login_response(ACCOUNT_ID)),
99+
status=201,
100+
content_type="application/json",
101+
)
58102
)
59-
aresponses.add(response={"status": "OK"})
103+
# Mock the actual API request
104+
aresponses.add(
105+
response=aresponses.Response(
106+
body=json.dumps({"status": "OK"}),
107+
status=200,
108+
content_type="application/json",
109+
)
110+
)
111+
60112
response = await api.request("GET", "/")
61-
assert api.token_expires_at > now
113+
assert api._token_expires_at > datetime.datetime.now()
62114
assert response.get("status") == "OK"
63115

64116

65117
async def test_get_account(api, aresponses):
66118
aresponses.add(
67-
response={
68-
"id": "id1",
69-
"email": "email1",
70-
}
119+
response=aresponses.Response(
120+
body=json.dumps({"id": "id1", "email": "email1"}),
121+
status=200,
122+
content_type="application/json",
123+
)
71124
)
72125

73126
account = await api.get_account()
@@ -81,12 +134,18 @@ async def test_api_error(api, aresponses):
81134
await api.get_account()
82135

83136

84-
async def test_not_logged_in(unauthenticated_api, aresponses):
137+
async def test_not_logged_in(unauthenticated_api):
85138
with pytest.raises(RuntimeError):
86139
await unauthenticated_api.request("GET", "/")
87140

88141

89-
async def test_request(api, aresponses):
90-
aresponses.add(response=aresponses.Response(text=None, status=200))
142+
async def test_request_empty_response(api, aresponses):
143+
aresponses.add(
144+
response=aresponses.Response(
145+
body="",
146+
status=200,
147+
headers={"content-length": "0"},
148+
)
149+
)
91150
response = await api.request("GET", "/")
92151
assert response is None

0 commit comments

Comments
 (0)