Skip to content

Commit

Permalink
update oauth docs
Browse files Browse the repository at this point in the history
  • Loading branch information
Graeme22 committed Jan 22, 2025
1 parent 0ed5d2c commit 9bad005
Show file tree
Hide file tree
Showing 6 changed files with 110 additions and 50 deletions.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ A simple, unofficial, sync/async SDK for Tradestation built on their public API.
- Comprehensive documentation
- Utility functions for timezone calculations, futures monthly expiration dates, and more

> [!NOTE]
> Do you use Tastytrade? We also built a [SDK](https://github.com/tastyware/tradestation) for Tastytrade users, with many of the same features!
## Installation

```console
Expand Down
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ A simple, sync/async SDK for TradeStation built on their public API. This will a

installation
sessions
oauth
sync-async

.. toctree::
Expand Down
58 changes: 58 additions & 0 deletions docs/oauth.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
oauth
=====

Unless you're creating a web app with an option for a TradeStation login, you can probably skip this section!

As mentioned previously, the utility function :func:`tradestation.oauth.login` handles creating a local HTTP server to handle the OAuth login callback. But what if you want to do this yourself?

Here's a simple example of how you could do this on your own server, using `FastAPI` and `authlib`:

.. code-block:: python
from authlib.integrations.starlette_client import OAuth
from fastapi import FastAPI, Request
from starlette.middleware.sessions import SessionMiddleware
from tradestation.oauth import AUDIENCE, REDIRECT_URI, SCOPES
SESSION_ENCRYPTION_KEY = "some-random-string"
oauth = OAuth()
oauth.register(
name="tradestation",
server_metadata_url="https://signin.tradestation.com/.well-known/openid-configuration",
client_id="api_key",
client_secret="secret_key",
access_token_params={"grant_type": "authorization_code"},
authorize_params={"audience": AUDIENCE},
authorize_state=SESSION_ENCRYPTION_KEY,
client_kwargs={
"response_type": "code",
"scope": SCOPES,
},
)
app = FastAPI(name="TradeStation SDK Login")
app.add_middleware(SessionMiddleware, secret_key=SESSION_ENCRYPTION_KEY)
@app.get("/login")
async def login_tradestation(request: Request):
# in production, you'd use `request.url_for("auth_tradestation")`
return await oauth.tradestation.authorize_redirect(request, REDIRECT_URI)
@app.get("/")
async def auth_tradestation(request: Request):
return await oauth.tradestation.authorize_access_token(request)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=3001)
.. note:
If you run into a CSRF error, it probably has to do with your session state! Try clearing your browser cookies or testing in an incognito window.
That should be enough to get you started!
1 change: 1 addition & 0 deletions docs/sessions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Tradestation uses OAuth for secure authentication to the API. In order to obtain
login()
This will let you authenticate in your local browser. Fortunately, this only needs to be done once, as afterwards you can use the refresh token to obtain new access tokens indefinitely.
If you need to do this yourself, check out :doc:`../oauth`.

Creating a session
------------------
Expand Down
4 changes: 2 additions & 2 deletions tests/test_oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@


def test_get_access_url():
credentials = Credentials(key="test")
credentials = Credentials(api_key="test", secret_key="")
url = get_access_url(credentials)
assert "test" in url


def test_convert_auth_code():
with pytest.raises(Exception):
convert_auth_code(Credentials(), "bogus")
convert_auth_code(Credentials(api_key="", secret_key=""), "bogus")


def test_response_page():
Expand Down
93 changes: 45 additions & 48 deletions tradestation/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,53 +20,13 @@


class Credentials(BaseModel):
key: str = ""
secret: str = ""
api_key: str
secret_key: str
redirect_uri: str = REDIRECT_URI
scopes: str = SCOPES

def clear(self) -> None:
self.key = ""
self.secret = ""
self.scopes = SCOPES


credentials = Credentials()


def get_access_url(credentials: Credentials) -> str:
query_string = "&".join(
[
"response_type=code",
f"audience={AUDIENCE}",
f"redirect_uri={REDIRECT_URI}",
f"client_id={credentials.key}",
f"scope={credentials.scopes}",
]
)
access_url = f"{OAUTH_URL}/authorize?{query_string}"
return access_url


def convert_auth_code(credentials: Credentials, auth_code: str) -> dict[str, Any]:
"""
Uses an api key, a secret key and authorization code to obtain a response
containing an access token, refresh token, user id, and expriation time
"""
post_data = {
"grant_type": "authorization_code",
"client_id": credentials.key,
"client_secret": credentials.secret,
"redirect_uri": REDIRECT_URI,
"code": auth_code,
}
headers = {"Content-Type": "application/x-www-form-urlencoded"}
response = httpx.post(f"{OAUTH_URL}/oauth/token", headers=headers, data=post_data)
if response.status_code != 200:
raise Exception(
"Could not load access and refresh tokens from authorization code!"
)

return response.json()
credentials = Credentials(api_key="", secret_key="")


root_page: bytes = f"""
Expand Down Expand Up @@ -285,6 +245,42 @@ def response_page(
</html>""".encode("utf-8")


def get_access_url(credentials: Credentials) -> str:
query_string = "&".join(
[
"response_type=code",
f"audience={AUDIENCE}",
f"redirect_uri={credentials.redirect_uri}",
f"client_id={credentials.api_key}",
f"scope={credentials.scopes}",
]
)
access_url = f"{OAUTH_URL}/authorize?{query_string}"
return access_url


def convert_auth_code(credentials: Credentials, auth_code: str) -> dict[str, Any]:
"""
Uses an api key, a secret key and authorization code to obtain a response
containing an access token, refresh token, user id, and expriation time
"""
post_data = {
"grant_type": "authorization_code",
"client_id": credentials.api_key,
"client_secret": credentials.secret_key,
"redirect_uri": REDIRECT_URI,
"code": auth_code,
}
headers = {"Content-Type": "application/x-www-form-urlencoded"}
response = httpx.post(f"{OAUTH_URL}/oauth/token", headers=headers, data=post_data)
if response.status_code != 200:
raise Exception(
"Could not load access and refresh tokens from authorization code!"
)

return response.json()


class RequestHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # pragma: no cover
# Serve root page with sign in link
Expand All @@ -301,8 +297,8 @@ def do_GET(self) -> None: # pragma: no cover
query = urlparse(self.path).query
query_components = dict(qc.split("=") for qc in query.split("&"))

credentials.key = query_components["apiKey"]
credentials.secret = query_components["apiSecret"]
credentials.api_key = query_components["apiKey"]
credentials.secret_key = query_components["apiSecret"]
credentials.scopes = query_components["scopes"].replace("+", "%20")

# Redirect to login page using API key submitted by user
Expand All @@ -316,12 +312,13 @@ def do_GET(self) -> None: # pragma: no cover
# Check if query path contains case insensitive "code="
code_match = re.search(r"code=(.+)", self.path, re.I)

if code_match and credentials.key and credentials.secret:
if code_match and credentials.api_key and credentials.secret_key:
user_auth_code = code_match[1]
token_access = convert_auth_code(credentials, user_auth_code)

# Clear stored info
credentials.clear()
credentials.api_key = ""
credentials.secret_key = ""

access_token = token_access["access_token"]
refresh_token = token_access["refresh_token"]
Expand Down

0 comments on commit 9bad005

Please sign in to comment.