Skip to content

Commit

Permalink
Client from login flow docs (#130)
Browse files Browse the repository at this point in the history
* added docs for client_from_login_flow, plus some minor functionality changes

* change case

* adds missing coverage

* remove erroneous continue

* Refuse to start on port 443
  • Loading branch information
alexgolec authored Jun 17, 2024
1 parent 186f75b commit cdb8bf8
Show file tree
Hide file tree
Showing 4 changed files with 260 additions and 52 deletions.
60 changes: 58 additions & 2 deletions docs/auth.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.


---------------
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -204,6 +212,33 @@ can also `join our Discord server <https://discord.gg/M3vjtHj>`__ 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
<schwab.auth.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 <schwab.auth.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
<schwab.auth.client_from_login_flow>` will print message reminding you of your
callback URL each time you run it.


++++++++++++++++++++
``401 Unauthorized``
++++++++++++++++++++
Expand Down Expand Up @@ -258,3 +293,24 @@ you're confident is valid, please `file a ticket
<https://github.com/alexgolec/schwab-py/issues>`__. 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<manual_login>` 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.
25 changes: 14 additions & 11 deletions docs/getting-started.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <login_flow>`. 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
Expand Down Expand Up @@ -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
Expand Down
161 changes: 125 additions & 36 deletions schwab/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,9 @@ def handle_token():
def status():
return 'running'

if callback_port == 443:
return

# Wrap this call in some hackery to suppress the flask startup messages
with open(os.devnull, 'w') as devnull:
import logging
Expand All @@ -213,19 +216,91 @@ 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
<https://developer.schwab.com/>`__. 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
<approved_pending>`.
: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.
<callback_url_advisory>`
: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 <https://docs.python.org/3/library/webbrowser.html#webbrowser.register>`__
'''

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)

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()
Expand Down Expand Up @@ -280,35 +355,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<add a documentation URL>')
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)
Expand All @@ -317,7 +395,18 @@ 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: # pragma: no cover
raise ValueError('endless wait requested')
else:
break

# Attempt to fetch from the queue
try:
received_url = output_queue.get(
Expand All @@ -326,10 +415,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 ' +
Expand All @@ -356,7 +442,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
<approved_pending>`.
:param asyncio: If set to ``True``, this will enable async support allowing
the client to be used in an async environment. Defaults to
``False``
Expand Down Expand Up @@ -387,7 +474,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
<approved_pending>`.
: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
Expand Down Expand Up @@ -475,7 +563,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
<approved_pending>`.
: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
Expand Down
Loading

0 comments on commit cdb8bf8

Please sign in to comment.