Skip to content

Commit 354924c

Browse files
authored
Automatic refresh token (#7)
* Automatic refresh token * Code and tests in place * Updated documentation * More doc changes
1 parent 5da9206 commit 354924c

7 files changed

Lines changed: 250 additions & 165 deletions

File tree

README.md

Lines changed: 41 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,14 @@ import asyncio
7070

7171
from aiohttp import ClientSession
7272

73-
from simplipy import get_systems
73+
from simplipy import API
7474

7575

7676
async def main() -> None:
7777
"""Create the aiohttp session and run."""
7878
async with ClientSession() as websession:
79-
systems = await get_systems("<EMAIL>", "<PASSWORD>", websession)
79+
simplisafe = API.login_via_credentials("<EMAIL>", "<PASSWORD>", websession)
80+
systems = await simplisafe.get_systems()
8081
# >>> [<simplipy.system.SystemV2 object at 0x10661e3c8>, ...]
8182

8283

@@ -97,19 +98,20 @@ these objects, meaning the same properties and methods are available to both.
9798
### Properties and Methods
9899

99100
```python
100-
from simplipy import get_systems
101+
from simplipy import API
101102

102103

103104
async def main() -> None:
104105
"""Create the aiohttp session and run."""
105106
async with ClientSession() as websession:
106-
systems = await get_systems("<EMAIL>", "<PASSWORD>", websession)
107+
simplisafe = API.login_via_credentials("<EMAIL>", "<PASSWORD>", websession)
108+
systems = await simplisafe.get_systems()
107109
# >>> [<simplipy.system.SystemV2 object at 0x10661e3c8>]
108110

109111
for system in systems:
110-
# Return a reference to a SimpliSafe™ account object (detailed later):
111-
system.account
112-
# >>> <simplipy.account.SimpliSafe™ object at 0x12aba2321>
112+
# Return a reference to a SimpliSafe™ API object (detailed later):
113+
system.api
114+
# >>> <simplipy.api.API object at 0x12aba2321>
113115

114116
# Return whether the alarm is currently going off:
115117
system.alarm_going_off
@@ -191,13 +193,14 @@ differences are outlined below.
191193
### Base Properties
192194

193195
```python
194-
from simplipy import get_systems
196+
from simplipy import API
195197

196198

197199
async def main() -> None:
198200
"""Create the aiohttp session and run."""
199201
async with ClientSession() as websession:
200-
systems = await get_systems("<EMAIL>", "<PASSWORD>", websession)
202+
simplisafe = API.login_via_credentials("<EMAIL>", "<PASSWORD>", websession)
203+
systems = await simplisafe.get_systems()
201204
for system in systems:
202205
for serial, sensor_attrs in system.sensors.items():
203206
# Return the sensor's name:
@@ -234,13 +237,14 @@ asyncio.get_event_loop().run_until_complete(main())
234237
### V2 Properties
235238

236239
```python
237-
from simplipy import get_systems
240+
from simplipy import API
238241

239242

240243
async def main() -> None:
241244
"""Create the aiohttp session and run."""
242245
async with ClientSession() as websession:
243-
systems = await get_systems("<EMAIL>", "<PASSWORD>", websession)
246+
simplisafe = API.login_via_credentials("<EMAIL>", "<PASSWORD>", websession)
247+
systems = await simplisafe.get_systems()
244248
for system in systems:
245249
for serial, sensor_attrs in system.sensors.items():
246250
# Return the sensor's data as a currently non-understood integer:
@@ -258,13 +262,14 @@ asyncio.get_event_loop().run_until_complete(main())
258262
### V3 Properties
259263

260264
```python
261-
from simplipy import get_systems
265+
from simplipy import API
262266

263267

264268
async def main() -> None:
265269
"""Create the aiohttp session and run."""
266270
async with ClientSession() as websession:
267-
systems = await get_systems("<EMAIL>", "<PASSWORD>", websession)
271+
simplisafe = API.login_via_credentials("<EMAIL>", "<PASSWORD>", websession)
272+
systems = await simplisafe.get_systems()
268273
for system in systems:
269274
for sensor in system.sensors:
270275
# Return whether the sensor is offline:
@@ -283,37 +288,37 @@ async def main() -> None:
283288
asyncio.get_event_loop().run_until_complete(main())
284289
```
285290

286-
## The `Account` Object
291+
## The `API` Object
287292

288-
Each `System` object has a reference to an `Account` object. This object
289-
contains properties and a method useful for authentication and ongoing
290-
access.
293+
Each `System` object has a reference to an `API` object. This object contains
294+
properties and a method useful for authentication and ongoing access.
291295

292-
**VERY IMPORTANT NOTE:** the `Account` object contains references to
296+
**VERY IMPORTANT NOTE:** the `API` object contains references to
293297
SimpliSafe™ access and refresh tokens. **It is vitally important that you do
294298
not let these tokens leave your control.** If exposed, savvy attackers could
295299
use them to view and alter your system's state. **You have been warned; proper
296300
usage of these properties is solely your responsibility.**
297301

298302
```python
299-
from simplipy import get_systems
303+
from simplipy import API
300304

301305

302306
async def main() -> None:
303307
"""Create the aiohttp session and run."""
304308
async with ClientSession() as websession:
305-
systems = await get_systems("<EMAIL>", "<PASSWORD>", websession)
309+
simplisafe = API.login_via_credentials("<EMAIL>", "<PASSWORD>", websession)
310+
systems = await simplisafe.get_systems()
306311
for system in systems:
307312
# Return the current access token:
308-
system.account.access_token
313+
system.api._access_token
309314
# >>> 7s9yasdh9aeu21211add
310315

311316
# Return the current refresh token:
312-
system.account.refresh_token
317+
system.api.refresh_token
313318
# >>> 896sad86gudas87d6asd
314319

315320
# Return the SimpliSafe™ user ID associated with this account:
316-
system.account.user_id
321+
system.api.user_id
317322
# >>> 1234567
318323

319324

@@ -328,40 +333,34 @@ asyncio.get_event_loop().run_until_complete(main())
328333
errors inherit from
329334
* `simplipy.errors.RequestError`: an error related to HTTP requests that return
330335
something other than a `200` response code
331-
* `simplipy.errors.TokenExpiredError`: an error related to an expired access
332-
token
333336

334337
# Refreshing the Access Token
335338

336-
When `simplipy.get_systems()` is run, everything is set to make repeated
337-
authorized requests against the SimpliSafe™ cloud. At some point, however, the
338-
access token will expire and any future requests will raise
339-
`simplipy.errors.TokenExpiredError`.
340-
341-
When this occurs, a new access token can easily be generated:
339+
It may be desirable to re-authenticate to the SimpliSafe™ API without using
340+
a user's email and password again. In that case, it is recommended that you
341+
save the `refresh_token` property somewhere; when it comes time to
342+
re-authenticate, simply:
342343

343344
```python
344-
await system.account.refresh_access_token()
345-
```
345+
from simplipy import API
346346

347-
This will use the "on-file" refresh token to request a new access token; once
348-
the call is complete, you're good to go.
349347

350-
In some instances, it may be desirable to store the "on-file" refresh token for
351-
later use (for example, if your app/script/etc. stops and needs to restart at
352-
some indeterminate point in the future). In that case, the
353-
`refresh_access_token()` method can take an optional `refresh_token` parameter:
348+
async def main() -> None:
349+
"""Create the aiohttp session and run."""
350+
async with ClientSession() as websession:
351+
simplisafe = API.login_via_token("<REFRESH TOKEN>", websession)
352+
systems = await simplisafe.get_systems()
353+
354354

355-
```python
356-
await system.account.refresh_access_token(refresh_token='abcdefg987665')
355+
asyncio.get_event_loop().run_until_complete(main())
357356
```
358357

359358
Although no official documentation exists, basic testing appears to confirm the
360359
hypothesis that the refresh token is both long-lived and single-use. This means
361360
that theoretically, it should be possible to use it to create an access token
362361
long into the future. If `refresh_access_token()` should throw an error,
363362
however, the system object(s) will need to be recreated via
364-
`simplipy.get_systems`.
363+
`simplipy.API.login_via_credentials`.
365364

366365
# Contributing
367366

simplipy/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
"""Define module-level imports."""
2-
from .account import get_systems # noqa
2+
from .api import API # noqa
Lines changed: 58 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
"""Define a SimpliSafe account."""
2-
# pylint: disable=import-error, unused-import
2+
# pylint: disable=import-error,protected-access,unused-import
3+
34
from datetime import datetime, timedelta
4-
from typing import List, Union # noqa
5+
from typing import List, Type, TypeVar, Union # noqa
56

67
from aiohttp import BasicAuth, ClientSession, client_exceptions
78

8-
from .errors import RequestError, TokenExpiredError
9+
from .errors import RequestError
910
from .system import System, SystemV2, SystemV3 # noqa
1011

1112
DEFAULT_USER_AGENT = 'SimpliSafe/2105 CFNetwork/902.2 Darwin/17.7.0'
@@ -17,28 +18,47 @@
1718

1819
SYSTEM_MAP = {2: SystemV2, 3: SystemV3}
1920

20-
21-
# pylint: disable=protected-access
22-
async def get_systems(
23-
email: str, password: str, websession: ClientSession) -> list:
24-
"""Return a list of systems."""
25-
account = SimpliSafe(websession)
26-
await account._login(email, password)
27-
return await account._get_subscriptions()
21+
ApiType = TypeVar('ApiType', bound='API')
2822

2923

30-
class SimpliSafe:
31-
"""Define an "account" client."""
24+
class API:
25+
"""Define an API object to interact with the SimpliSafe cloud."""
3226

3327
def __init__(self, websession: ClientSession) -> None:
3428
"""Initialize."""
29+
self._access_token = None
3530
self._access_token_expire = None # type: Union[None, datetime]
31+
self._actively_refreshing = False
3632
self._email = None # type: Union[None, str]
3733
self._websession = websession
38-
self.access_token = None
3934
self.refresh_token = None
4035
self.user_id = None
4136

37+
@classmethod
38+
async def login_via_credentials(
39+
cls: Type[ApiType], email: str, password: str,
40+
websession: ClientSession) -> ApiType:
41+
"""Create an API object from a email address and password."""
42+
klass = cls(websession)
43+
klass._email = email
44+
45+
await klass._authenticate({
46+
'grant_type': 'password',
47+
'username': email,
48+
'password': password,
49+
})
50+
51+
return klass
52+
53+
@classmethod
54+
async def login_via_token(
55+
cls: Type[ApiType], refresh_token: str,
56+
websession: ClientSession) -> ApiType:
57+
"""Create an API object from a refresh token."""
58+
klass = cls(websession)
59+
await klass._refresh_access_token(refresh_token)
60+
return klass
61+
4262
async def _authenticate(self, payload_data: dict) -> None:
4363
"""Request token data and parse it."""
4464
token_resp = await self.request(
@@ -47,13 +67,26 @@ async def _authenticate(self, payload_data: dict) -> None:
4767
data=payload_data,
4868
auth=BasicAuth(
4969
login=DEFAULT_AUTH_USERNAME, password='', encoding='latin1'))
50-
self.access_token = token_resp['access_token']
70+
self.refresh_token = token_resp['refresh_token']
71+
72+
auth_check_resp = await self.request('get', 'api/authCheck')
73+
self.user_id = auth_check_resp['userId']
74+
self._access_token = token_resp['access_token']
5175
self._access_token_expire = datetime.now() + timedelta(
5276
seconds=int(token_resp['expires_in']))
53-
self.refresh_token = token_resp['refresh_token']
5477

55-
async def _get_subscriptions(self) -> list:
56-
"""Get subscriptions associated to this account."""
78+
async def _refresh_access_token(self, refresh_token: str) -> None:
79+
"""Regenerate an access token."""
80+
await self._authenticate({
81+
'grant_type': 'refresh_token',
82+
'username': self._email,
83+
'refresh_token': refresh_token,
84+
})
85+
86+
self._actively_refreshing = False
87+
88+
async def get_systems(self) -> list:
89+
"""Get systems associated to this account."""
5790
subscription_resp = await self.get_subscription_data()
5891

5992
systems = [] # type: List[System]
@@ -66,37 +99,13 @@ async def _get_subscriptions(self) -> list:
6699

67100
return systems
68101

69-
async def _login(self, email: str, password: str) -> None:
70-
"""Login to SimpliSafe."""
71-
self._email = email
72-
73-
await self._authenticate({
74-
'grant_type': 'password',
75-
'username': email,
76-
'password': password,
77-
})
78-
79-
auth_check_resp = await self.request('get', 'api/authCheck')
80-
self.user_id = auth_check_resp['userId']
81-
82102
async def get_subscription_data(self) -> dict:
83103
"""Get the latest location-level data."""
84104
return await self.request(
85105
'get',
86106
'users/{0}/subscriptions'.format(self.user_id),
87107
params={'activeOnly': 'true'})
88108

89-
async def refresh_access_token(self, refresh_token: str = None) -> None:
90-
"""Regenerate an access token using the stored refresh token."""
91-
await self._authenticate({
92-
'grant_type':
93-
'refresh_token',
94-
'username':
95-
self._email,
96-
'refresh_token':
97-
refresh_token if refresh_token else self.refresh_token,
98-
})
99-
100109
async def request(
101110
self,
102111
method: str,
@@ -109,15 +118,18 @@ async def request(
109118
**kwargs) -> dict:
110119
"""Make a request."""
111120
if (self._access_token_expire
112-
and datetime.now() >= self._access_token_expire):
113-
raise TokenExpiredError('The access token has expired')
121+
and datetime.now() >= self._access_token_expire
122+
and not self._actively_refreshing):
123+
self._actively_refreshing = True
124+
await self._refresh_access_token( # type: ignore
125+
self.refresh_token)
114126

115127
url = '{0}/{1}'.format(URL_BASE, endpoint)
116128

117129
if not headers:
118130
headers = {}
119-
if not kwargs.get('auth') and self.access_token:
120-
headers['Authorization'] = 'Bearer {0}'.format(self.access_token)
131+
if not kwargs.get('auth') and self._access_token:
132+
headers['Authorization'] = 'Bearer {0}'.format(self._access_token)
121133
headers.update({
122134
'Content-Type': 'application/x-www-form-urlencoded',
123135
'Host': URL_HOSTNAME,

simplipy/errors.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,3 @@ class RequestError(SimplipyError):
1111
"""Define an error related to invalid requests."""
1212

1313
pass
14-
15-
16-
class TokenExpiredError(SimplipyError):
17-
"""Define an error for expired access tokens."""
18-
19-
pass

0 commit comments

Comments
 (0)