Skip to content

Commit

Permalink
Added fix for #60 login_hint
Browse files Browse the repository at this point in the history
  • Loading branch information
tdorssers committed Mar 6, 2022
1 parent b6d813e commit 33db201
Show file tree
Hide file tree
Showing 5 changed files with 55 additions and 31 deletions.
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ TeslaPy 2.0.0+ no longer implements headless authentication. The constructor dif
| `cache_file` | (optional) path to cache file used by default loader and dumper |
| `cache_loader` | (optional) function that returns the cache dict |
| `cache_dumper` | (optional) function with one argument, the cache dict |
| `sso_base_url` | (optional) URL of SSO service, set to `https://auth.tesla.cn/` if your email is registered in another region |

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

Expand Down Expand Up @@ -340,6 +341,8 @@ As of September 3, 2021, Tesla has added ReCaptcha to the login form. This cause

As of January 12, 2022, Tesla has deprecated the use of [RFC 7523](https://tools.ietf.org/html/rfc7523) tokens and requires the SSO tokens to be used for API access. If you get a `requests.exceptions.HTTPError: 401 Client Error: Unauthorized for url: https://owner-api.teslamotors.com/api/1/vehicles` and you are using correct credentials then you are probably using an old version of this module.

As of March 1, 2022, Tesla no longer supports the login_hint parameter in the V3 authorization URL. If you get a `requests.exceptions.HTTPError: 500 Server Error: Internal Server Error for url: https://auth.tesla.com/oauth2/v3/authorize` then you are probably using an old version of this module.

## Demo applications

The source repository contains three demo applications that *optionally* use [pywebview](https://pypi.org/project/pywebview/) version 3.0 or higher or [selenium](https://pypi.org/project/selenium/) version 3.13.0 or higher to automate weblogin. Selenium 4.0.0 or higher is required for Edge Chromium.
Expand All @@ -348,9 +351,9 @@ The source repository contains three demo applications that *optionally* use [py

```
usage: cli.py [-h] -e EMAIL [-f FILTER] [-a API [KEYVALUE ...]] [-k KEYVALUE]
[-c COMMAND] [-t TIMEOUT] [-p PROXY] [-R REFRESH] [-l] [-o] [-v]
[-w] [-g] [-b] [-n] [-m] [-s] [-d] [-r] [-S] [-H] [-V] [-L] [-u]
[--chrome] [--edge] [--firefox] [--opera] [--safari]
[-c COMMAND] [-t TIMEOUT] [-p PROXY] [-R REFRESH] [-U URL] [-l]
[-o] [-v] [-w] [-g] [-b] [-n] [-m] [-s] [-d] [-r] [-S] [-H] [-V]
[-L] [-u] [--chrome] [--edge] [--firefox] [--opera] [--safari]
Tesla Owner API CLI
Expand All @@ -365,6 +368,7 @@ optional arguments:
-t TIMEOUT connect/read timeout
-p PROXY proxy server URL
-R REFRESH use this refresh token
-U URL SSO service base URL
-l, --list list all selected vehicles/batteries
-o, --option list vehicle option codes
-v, --vin vehicle identification number decode
Expand Down
4 changes: 3 additions & 1 deletion cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ def main():
default_format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO,
format=default_format)
with Tesla(args.email, verify=args.verify, proxy=args.proxy) as tesla:
with Tesla(args.email, verify=args.verify, proxy=args.proxy,
sso_base_url=args.url) as tesla:
if (webdriver and args.web is not None) or webview:
tesla.authenticator = custom_auth
if args.timeout:
Expand Down Expand Up @@ -128,6 +129,7 @@ def main():
help='connect/read timeout')
parser.add_argument('-p', dest='proxy', help='proxy server URL')
parser.add_argument('-R', dest='refresh', help='use this refresh token')
parser.add_argument('-U', dest='url', help='SSO service base URL')
parser.add_argument('-l', '--list', action='store_true',
help='list all selected vehicles/batteries')
parser.add_argument('-o', '--option', action='store_true',
Expand Down
16 changes: 12 additions & 4 deletions gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,7 @@ def __init__(self, **kwargs):
opt_menu.add_checkbutton(label='Verify SSL', variable=self.verify,
command=self.apply_settings)
opt_menu.add_command(label='Set proxy URL', command=self.set_proxy)
opt_menu.add_command(label='Set SSO base URL', command=self.set_sso_url)
web_menu = Menu(menu, tearoff=0)
opt_menu.add_cascade(label='Web browser', menu=web_menu,
state=NORMAL if webdriver else DISABLED)
Expand All @@ -631,13 +632,13 @@ def __init__(self, **kwargs):
self.status.text('Not logged in')
# Read config
config = RawConfigParser()
self.email = ''
self.proxy = ''
self.email, self.proxy, self.sso_url = '', '', ''
try:
config.read('gui.ini')
self.email = config.get('app', 'email')
self.verify.set(config.get('app', 'verify'))
self.proxy = config.get('app', 'proxy')
self.sso_url = config.get('app', 'sso_url')
self.browser.set(config.get('app', 'browser'))
self.selenium.set(config.get('app', 'selenium'))
self.auto_refresh.set(config.get('display', 'auto_refresh'))
Expand Down Expand Up @@ -694,7 +695,7 @@ def login(self):
status_forcelist=(500, 502, 503, 504))
tesla = teslapy.Tesla(self.email, authenticator=self.custom_auth,
verify=self.verify.get(), proxy=self.proxy,
retry=retry)
retry=retry, sso_base_url=self.sso_url)
# Create and start login thread. Check thread status after 100 ms
self.login_thread = LoginThread(tesla)
self.login_thread.start()
Expand Down Expand Up @@ -736,7 +737,8 @@ def logout(self):
pool.apply(show_webview, (self.login_thread.tesla.logout(), ))
# Do not sign out if selenium is available and selected
self.login_thread.tesla.logout(not (webdriver and self.selenium.get()))
del self.vehicle
if hasattr(self, 'vehicle'):
del self.vehicle
# Redraw dashboard
self.dashboard.pack_forget()
self.dashboard = Dashboard(self)
Expand Down Expand Up @@ -1108,6 +1110,11 @@ def set_proxy(self):
temp = askstring('Set', 'Proxy URL', initialvalue=self.proxy)
self.proxy = '' if temp is None else temp

def set_sso_url(self):
""" Set SSO service base URL """
temp = askstring('Set', 'SSO service base URL', initialvalue=self.sso_url)
self.sso_url = '' if temp is None else temp

def save_and_quit(self):
""" Save settings to file and quit app """
config = RawConfigParser()
Expand All @@ -1117,6 +1124,7 @@ def save_and_quit(self):
config.set('app', 'email', self.email)
config.set('app', 'proxy', self.proxy)
config.set('app', 'verify', self.verify.get())
config.set('app', 'sso_url', self.sso_url)
config.set('app', 'browser', self.browser.get())
config.set('app', 'selenium', self.selenium.get())
config.set('display', 'auto_refresh', self.auto_refresh.get())
Expand Down
4 changes: 3 additions & 1 deletion menu.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,8 @@ def main():
ctx.verify_mode = ssl.CERT_NONE
geopy.geocoders.options.default_ssl_context = ctx
email = raw_input('Enter email: ')
with Tesla(email, verify=args.verify, proxy=args.proxy) as tesla:
with Tesla(email, verify=args.verify, proxy=args.proxy,
sso_base_url=args.url) as tesla:
if (webdriver and args.web is not None) or webview:
tesla.authenticator = custom_auth
if args.timeout:
Expand All @@ -322,6 +323,7 @@ def main():
parser = argparse.ArgumentParser(description='Tesla Owner API Menu')
parser.add_argument('-d', '--debug', action='store_true',
help='set logging level to debug')
parser.add_argument('--url', help='SSO service base URL')
parser.add_argument('--verify', action='store_false',
help='disable verify SSL certificate')
parser.add_argument('--timeout', type=int, help='connect/read timeout')
Expand Down
52 changes: 30 additions & 22 deletions teslapy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,16 @@ class Tesla(OAuth2Session):
cache_file: (optional) Path to cache file used by default loader and dumper.
cache_loader: (optional) Function that returns the cache dict.
cache_dumper: (optional) Function with one argument, the cache dict.
sso_base_url: (optional) URL of SSO service, set to `https://auth.tesla.cn/`
if your email is registered in another region.
kwargs: (optional) Extra arguments for the Session constructor.
"""

def __init__(self, email, verify=True, proxy=None, retry=0, timeout=10,
user_agent=__name__ + '/' + __version__, authenticator=None,
cache_file='cache.json', cache_loader=None, cache_dumper=None):
super(Tesla, self).__init__(client_id=SSO_CLIENT_ID)
cache_file='cache.json', cache_loader=None, cache_dumper=None,
sso_base_url=None, **kwargs):
super(Tesla, self).__init__(client_id=SSO_CLIENT_ID, **kwargs)
if not email:
raise ValueError('`email` is not set')
self.email = email
Expand All @@ -75,12 +79,13 @@ def __init__(self, email, verify=True, proxy=None, retry=0, timeout=10,
self.cache_file = cache_file
self.timeout = timeout
self.endpoints = {}
self._sso_base = SSO_BASE_URL
self.sso_base_url = sso_base_url or SSO_BASE_URL
self._auto_refresh_url = None
self.code_verifier = None
# Set OAuth2Session properties
self.scope = ('openid', 'email', 'offline_access')
self.redirect_uri = SSO_BASE_URL + 'void/callback'
self.auto_refresh_url = self._sso_base + 'oauth2/v3/token'
self.auto_refresh_url = 'oauth2/v3/token'
self.auto_refresh_kwargs = {'client_id': SSO_CLIENT_ID}
self.token_updater = self._token_updater
self.mount('https://', requests.adapters.HTTPAdapter(max_retries=retry))
Expand All @@ -91,12 +96,24 @@ def __init__(self, email, verify=True, proxy=None, retry=0, timeout=10,
self.trust_env = False
self.proxies.update({'https': proxy})
self._token_updater() # Try to read token from cache
logger.debug('Using SSO service URL %s', self.sso_base_url)

@property
def expires_at(self):
""" Returns unix time when token needs refreshing """
return self.token.get('expires_at')

@property
def auto_refresh_url(self):
""" Returns refresh token endpoint URL for auto-renewal access token """
url = urljoin(self.sso_base_url, self._auto_refresh_url)
return url if self._auto_refresh_url else None

@auto_refresh_url.setter
def auto_refresh_url(self, url):
""" Sets refresh token endpoint URL for auto-renewal of access token """
self._auto_refresh_url = url

def request(self, method, url, serialize=True, **kwargs):
""" Overriddes base method to support relative URLs, serialization and
error message handling. Raises HTTPError when an error occurs.
Expand All @@ -113,7 +130,7 @@ def request(self, method, url, serialize=True, **kwargs):
Return type: JsonDict or String or requests.Response
"""
if url.startswith(self._sso_base):
if url.startswith(self.sso_base_url):
return super(Tesla, self).request(method, url, **kwargs)
# Construct URL and send request with optional serialized data
url = urljoin(BASE_URL, url)
Expand Down Expand Up @@ -152,18 +169,10 @@ def authorization_url(self, url='oauth2/v3/authorize', **kwargs):
unencoded_digest = hashlib.sha256(self.code_verifier).digest()
code_challenge = base64.urlsafe_b64encode(unencoded_digest).rstrip(b'=')
# Prepare for OAuth 2 Authorization Code Grant flow
url = urljoin(self._sso_base, url)
url = urljoin(self.sso_base_url, url)
kwargs['code_challenge'] = code_challenge
kwargs['code_challenge_method'] = 'S256'
kwargs['login_hint'] = self.email
url, _ = super(Tesla, self).authorization_url(url, **kwargs)
# Detect account's registered region
response = self.get(url)
response.raise_for_status() # Raise HTTPError, if one occurred
if response.history:
self._sso_base = urljoin(response.url, '/')
self.auto_refresh_url = self._sso_base + 'oauth2/v3/token'
return response.url
return super(Tesla, self).authorization_url(url, **kwargs)[0]

def fetch_token(self, token_url='oauth2/v3/token', **kwargs):
""" Overriddes base method to sign into Tesla's SSO service using
Expand All @@ -183,7 +192,7 @@ def fetch_token(self, token_url='oauth2/v3/token', **kwargs):
url = self.authorization_url()
kwargs['authorization_response'] = self.authenticator(url)
# Use authorization code in redirected location to get token
token_url = urljoin(self._sso_base, token_url)
token_url = urljoin(self.sso_base_url, token_url)
kwargs['include_client_id'] = True
kwargs.setdefault('verify', self.verify)
kwargs.setdefault('code_verifier', self.code_verifier)
Expand All @@ -204,7 +213,7 @@ def refresh_token(self, token_url='oauth2/v3/token', **kwargs):
"""
if not self.authorized and not kwargs.get('refresh_token'):
raise ValueError('`refresh_token` is not set')
token_url = urljoin(self._sso_base, token_url)
token_url = urljoin(self.sso_base_url, token_url)
kwargs.setdefault('verify', self.verify)
super(Tesla, self).refresh_token(token_url, **kwargs)
self._token_updater() # Save new token
Expand All @@ -214,7 +223,7 @@ def close(self):
""" Overriddes base method to remove all adapters on close """
super(Tesla, self).close()
self.adapters.clear()

def logout(self, sign_out=False):
""" Removes token from cache, returns logout URL, and optionally logs
out of default browser.
Expand All @@ -225,7 +234,7 @@ def logout(self, sign_out=False):
"""
if not self.authorized:
return None
url = self._sso_base + 'oauth2/v3/logout?client_id=' + SSO_CLIENT_ID
url = self.sso_base_url + 'oauth2/v3/logout?client_id=' + SSO_CLIENT_ID
# Built-in sign out method
if sign_out:
if webbrowser.open(url):
Expand Down Expand Up @@ -275,12 +284,11 @@ def _token_updater(self, token=None):
raise ValueError('`cache_loader` must return dict')
# Write token to cache
if self.authorized:
cache[self.email] = {'url': self._sso_base, 'sso': self.token}
cache[self.email] = {'url': self.sso_base_url, 'sso': self.token}
self.cache_dumper(cache)
# Read token from cache
elif self.email in cache:
self._sso_base = cache[self.email].get('url', SSO_BASE_URL)
self.auto_refresh_url = self._sso_base + 'oauth2/v3/token'
self.sso_base_url = cache[self.email].get('url', SSO_BASE_URL)
self.token = cache[self.email].get('sso', {})
if not self.token:
return
Expand Down

0 comments on commit 33db201

Please sign in to comment.