Skip to content

Commit e88ec08

Browse files
committed
Added #65, fixed #68 and added #71
1 parent 08e8cd3 commit e88ec08

File tree

2 files changed

+61
-18
lines changed

2 files changed

+61
-18
lines changed

README.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ TeslaPy 2.0.0+ no longer implements headless authentication. The constructor dif
3333
| `cache_loader` | (optional) function that returns the cache dict |
3434
| `cache_dumper` | (optional) function with one argument, the cache dict |
3535
| `sso_base_url` | (optional) URL of SSO service, set to `https://auth.tesla.cn/` if your email is registered in another region |
36+
| `state` | (optional) state string for CSRF protection |
37+
| `code_verifier` | (optional) PKCE code verifier string |
38+
| `app_user_agent` | (optional) X-Tesla-User-Agent string |
3639

3740
TeslaPy 2.1.0+ no longer implements [RFC 7523](https://tools.ietf.org/html/rfc7523) and uses the SSO token for all API requests.
3841

@@ -43,9 +46,11 @@ The convenience method `api()` uses named endpoints listed in *endpoints.json* t
4346
| Call | Description |
4447
| --- | --- |
4548
| `request()` | performs API call using relative or absolute URL, serialization and error message handling |
46-
| `authorization_url()` | forms authorization URL with [PKCE](https://oauth.net/2/pkce/) extension |
49+
| `new_code_verifier()` | generates code verifier for [PKCE](https://oauth.net/2/pkce/) |
50+
| `authorization_url()` | forms authorization URL with [PKCE](https://oauth.net/2/pkce/) extension and tries to detect the accounts registered region |
4751
| `fetch_token()` | requests an SSO token using Authorization Code grant with [PKCE](https://oauth.net/2/pkce/) extension |
4852
| `refresh_token()` | requests an SSO token using [Refresh Token](https://oauth.net/2/grant-types/refresh-token/) grant |
53+
| `close()` | remove all requests adapter instances |
4954
| `logout()` | removes token from cache, returns logout URL and optionally signs out using system's default web browser |
5055
| `vehicle_list()` | returns a list of Vehicle objects |
5156
| `battery_list()` | returns a list of Battery objects |
@@ -189,6 +194,29 @@ print(vehicles[0])
189194
tesla.close()
190195
```
191196

197+
#### Alternative staged
198+
199+
Support for staged authorization has been added to TeslaPy 2.5.0. The keyword arguments `state` and `code_verifier` are accepted by the `Tesla` class constructor, the `authorization_url()` method and the `fetch_token()` method.
200+
201+
```python
202+
import teslapy
203+
# First stage
204+
tesla = teslapy.Tesla('[email protected]')
205+
if not tesla.authorized:
206+
state = tesla.new_state()
207+
code_verifier = tesla.new_code_verifier()
208+
print('Use browser to login. Page Not Found will be shown at success.')
209+
print('Open: ' + tesla.authorization_url(state=state, code_verifier=code_verifier))
210+
tesla.close()
211+
# Second stage
212+
tesla = teslapy.Tesla('[email protected]', state=state, code_verifier=code_verifier)
213+
if not tesla.authorized:
214+
tesla.fetch_token(authorization_response=input('Enter URL after authentication: '))
215+
vehicles = tesla.vehicle_list()
216+
print(vehicles[0])
217+
tesla.close()
218+
```
219+
192220
#### 3rd party authentication apps
193221

194222
TeslaPy 2.4.0+ supports usage of a refresh token obtained by 3rd party [authentication apps](https://teslascope.com/help/generating-tokens). The refresh token is used to obtain an access token and both are cached for persistence, so you only need to supply the refresh token only once.

teslapy/__init__.py

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
SSO_BASE_URL = 'https://auth.tesla.com/'
3535
SSO_CLIENT_ID = 'ownerapi'
3636
STREAMING_BASE_URL = 'wss://streaming.vn.teslamotors.com/'
37+
APP_USER_AGENT = 'TeslaApp/4.7.0'
3738

3839
# Setup module logging
3940
logger = logging.getLogger(__name__)
@@ -62,13 +63,18 @@ class Tesla(OAuth2Session):
6263
cache_dumper: (optional) Function with one argument, the cache dict.
6364
sso_base_url: (optional) URL of SSO service, set to `https://auth.tesla.cn/`
6465
if your email is registered in another region.
65-
kwargs: (optional) Extra arguments for the Session constructor.
66+
code_verifier (optional): PKCE code verifier string.
67+
app_user_agent (optional): X-Tesla-User-Agent string.
68+
69+
Extra keyword arguments to pass to OAuth2Session constructor using `kwargs`:
70+
state (optional): A state string for CSRF protection.
6671
"""
6772

6873
def __init__(self, email, verify=True, proxy=None, retry=0, timeout=10,
6974
user_agent=__name__ + '/' + __version__, authenticator=None,
7075
cache_file='cache.json', cache_loader=None, cache_dumper=None,
71-
sso_base_url=None, **kwargs):
76+
sso_base_url=None, code_verifier=None,
77+
app_user_agent=APP_USER_AGENT, **kwargs):
7278
super(Tesla, self).__init__(client_id=SSO_CLIENT_ID, **kwargs)
7379
if not email:
7480
raise ValueError('`email` is not set')
@@ -81,7 +87,7 @@ def __init__(self, email, verify=True, proxy=None, retry=0, timeout=10,
8187
self.endpoints = {}
8288
self.sso_base_url = sso_base_url or SSO_BASE_URL
8389
self._auto_refresh_url = None
84-
self.code_verifier = None
90+
self.code_verifier = code_verifier
8591
# Set OAuth2Session properties
8692
self.scope = ('openid', 'email', 'offline_access')
8793
self.redirect_uri = SSO_BASE_URL + 'void/callback'
@@ -90,7 +96,7 @@ def __init__(self, email, verify=True, proxy=None, retry=0, timeout=10,
9096
self.token_updater = self._token_updater
9197
self.mount('https://', requests.adapters.HTTPAdapter(max_retries=retry))
9298
self.headers.update({'Content-Type': 'application/json',
93-
'X-Tesla-User-Agent': 'TeslaApp/4.5.0',
99+
'X-Tesla-User-Agent': app_user_agent,
94100
'User-Agent': user_agent})
95101
self.verify = verify
96102
if proxy:
@@ -152,30 +158,41 @@ def request(self, method, url, serialize=True, **kwargs):
152158
return response.json(object_hook=JsonDict)
153159
return response.text
154160

155-
def authorization_url(self, url='oauth2/v3/authorize', **kwargs):
161+
@staticmethod
162+
def new_code_verifier():
163+
""" Generate code verifier for PKCE as per RFC 7636 section 4.1 """
164+
result = base64.urlsafe_b64encode(os.urandom(32)).rstrip(b'=')
165+
logger.debug('Generated new code verifier %s.',
166+
result.decode() if isinstance(result, bytes) else result)
167+
return result
168+
169+
def authorization_url(self, url='oauth2/v3/authorize',
170+
code_verifier=None, **kwargs):
156171
""" Overriddes base method to form an authorization URL with PKCE
157172
extension for Tesla's SSO service.
158173
159174
url (optional): Authorization endpoint url.
175+
code_verifier (optional): PKCE code verifier string.
160176
161177
Extra keyword arguments to pass to base method using `kwargs`:
162178
state (optional): A state string for CSRF protection.
163179
164-
Return type: String
180+
Return type: String or None
165181
"""
166182
if self.authorized:
167183
return None
168184
# Generate code verifier and challenge for PKCE (RFC 7636)
169-
self.code_verifier = base64.urlsafe_b64encode(os.urandom(32)).rstrip(b'=')
185+
self.code_verifier = code_verifier or self.new_code_verifier()
170186
unencoded_digest = hashlib.sha256(self.code_verifier).digest()
171187
code_challenge = base64.urlsafe_b64encode(unencoded_digest).rstrip(b'=')
172188
# Prepare for OAuth 2 Authorization Code Grant flow
173189
url = urljoin(self.sso_base_url, url)
174190
kwargs['code_challenge'] = code_challenge
175191
kwargs['code_challenge_method'] = 'S256'
176-
without_hint = super(Tesla, self).authorization_url(url, **kwargs)[0]
192+
without_hint, state = super(Tesla, self).authorization_url(url, **kwargs)
177193
# Detect account's registered region
178194
kwargs['login_hint'] = self.email
195+
kwargs['state'] = state
179196
with_hint = super(Tesla, self).authorization_url(url, **kwargs)[0]
180197
response = self.get(with_hint, allow_redirects=False)
181198
if response.is_redirect:
@@ -192,6 +209,7 @@ def fetch_token(self, token_url='oauth2/v3/token', **kwargs):
192209
193210
Extra keyword arguments to pass to base method using `kwargs`:
194211
authorization_response (optional): Authorization response URL.
212+
code_verifier (optional): Code verifier cryptographic random string.
195213
196214
Return type: dict
197215
"""
@@ -240,7 +258,7 @@ def logout(self, sign_out=False):
240258
241259
sign_out (optional): sign out using system's default web browser.
242260
243-
Return type: String
261+
Return type: String or None
244262
"""
245263
if not self.authorized:
246264
return None
@@ -679,7 +697,7 @@ def api(self, name, **kwargs):
679697
return self.tesla.api(name, pathvars, **kwargs)
680698

681699
def get_calendar_history_data(
682-
self, kind='savings', period='day', start_date=None,
700+
self, kind='energy', period='day', start_date=None,
683701
end_date=time.strftime('%Y-%m-%dT%H:%M:%S.000Z'),
684702
installation_timezone=None, timezone=None, tariff=None):
685703
""" Retrieve live status of product
@@ -697,14 +715,13 @@ def get_calendar_history_data(
697715
"""
698716
return self.api('CALENDAR_HISTORY_DATA', kind=kind, period=period,
699717
start_date=start_date, end_date=end_date,
700-
timezone=timezone,
701718
installation_timezone=installation_timezone,
702-
tariff=tariff)['response']
719+
timezone=timezone, tariff=tariff)['response']
703720

704721
def get_history_data(
705-
self, kind='savings', period='day', start_date=None,
722+
self, kind='energy', period='day', start_date=None,
706723
end_date=time.strftime('%Y-%m-%dT%H:%M:%S.000Z'),
707-
installation_timezone=None, timezone=None, tariff=None):
724+
installation_timezone=None, timezone=None):
708725
""" Retrieve live status of product
709726
kind: A telemetry type of 'backup', 'energy', 'power',
710727
'self_consumption', 'time_of_use_energy', and
@@ -716,13 +733,11 @@ def get_history_data(
716733
start_date: The state date in the data requested in the json format
717734
'2021-02-27T07:59:59.999Z'
718735
installation_timezone: Timezone of installation location for 'savings'
719-
tariff: Unclear format use in 'savings' only
720736
"""
721737
return self.api('HISTORY_DATA', kind=kind, period=period,
722738
start_date=start_date, end_date=end_date,
723-
timezone=timezone,
724739
installation_timezone=installation_timezone,
725-
tariff=tariff)['response']
740+
timezone=timezone)['response']
726741

727742
def command(self, name, **kwargs):
728743
""" Wrapper method for product command response error handling """

0 commit comments

Comments
 (0)