From 28e0d8b498f86b8c605890039d7dd7fb83825799 Mon Sep 17 00:00:00 2001 From: Alex Golec Date: Sun, 16 Jun 2024 19:57:06 -0400 Subject: [PATCH] added docs for client_from_login_flow, plus some minor functionality changes --- docs/auth.rst | 60 ++++++++++++++- docs/getting-started.rst | 25 +++--- schwab/auth.py | 159 ++++++++++++++++++++++++++++++--------- tests/auth_test.py | 44 +++++++++++ 4 files changed, 239 insertions(+), 49 deletions(-) diff --git a/docs/auth.rst b/docs/auth.rst index c471cf4..61ea07e 100644 --- a/docs/auth.rst +++ b/docs/auth.rst @@ -14,7 +14,7 @@ last remaining hurdle: OAuth authentication. Before we begin, however, note that this guide is meant to users who want to run applications on their own machines, without distributing them to others. If you plan on distributing your app, or if you plan on running it on a server and -allowing access to other users, this login flow is not for you. +allowing access to other users, these login flows are not for you. --------------- @@ -78,11 +78,19 @@ using them easy. Fetching a Token and Creating a Client -------------------------------------- -.. _manual_login: +.. _login_flow: This function will guide you through the process of logging in and creating a token. +.. autofunction:: schwab.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, this function will guide you through the process of manually +creating a token by copy-pasting relevant URLs. + .. autofunction:: schwab.auth.client_from_manual_flow Once you have a token written on disk, you can reuse it without going through @@ -204,6 +212,33 @@ can also `join our Discord server `__ to ask questions. +.. _ssl_errors: + ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +Browser Warnings About Invalid/Self-Signed Certificates ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +:ref:`ssl_errors` + +When creating a token using :func:`client_from_login_flow +`, you will likely encounter a warning from +your browser about refusing to connect to a site using an invalid or self-signed +certificate. Under the hood, +:func:`client_from_login_flow ` starts a +server on your machine to listen for the OAuth callback. Since Schwab requires +``https:`` callback URLs, this server must declare an SSL context. However, +certificate authorities do not sign certificates for ``localhost`` or +``127.0.0.1``, and so the server must self-sign the certificate. As this would +be a security issue in any other context, your browser shows you a stern +security warning. + +It is safe to ignore this warning and proceed anyway. *However*, you should +always verify that the address of the page displaying the warning matches your +callback URL. :func:`client_from_login_flow +` will print message reminding you of your +callback URL each time you run it. + + ++++++++++++++++++++ ``401 Unauthorized`` ++++++++++++++++++++ @@ -258,3 +293,24 @@ you're confident is valid, please `file a ticket `__. Just remember, **never share your token file, not even with** ``schwab-py`` **developers**. Sharing the token file is as dangerous as sharing your Schwab username and password. + +++++++++++++++++++++++++++++++ +What If I Can't Use a Browser? +++++++++++++++++++++++++++++++ + +Launching a browser can be inconvenient in some situations, most notably in +containerized applications running on a cloud provider. ``tda-api`` supports two +alternatives to creating tokens by opening a web browser. + +Firstly, the :ref:`manual login flow` flow allows you to go +through the login flow on a different machine than the one on which +``schwab-py`` is running. Instead of starting the web browser and automatically +opening the relevant URLs, this flow allows you to manually copy-paste around +the URLs. It's a little more cumbersome, but it has no dependency on selenium. + +Alterately, you can take advantage of the fact that token files are portable. +Once you create a token on one machine, such as one where you can open a web +browser, you can easily copy that token file to another machine, such as your +application in the cloud. However, make sure you don't use the same token on +two machines. It is recommended to delete the token created on the +browser-capable machine as soon as it is copied to its destination. diff --git a/docs/getting-started.rst b/docs/getting-started.rst index 2bb04e8..1b314a5 100644 --- a/docs/getting-started.rst +++ b/docs/getting-started.rst @@ -45,22 +45,26 @@ sending an HTTP request to the callback URL with ingredients for the token in the URL query. The vast majority of users should set their callback URL to -``https://127.0.0.1`` (note the lack of a trailing slash). This means that once -the login flow is completed, the generated credentials are sent back to your -machine, rather than any external server. Certain advanced users may be able to -receive this data at a server, but this documentation assumes they are advanced +``https://127.0.0.1:8182`` (note the lack of a trailing slash). This means that +once the login flow is completed, the generated credentials are sent back to +your machine at port ``8182``, rather than any external server. Setting a port +number is not require to use ``schwab-py``, but it is required to use +:ref:`certain convenient features `. Advanced users may be able to +use a non-local callback URL, but this documentation assumes they are advanced enough not to need our help creating such a setup. In any case, note that whatever callback URL you choose, you must pass it to ``schwab-py`` *exactly* in the same way as you specified it while creating your -app. Any deviation (including the addition of trailing slashes!) can cause +app. Any deviation (including adding or removing a trailing slash!) can cause difficult-to-debug issues. Be careful not to mis-copy this value. +.. _approved_pending: + After your app is created, you will likely see it in an ``Approved - Pending`` state when you view it in your dashboard. Don't be fooled by the word ``Approved``: your app is not yet ready for use. You must wait for Schwab to -approve it, at which point its status will be ``Ready For Use.`` This can take -up to a few days. Only then can you proceed to using ``schwab-py``. +*actually* approve it, at which point its status will be ``Ready For Use.`` This +can take up to a few days. Only then can you proceed to using ``schwab-py``. Once your app is created and approved, you will receive your app key and secret. Neither of these are meant to be shared by anyone, so keep them safe. You will @@ -101,10 +105,9 @@ package: If this succeeded, you're ready to move on to :ref:`auth`. Note that if you are using a virtual environment and switch to a new terminal -your virtual environment will not be active in the new terminal, -and you need to run the activate command again. -If you want to disable the loaded virtual environment in the same terminal window, -use the command: +your virtual environment will not be active in the new terminal, and you need to +run the activate command again. If you want to disable the loaded virtual +environment in the same terminal window, use the command: .. code-block:: shell diff --git a/schwab/auth.py b/schwab/auth.py index b3c61a4..3e7c650 100644 --- a/schwab/auth.py +++ b/schwab/auth.py @@ -213,7 +213,77 @@ def client_from_login_flow(api_key, app_secret, callback_url, token_path, asyncio=False, enforce_enums=False, token_write_func=None, callback_timeout=300.0, interactive=True, requested_browser=None): - # TODO: documentation + ''' + Open a web browser to perform an OAuth webapp login flow and creates 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``. + + .. _callback_url_advisory: + + **Important Note:** This method operates by starting an HTTP server on the + port specified in your callback URL. When you complete the Schwab login + flow, Schwab sends a request to the callback URL with the required login + data encoded in the request parameters. *Anyone who receives this request + can steal your token and act on your account as though they were you.* + + ``schwab-py`` takes your security seriously. As a result, we only allow + ``127.0.0.1`` as a host. We *strongly* recommend using a port number higher + than ``1024``, as most operating systems require superuser privileges to listen + on ports below ``1024``, and some also require changes to system firewalls + to accept connections to those ports, even when the connections originate from + the same machine. The vast majority of users should just use + ``https://127.0.0.1:8182`` as a callback URL. + + Note in particular that specifying *no* port number is equivalent to + specifying port 443, which is the default port number for HTTPS. Your + operating system will likely refuse to open this port for you, and this + method will fail. + + If you want to use this method but haven't specified a compatible callback + URL, you must update your app's configuration on `Schwab's developer portal + `__. Note making this change will likely + require app re-approval from Schwab, which typically takes a few days. + + :param api_key: Your Schwab application's app key. + :param app_secret: Application secret provided upon :ref:`app approval + `. + :param callback_url: Your Schwab application's callback URL. Note this must + *exactly* match the value you've entered in your + application configuration, otherwise login will fail + with a security error. Be sure to check case and + trailing slashes. :ref:`See the above note for + important information about setting your callback URL. + ` + :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. + :param asyncio: If set to ``True``, this will enable async support allowing + the client to be used in an async environment. Defaults to + ``False`` + :param enforce_enums: Set it to ``False`` to disable the enum checks on ALL + the client methods. Only do it if you know you really + need it. For most users, it is advised to use enums + to avoid errors. + :param token_write_func: Function that writes the token on update. Will be + called whenever the token is updated, such as when + it is refreshed. See the above-mentioned example + for what parameters this method takes. + :param callback_timeout: How long to wait for a callback from the server + before giving up, in seconds. Wait forever if set + to zero or ``None``. + :param interactive: Require user input before starting the browser. + :param requested_browser: Name of the browser to attempt to open. This + function uses the standard ``webbrowser`` library + under the hood, so you can find a table of valid + values + `here `__ + ''' + + if callback_timeout is None: + callback_timeout = 0 + if callback_timeout < 0: + raise ValueError('callback_timeout must be positive') # Start the server parsed = urllib.parse.urlparse(callback_url) @@ -221,11 +291,13 @@ def client_from_login_flow(api_key, app_secret, callback_url, token_path, if parsed.hostname != '127.0.0.1': # TODO: document this error raise ValueError( - ('disallowed hostname {}. client_from_login_flow only allows '+ - 'callback URLs with hostname 127.0.0.1').format( + ('Disallowed hostname {}. client_from_login_flow only allows '+ + 'callback URLs with hostname 127.0.0.1. See here for ' + + 'more information: https://schwab-py.readthedocs.io/en/' + + 'latest/auth.html#callback-url-advisory').format( parsed.hostname)) - callback_port = parsed.port if parsed.port else 80 + callback_port = parsed.port if parsed.port else 443 callback_path = parsed.path if parsed.path else '/' output_queue = multiprocessing.Queue() @@ -280,35 +352,38 @@ def callback_server(): authorization_url, state = oauth.create_authorization_url( 'https://api.schwabapi.com/v1/oauth/authorize') + print() + print('***********************************************************************') + print() + print('This is the browser-assisted login and token creation flow for') + print('schwab-py. This flow automatically opens the login page on your') + print('browser, captures the resulting OAuth callback, and creates a token') + print('using the result. The authorization URL is:') + print() + print('>>', authorization_url) + print() + print('IMPORTANT: Your browser will give you a security warning about an') + print('invalid certificate prior to issuing the redirect. This is because') + print('schwab-py has started a server on your machine to receive the OAuth') + print('redirect using a self-signed SSL certificate. You can ignore that') + print('warning, but make sure to first check that the URL matches your') + print('callback URL, ignoring URL parameters. As a reminder, your callback URL') + print('is:') + print() + print('>>',callback_url) + print() + print('See here to learn more about self-signed SSL certificates:') + print('https://schwab-py.readthedocs.io/en/latest/auth.html#ssl-errors') + print() + print('If you encounter any issues, see here for troubleshooting:') + print('https://schwab-py.readthedocs.io/en/latest/auth.html#troubleshooting') + print('***********************************************************************') + print() + if interactive: - print() - print('**************************************************************') - print() - print('This is the browser-assisted login and token creation flow for') - print('schwab-py. This flow automatically opens the login page on your') - print('browser, captures the resulting OAuth callback, and creates a token') - print('using the result.') - print() - print('IMPORTANT: Your browser will give you a security warning about an') - print('invalid certificate prior to issuing the redirect. This is because') - print('schwab-py has started a server on your machine to receive the OAuth') - print('redirect using a self-signed SSL certificate. You can ignore that') - print('warning, but make sure to first check that the URL matches your') - print('callback URL. As a reminder, your callback URL is:') - print() - print('>>',callback_url) - print() - print('See here to learn more: TODO') - print() - print('If you encounter any issues, see here for troubleshooting:') - print('https://schwab-py.readthedocs.io/en/latest/auth.html#troubleshooting') - print('\n**************************************************************') - print() prompt('Press ENTER to open the browser. Note you can run ' + 'client_from_login_flow with interactive=False to skip this input') - # TODO: Add a link to the table for browsers: - # https://docs.python.org/3/library/webbrowser.html#webbrowser.register controller = webbrowser.get(requested_browser) print(webbrowser.get) controller.open(authorization_url) @@ -317,7 +392,19 @@ def callback_server(): now = __TIME_TIME() timeout_time = now + callback_timeout received_url = None - while now < timeout_time: + while True: + now = __TIME_TIME() + if now >= timeout_time: + if callback_timeout == 0: + # XXX: We're detecting a test environment here to avoid an + # infinite sleep. Surely there must be a better way to do + # this... + if __TIME_TIME != time.time: + raise ValueError('endless wait requested') + continue + else: + break + # Attempt to fetch from the queue try: received_url = output_queue.get( @@ -326,10 +413,7 @@ def callback_server(): except queue.Empty: pass - now = __TIME_TIME() - if not received_url: - # TODO: document this error raise RedirectTimeoutError( 'Timed out waiting for a post-authorization callback. You '+ 'can set a longer timeout by passing a value of ' + @@ -356,7 +440,8 @@ def client_from_token_file(token_path, api_key, app_secret, asyncio=False, :func:`~schwab.auth.client_from_login_flow` or :func:`~schwab.auth.easy_client` to create one. :param api_key: Your Schwab application's app key. - :param app_secret: Application secret. Provided upon app approval. + :param app_secret: Application secret. Provided upon :ref:`app approval + `. :param asyncio: If set to ``True``, this will enable async support allowing the client to be used in an async environment. Defaults to ``False`` @@ -387,7 +472,8 @@ def client_from_manual_flow(api_key, app_secret, callback_url, token_path, each updated version to ``token_path``. :param api_key: Your Schwab application's app key. - :param app_secret: Application secret provided upon app approval. + :param app_secret: Application secret provided upon :ref:`app approval + `. :param callback_url: Your Schwab application's callback URL. Note this must *exactly* match the value you've entered in your application configuration, otherwise login will fail @@ -475,7 +561,8 @@ def client_from_access_functions(api_key, app_secret, token_read_func, client_from_access_functions.py>`__ for details. :param api_key: Your Schwab application's app key. - :param app_secret: Application secret. Provided upon app approval. + :param app_secret: Application secret. Provided upon :ref:`app approval + `. :param token_read_func: Function that takes no arguments and returns a token object. :param token_write_func: Function that writes the token on update. Will be diff --git a/tests/auth_test.py b/tests/auth_test.py index e2da6b5..b2afe88 100644 --- a/tests/auth_test.py +++ b/tests/auth_test.py @@ -236,6 +236,50 @@ def test_time_out_waiting_for_request( callback_timeout=0.01) + @patch('schwab.auth.Client') + @patch('schwab.auth.OAuth2Client', new_callable=MockOAuthClient) + @patch('schwab.auth.AsyncOAuth2Client', new_callable=MockAsyncOAuthClient) + @patch('schwab.auth.webbrowser.get', new_callable=MagicMock) + @patch('schwab.auth.prompt', MagicMock(return_value='')) + @patch('time.time', MagicMock(return_value=MOCK_NOW)) + def test_wait_forever_callback_timeout_equals_none( + self, mock_webbrowser_get, async_session, sync_session, client): + AUTH_URL = 'https://auth.url.com' + + sync_session.return_value = sync_session + sync_session.create_authorization_url.return_value = AUTH_URL, None + sync_session.fetch_token.return_value = self.raw_token + + callback_url = 'https://127.0.0.1:6969/callback' + + with self.assertRaisesRegex(ValueError, 'endless wait requested'): + auth.client_from_login_flow( + API_KEY, APP_SECRET, callback_url, self.token_path, + callback_timeout=None) + + + @patch('schwab.auth.Client') + @patch('schwab.auth.OAuth2Client', new_callable=MockOAuthClient) + @patch('schwab.auth.AsyncOAuth2Client', new_callable=MockAsyncOAuthClient) + @patch('schwab.auth.webbrowser.get', new_callable=MagicMock) + @patch('schwab.auth.prompt', MagicMock(return_value='')) + @patch('time.time', MagicMock(return_value=MOCK_NOW)) + def test_wait_forever_callback_timeout_equals_zero( + self, mock_webbrowser_get, async_session, sync_session, client): + AUTH_URL = 'https://auth.url.com' + + sync_session.return_value = sync_session + sync_session.create_authorization_url.return_value = AUTH_URL, None + sync_session.fetch_token.return_value = self.raw_token + + callback_url = 'https://127.0.0.1:6969/callback' + + with self.assertRaisesRegex(ValueError, 'endless wait requested'): + auth.client_from_login_flow( + API_KEY, APP_SECRET, callback_url, self.token_path, + callback_timeout=0) + + class ClientFromTokenFileTest(unittest.TestCase): def setUp(self):