Skip to content

Commit

Permalink
Add manual login flow (#139)
Browse files Browse the repository at this point in the history
* Added manual login flow

* Ran make fix
  • Loading branch information
alexgolec authored Jan 18, 2021
1 parent 404bd58 commit e8f43b8
Show file tree
Hide file tree
Showing 3 changed files with 262 additions and 27 deletions.
27 changes: 27 additions & 0 deletions docs/auth.rst
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,14 @@ browsers/>`__.

.. autofunction:: tda.auth.client_from_login_flow

.. _manual_login:

If for some reason you cannot open a web browser, such as when running in a
cloud environment, the following function will guide you through the process of
manually creating a token by copy-pasting relevant URLs.

.. autofunction:: tda.auth.client_from_manual_flow

Once you have a token written on disk, you can reuse it without going through
the login flow again.

Expand Down Expand Up @@ -239,3 +247,22 @@ you're confident is valid, please `file a ticket
<https://github.com/alexgolec/tda-api/issues>`__. Just remember, **never share
your token file, not even with** ``tda-api`` **developers**. Sharing the token
file is as dangerous as sharing your TD Ameritrade username and password.


++++++++++++++++++++++++++++++
What If I Can't Use a Browser?
++++++++++++++++++++++++++++++

Unfortunately, there is no way to create a token without a browser: one way or
another, you have to open up the login URL to enter your username and password
on TDAmeritrade's website. (Some OAuth APIs support `alternate login flows
<https://auth0.com/docs/flows>`__, but TDAmeritrade does not.) Still, there are
many situations in which the machine on which you're running cannot perform this
login flow, such as in a cloud or headless server setting, or when your browser
is somehow misconfigured.

Fortunately, you don't have to run the login flow on the same machine as
``tda-api``. The library provides a helpful :ref:`manual login flow<manual_login>` which will guide
you through the process of performing the login. It requires a little more care
than the easy selenium-based flow, but it should help you log in if you're
stuck otherwise.
131 changes: 105 additions & 26 deletions tda/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,40 @@ class RedirectTimeoutError(Exception):
pass


def __fetch_and_register_token_from_redirect(
oauth, redirected_url, api_key, token_path, token_write_func, asyncio):
token = oauth.fetch_token(
'https://api.tdameritrade.com/v1/oauth2/token',
authorization_response=redirected_url,
access_type='offline',
client_id=api_key,
include_client_id=True)

# Don't emit token details in debug logs
__register_token_redactions(token)

# Record the token
update_token = (
__update_token(token_path) if token_write_func is None
else token_write_func)
update_token(token)

if asyncio:
session_class = AsyncOAuth2Client
client_class = AsyncClient
else:
session_class = OAuth2Client
client_class = Client

# Return a new session configured to refresh credentials
return client_class(
api_key,
session_class(api_key, token=token,
auto_refresh_url='https://api.tdameritrade.com/v1/oauth2/token',
auto_refresh_kwargs={'client_id': api_key},
update_token=update_token))


def client_from_login_flow(webdriver, api_key, redirect_url, token_path,
redirect_wait_time_seconds=0.1, max_waits=3000,
asyncio=False, token_write_func=None):
Expand Down Expand Up @@ -146,36 +180,81 @@ def client_from_login_flow(webdriver, api_key, redirect_url, token_path,
time.sleep(redirect_wait_time_seconds)
num_waits += 1

token = oauth.fetch_token(
'https://api.tdameritrade.com/v1/oauth2/token',
authorization_response=current_url,
access_type='offline',
client_id=api_key,
include_client_id=True)
return __fetch_and_register_token_from_redirect(
oauth, current_url, api_key, token_path, token_write_func,
asyncio)

# Don't emit token details in debug logs
__register_token_redactions(token)

# Record the token
update_token = (
__update_token(token_path) if token_write_func is None
else token_write_func)
update_token(token)
def client_from_manual_flow(api_key, redirect_url, token_path,
asyncio=False, token_write_func=None):
'''
Walks the user through performing an OAuth login flow by manually
copy-pasting URLs, and returns a client wrapped around the resulting token.
The client will be configured to refresh the token as necessary, writing
each updated version to ``token_path``.
if asyncio:
session_class = AsyncOAuth2Client
client_class = AsyncClient
else:
session_class = OAuth2Client
client_class = Client
Note this method is more complicated and error prone, and should be avoided
in favor of :func:`client_from_login_flow` wherever possible.
# Return a new session configured to refresh credentials
return client_class(
api_key,
session_class(api_key, token=token,
auto_refresh_url='https://api.tdameritrade.com/v1/oauth2/token',
auto_refresh_kwargs={'client_id': api_key},
update_token=update_token))
:param api_key: Your TD Ameritrade application's API key, also known as the
client ID.
:param redirect_url: Your TD Ameritrade application's redirect URL. Note
this must *exactly* match the value you've entered in
your application configuration, otherwise login will
fail with a security error.
:param token_path: Path to which the new token will be written. If the token
file already exists, it will be overwritten with a new
one. Updated tokens will be written to this path as well.
'''
get_logger().info(('Creating new token with redirect URL \'{}\' ' +
'and token path \'{}\'').format(redirect_url, token_path))

api_key = __normalize_api_key(api_key)

oauth = OAuth2Client(api_key, redirect_uri=redirect_url)
authorization_url, state = oauth.create_authorization_url(
'https://auth.tdameritrade.com/auth')

print('\n**************************************************************\n')
print('This is the manual login and token creation flow for tda-api.')
print('Please follow these instructions exactly:')
print()
print(' 1. Open the following link by copy-pasting it into the browser')
print(' of your choice:')
print()
print(' ' + authorization_url)
print()
print(' 2. Log in with your account credentials. You may be asked to')
print(' perform two-factor authentication using text messaging or')
print(' another method, as well as whether to trust the browser.')
print()
print(' 3. When asked whether to allow your app access to your account,')
print(' select "Allow".')
print()
print(' 4. Your browser should be redirected to your redirect URI. Copy')
print(' the ENTIRE address, paste it into the following prompt, and press')
print(' Enter/Return.')
print()
print('If you encounter any issues, see here for troubleshooting:')
print('https://tda-api.readthedocs.io/en/stable/auth.html')
print('#troubleshooting')
print('\n**************************************************************\n')

if redirect_url.startswith('http://'):
print(('WARNING: Your redirect URL ({}) will transmit data over HTTP, ' +
'which is a potentially severe security vulnerability. ' +
'Please go to your app\'s configuration with TDAmeritrade ' +
'and update your redirect URL to begin with \'https\' ' +
'to stop seeing this message.').format(redirect_url))

# Workaround for Mac OS freezing on reading input
import readline

redirected_url = input('Redirect URL> ').strip()

return __fetch_and_register_token_from_redirect(
oauth, redirected_url, api_key, token_path, token_write_func,
asyncio)


def easy_client(api_key, redirect_uri, token_path, webdriver_func=None,
Expand Down
131 changes: 130 additions & 1 deletion tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,14 @@ def test_success_no_write_func(self, session, client):
REDIRECT_URL = 'https://redirect.url.com'


class AnyStringWith(str):
'''
Utility for checking whether a function was called with the given string as
a substring.
'''
def __eq__(self, other):
return self in other

class ClientFromLoginFlow(unittest.TestCase):

def setUp(self):
Expand Down Expand Up @@ -198,7 +206,8 @@ def test_no_token_file_https(self, session_constructor, client):
@no_duplicates
@patch('tda.auth.Client')
@patch('tda.auth.OAuth2Client')
def test_no_token_file_http(self, session_constructor, client):
@patch('builtins.print')
def test_no_token_file_http(self, print_func, session_constructor, client):
AUTH_URL = 'https://auth.url.com'

redirect_url = 'http://redirect.url.com'
Expand All @@ -222,6 +231,8 @@ def test_no_token_file_http(self, session_constructor, client):
with open(self.json_path, 'r') as f:
self.assertEqual(self.token, json.load(f))

print_func.assert_any_call(AnyStringWith('will transmit data over HTTP'))

@no_duplicates
@patch('tda.auth.Client')
@patch('tda.auth.OAuth2Client')
Expand Down Expand Up @@ -333,6 +344,124 @@ def dummy_token_write_func(*args, **kwargs):
update_token=dummy_token_write_func)


class ClientFromManualFlow(unittest.TestCase):

def setUp(self):
self.tmp_dir = tempfile.TemporaryDirectory()
self.json_path = os.path.join(self.tmp_dir.name, 'token.json')
self.token = {'token': 'yes'}


@no_duplicates
@patch('tda.auth.Client')
@patch('tda.auth.OAuth2Client')
@patch('tda.auth.input')
def test_no_token_file(self, input_func, session_constructor, client):
AUTH_URL = 'https://auth.url.com'

session = MagicMock()
session_constructor.return_value = session
session.create_authorization_url.return_value = AUTH_URL, None
session.fetch_token.return_value = self.token

client.return_value = 'returned client'
input_func.return_value = 'http://redirect.url.com/?data'

self.assertEqual('returned client',
auth.client_from_manual_flow(
API_KEY, REDIRECT_URL, self.json_path))

with open(self.json_path, 'r') as f:
self.assertEqual(self.token, json.load(f))

@no_duplicates
@patch('tda.auth.Client')
@patch('tda.auth.OAuth2Client')
@patch('tda.auth.input')
def test_normalize_api_key(self, input_func, session_constructor, client):
AUTH_URL = 'https://auth.url.com'

session = MagicMock()
session_constructor.return_value = session
session.create_authorization_url.return_value = AUTH_URL, None
session.fetch_token.return_value = self.token

webdriver = MagicMock()
webdriver.current_url = REDIRECT_URL + '/token_params'

client.return_value = 'returned client'
input_func.return_value = 'http://redirect.url.com/?data'

self.assertEqual('returned client',
auth.client_from_manual_flow(
'API_KEY', REDIRECT_URL, self.json_path))

self.assertEqual(
'[email protected]',
session_constructor.call_args[0][0])


@no_duplicates
@patch('tda.auth.Client')
@patch('tda.auth.OAuth2Client')
@patch('tda.auth.input')
def test_custom_token_write_func(self, input_func, session_constructor, client):
AUTH_URL = 'https://auth.url.com'

session = MagicMock()
session_constructor.return_value = session
session.create_authorization_url.return_value = AUTH_URL, None
session.fetch_token.return_value = self.token

webdriver = MagicMock()
webdriver.current_url = REDIRECT_URL + '/token_params'

client.return_value = 'returned client'
input_func.return_value = 'http://redirect.url.com/?data'

def dummy_token_write_func(*args, **kwargs):
pass

self.assertEqual('returned client',
auth.client_from_manual_flow(
API_KEY, REDIRECT_URL,
self.json_path,
token_write_func=dummy_token_write_func))

session_constructor.assert_called_with(
_, token=_, auto_refresh_url=_, auto_refresh_kwargs=_,
update_token=dummy_token_write_func)


@no_duplicates
@patch('tda.auth.Client')
@patch('tda.auth.OAuth2Client')
@patch('tda.auth.input')
@patch('builtins.print')
def test_print_warning_on_http_redirect_uri(
self, print_func, input_func, session_constructor, client):
AUTH_URL = 'https://auth.url.com'

redirect_url = 'http://redirect.url.com'

session = MagicMock()
session_constructor.return_value = session
session.create_authorization_url.return_value = AUTH_URL, None
session.fetch_token.return_value = self.token

client.return_value = 'returned client'
input_func.return_value = 'http://redirect.url.com/?data'

self.assertEqual('returned client',
auth.client_from_manual_flow(
API_KEY, redirect_url, self.json_path))

with open(self.json_path, 'r') as f:
self.assertEqual(self.token, json.load(f))

print_func.assert_any_call(AnyStringWith('will transmit data over HTTP'))


class EasyClientTest(unittest.TestCase):

def setUp(self):
Expand Down

0 comments on commit e8f43b8

Please sign in to comment.