Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

collab and notebook support for easy_client #193

Merged
merged 2 commits into from
Mar 9, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 88 additions & 50 deletions docs/auth.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,61 +17,41 @@ plan on distributing your app, or if you plan on running it on a server and
allowing access to other users, these login flows are not for you.


---------------
OAuth Refresher
---------------
------------------------
The Quick and Easy Route
------------------------

*This section is purely for the curious. If you already understand OAuth (wow,
congrats) or if you don't care and just want to use this package as fast as
possible, feel free to skip this section. If you encounter any weird behavior,
this section may help you understand what's going on.*
If all you want to do is create a client, you should use
:func:`~schwab.auth.easy_client`. This method will attempt to create a client in
a way that's appropriate to the context in which you're running:

Webapp authentication is a complex beast. The OAuth protocol was created to
allow applications to access one anothers' APIs securely and with the minimum
level of trust possible. A full treatise on this topic is well beyond the scope
of this guide, but in order to alleviate some of the complexity that seems to
surround this part of the API, let's give a quick explanation of how OAuth works
in the context of Schwab's API.
* If you've already got a token at ``token_path``,
:func:`load it <schwab.auth.client_from_token_file>` and continue. Otherwise
create a new one.
* In desktop environments, :func:`start a web browser
<schwab.auth.client_from_login_flow>` in which you can sign in, and
automatically capture the created token.
* In a notebook like Google Colab or Jupyter, instead run the :func:`manual
flow <schwab.auth.client_from_manual_flow>`.

The first thing to understand is that the OAuth webapp flow was created to allow
client-side applications consisting of a webapp frontend and a remotely hosted
backend to interact with a third party API. Unlike the `backend application flow
<https://requests-oauthlib.readthedocs.io/en/latest/oauth2_workflow.html
#backend-application-flow>`__, in which the remotely hosted backend has a secret
which allows it to access the API on its own behalf, the webapp flow allows
either the webapp frontend or the remotely host backend to access the API *on
behalf of its users*.
Here's how you can use it. If for some reason this doesn't work, please report
your issues in the `Discord server <https://discord.gg/BEr6y6Xqyv>`__. See
:func:`~schwab.auth.easy_client` for details:

If you've ever installed a GitHub, Facebook, Twitter, GMail, etc. app, you've
seen this flow. You click on the "install" link, a login window pops up, you
enter your password, and you're presented with a page that asks whether you want
to grant the app access to your account.
.. code-block:: python

Here's what's happening under the hood. The window that pops up is the
authentication URL, which opens a login page for the target API. The aim is to
allow the user to input their username and password without the webapp frontend
or the remotely hosted backend seeing it. On web browsers, this is accomplished
using the browser's refusal to send credentials from one domain to another.

Once login here is successful, the API replies with a redirect to a URL that the
remotely hosted backend controls. This is the callback URL. This redirect will
contain a code which securely identifies the user to the API, embedded in the
query of the request.

You might think that code is enough to access the API, and it would be if the
API author were willing to sacrifice long-term security. The exact reasons why
it doesn't work involve some deep security topics like robustness against replay
attacks and session duration limitation, but we'll skip them here.
from schwab.auth import easy_client

This code is useful only for fetching a token from the authentication endpoint.
*This token* is what we want: a secure secret which the client can use to access
API endpoints, and can be refreshed over time.
# Follow the instructions on the screen to authenticate your client.
c = easy_client(
api_key='APIKEY',
app_secret='APP_SECRET',
callback_url='https://127.0.0.1',
token_path='/tmp/token.json')

If you've gotten this far and your head isn't spinning, you haven't been paying
attention. Security-sensitive protocols can be very complicated, and you should
**never** build your own implementation. Fortunately there exist very robust
implementations of this flow, and ``schwab-py``'s authentication module makes
using them easy.
resp = c.get_price_history_every_day('AAPL')
assert resp.status_code == httpx.codes.OK
history = resp.json()


.. _login_flow:
Expand All @@ -88,8 +68,8 @@ token.
.. _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.
cloud environment or a notebook, 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

Expand Down Expand Up @@ -204,6 +184,64 @@ seeing this error, you have no choice but to delete your old token file and
create a new one.


---------------
OAuth Refresher
---------------

*This section is purely for the curious. If you already understand OAuth (wow,
congrats) or if you don't care and just want to use this package as fast as
possible, feel free to skip this section. If you encounter any weird behavior,
this section may help you understand what's going on.*

Webapp authentication is a complex beast. The OAuth protocol was created to
allow applications to access one anothers' APIs securely and with the minimum
level of trust possible. A full treatise on this topic is well beyond the scope
of this guide, but in order to alleviate some of the complexity that seems to
surround this part of the API, let's give a quick explanation of how OAuth works
in the context of Schwab's API.

The first thing to understand is that the OAuth webapp flow was created to allow
client-side applications consisting of a webapp frontend and a remotely hosted
backend to interact with a third party API. Unlike the `backend application flow
<https://requests-oauthlib.readthedocs.io/en/latest/oauth2_workflow.html
#backend-application-flow>`__, in which the remotely hosted backend has a secret
which allows it to access the API on its own behalf, the webapp flow allows
either the webapp frontend or the remotely host backend to access the API *on
behalf of its users*.

If you've ever installed a GitHub, Facebook, Twitter, GMail, etc. app, you've
seen this flow. You click on the "install" link, a login window pops up, you
enter your password, and you're presented with a page that asks whether you want
to grant the app access to your account.

Here's what's happening under the hood. The window that pops up is the
authentication URL, which opens a login page for the target API. The aim is to
allow the user to input their username and password without the webapp frontend
or the remotely hosted backend seeing it. On web browsers, this is accomplished
using the browser's refusal to send credentials from one domain to another.

Once login here is successful, the API replies with a redirect to a URL that the
remotely hosted backend controls. This is the callback URL. This redirect will
contain a code which securely identifies the user to the API, embedded in the
query of the request.

You might think that code is enough to access the API, and it would be if the
API author were willing to sacrifice long-term security. The exact reasons why
it doesn't work involve some deep security topics like robustness against replay
attacks and session duration limitation, but we'll skip them here.

This code is useful only for fetching a token from the authentication endpoint.
*This token* is what we want: a secure secret which the client can use to access
API endpoints, and can be refreshed over time.

If you've gotten this far and your head isn't spinning, you haven't been paying
attention. Security-sensitive protocols can be very complicated, and you should
**never** build your own implementation. Fortunately there exist very robust
implementations of this flow, and ``schwab-py``'s authentication module makes
using them easy.



---------------
Troubleshooting
---------------
Expand Down
2 changes: 1 addition & 1 deletion docs/client.rst
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ this will likely cause issues with the underlying OAuth2 session management**
from schwab.auth import client_from_manual_flow

# Follow the instructions on the screen to authenticate your client.
c = client_from_manual_flow(
c = easy_client(
api_key='APIKEY',
app_secret='APP_SECRET',
callback_url='https://127.0.0.1',
Expand Down
7 changes: 4 additions & 3 deletions docs/streaming.rst
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,11 @@ run this outside regular trading hours you may not see anything):

# Assumes you've already created a token. See the authentication page for more
# information.
client = client_from_token_file(
token_path='/path/to/token.json',
client = easy_client(
api_key='YOUR_API_KEY',
app_secret='YOUR_APP_SECRET')
app_secret='YOUR_APP_SECRET',
callback_url='https://127.0.0.1',
token_path='/path/to/token.json')
stream_client = StreamClient(client, account_id=1234567890)

async def read_stream():
Expand Down
43 changes: 39 additions & 4 deletions schwab/auth.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from authlib.integrations.httpx_client import AsyncOAuth2Client, OAuth2Client
from prompt_toolkit import prompt

import collections
import contextlib
Expand Down Expand Up @@ -338,7 +337,7 @@ def callback_server():
print()

if interactive:
prompt('Press ENTER to open the browser. Note you can call ' +
input('Press ENTER to open the browser. Note you can call ' +
'this method with interactive=False to skip this input.')

controller = webbrowser.get(requested_browser)
Expand Down Expand Up @@ -485,7 +484,7 @@ def client_from_manual_flow(api_key, app_secret, callback_url, token_path,
'and update your callback URL to begin with \'https\' ' +
'to stop seeing this message.').format(callback_url))

received_url = prompt('Redirect URL> ').strip()
received_url = input('Redirect URL> ').strip()

token_write_func = (
__make_update_token_func(token_path) if token_write_func is None
Expand Down Expand Up @@ -639,6 +638,31 @@ async def oauth_client_update_token(t, *args, **kwargs):
# easy_client


# TODO: Figure out how to properly mock global objects in unittest. This hack
# ensures that the _get_ipython variable is defined so that we can patch is
# using module-level patching. This is safe in most contexts, but there are
# circumstances where it gets weird like starting an ipython notebook after
# schwab-py is loaded.
try:
_get_ipython = get_ipython
except NameError:
_get_ipython = None


def __running_in_notebook():
# Google Colab
if os.getenv('COLAB_RELEASE_TAG'):
return True

# ipython in notebook mode
if _get_ipython is not None:
shell = _get_ipython().__class__.__name__
if shell == 'ZMQInteractiveShell':
return True

return False


def easy_client(api_key, app_secret, callback_url, token_path, asyncio=False,
enforce_enums=True, max_token_age=60*60*24*6.5,
callback_timeout=300.0, interactive=True,
Expand Down Expand Up @@ -707,7 +731,18 @@ def easy_client(api_key, app_secret, callback_url, token_path, asyncio=False,
logger.info('token too old, proactively creating a new one')
c = None

if c is None:
# Return early on success
if c is not None:
return c

# Detect whether we're running in a notebook
if __running_in_notebook():
c = client_from_manual_flow(api_key, app_secret, callback_url,
token_path, enforce_enums=enforce_enums)
logger.info(
'Returning client fetched using manual flow, writing' +
'token to \'%s\'', token_path)
else:
c = client_from_login_flow(
api_key, app_secret, callback_url, token_path, asyncio=asyncio,
enforce_enums=enforce_enums, callback_timeout=callback_timeout,
Expand Down
1 change: 0 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@
'flask',
'httpx',
'multiprocess',
'prompt_toolkit',
'psutil',
'python-dateutil',
'urllib3',
Expand Down
Loading