-
Notifications
You must be signed in to change notification settings - Fork 50
Adds support for OAuth 2.0 #106
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
Open
climbercarmich
wants to merge
4
commits into
Voyz:master
Choose a base branch
from
climbercarmich:oauth2-feature
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
3153157
Adds support for OAuth 2.0 Client Credentials Grant flow for
climbercarmich 7bca364
Feat: Complete Easy & Medium tasks for OAuth 2.0 PR revisions
climbercarmich de4cd95
Refactor: Standardize OAuth2.0 request handling and session init
climbercarmich 96e41a9
Merge branch 'Voyz:master' into oauth2-feature
climbercarmich File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| """ | ||
| REST OAuth 2.0. | ||
|
|
||
| Showcases usage of OAuth 2.0 with IbkrClient. | ||
|
|
||
| This example demonstrates authenticating using OAuth 2.0 and making some basic API calls. | ||
|
|
||
| Using IbkrClient with OAuth 2.0 support will automatically handle generating the JWTs | ||
| and managing the SSO bearer token. You should be able to use all endpoints in the same | ||
| way as when not using OAuth. | ||
|
|
||
| IMPORTANT: In order to use OAuth 2.0, you're required to set up the following | ||
| environment variables: | ||
|
|
||
| - IBIND_USE_OAUTH: Set to True (or pass use_oauth=True to IbkrClient). | ||
| - IBIND_OAUTH2_CLIENT_ID: Your OAuth 2.0 Client ID. | ||
| - IBIND_OAUTH2_CLIENT_KEY_ID: Your OAuth 2.0 Client Key ID (kid). | ||
| - IBIND_OAUTH2_PRIVATE_KEY_PEM: Your OAuth 2.0 private key in PEM format. | ||
| To set this as an environment variable, you should replace newlines (\n) | ||
| in your PEM file with the literal characters '\\n'. | ||
| For example, if your key is: | ||
| ----BEGIN PRIVATE KEY----- | ||
| ABC\nDEF | ||
| -----END PRIVATE KEY----- | ||
| You would set the environment variable as: | ||
| IBIND_OAUTH2_PRIVATE_KEY_PEM="-----BEGIN PRIVATE KEY-----\\nABC\\nDEF\\n-----END PRIVATE KEY-----" | ||
| - IBIND_OAUTH2_USERNAME: Your IBKR username associated with the OAuth 2.0 app. | ||
|
|
||
| Optionally, you can also set these if they differ from the defaults: | ||
| - IBIND_OAUTH2_TOKEN_URL: Defaults to 'https://api.ibkr.com/oauth2/api/v1/token'. | ||
| - IBIND_OAUTH2_SSO_SESSION_URL: Defaults to 'https://api.ibkr.com/gw/api/v1/sso-sessions'. | ||
| - IBIND_OAUTH2_AUDIENCE: Defaults to '/token'. | ||
| - IBIND_OAUTH2_SCOPE: Defaults to 'sso-sessions.write'. | ||
| - IBIND_OAUTH2_REST_URL: Defaults to 'https://api.ibkr.com/v1/api/'. | ||
| - IBIND_OAUTH2_IP_ADDRESS: Your public IP address. If not set, the library will attempt to fetch it. | ||
|
|
||
| If you prefer setting these variables inline, you can pass an instance of the | ||
| OAuth2Config class as the 'oauth_config' parameter to the IbkrClient constructor. | ||
| This is especially useful if managing the PEM key via environment variables is cumbersome. | ||
|
|
||
| Example of dynamic configuration: | ||
| from ibind import IbkrClient, OAuth2Config | ||
|
|
||
| oauth_cfg = OAuth2Config( | ||
| client_id='your_client_id', | ||
| client_key_id='your_client_key_id', | ||
| private_key_pem='-----BEGIN PRIVATE KEY-----\nYOUR KEY HERE\n-----END PRIVATE KEY-----', # Direct string | ||
| username='your_ibkr_username' | ||
| # ... any other overrides | ||
| ) | ||
| client = IbkrClient(use_oauth=True, oauth_config=oauth_cfg) | ||
|
|
||
| This example assumes environment variables are set for simplicity. | ||
| """ | ||
|
|
||
| import os | ||
| from ibind import IbkrClient, ibind_logs_initialize | ||
| from ibind.oauth.oauth2 import OAuth2Config | ||
|
|
||
| # Initialize IBind logs (optional, but good for seeing what's happening) | ||
| ibind_logs_initialize() | ||
|
|
||
| cacert = os.getenv('IBIND_CACERT', False) # Standard cacert, though OAuth usually implies HTTPS | ||
|
|
||
| # --- Essential OAuth 2.0 Environment Variables --- | ||
| # These must be set for the example to run using environment-based configuration. | ||
| required_env_vars = [ | ||
| 'IBIND_OAUTH2_CLIENT_ID', | ||
| 'IBIND_OAUTH2_CLIENT_KEY_ID', | ||
| 'IBIND_OAUTH2_PRIVATE_KEY_PEM', | ||
| 'IBIND_OAUTH2_USERNAME', | ||
| 'IBIND_USE_OAUTH' # Ensure this is also set, typically to "True" | ||
| ] | ||
|
|
||
| missing_vars = [var_name for var_name in required_env_vars if not os.getenv(var_name)] | ||
|
|
||
| client = None | ||
| if missing_vars: | ||
| print("Missing required OAuth 2.0 environment variables for this example:") | ||
| for var_name in missing_vars: | ||
| print(f" - {var_name}") | ||
| print("Please set them to run this example, or configure OAuth2Config dynamically (see docstring).") | ||
| print("Exiting example.") | ||
| else: | ||
| if os.getenv('IBIND_USE_OAUTH', '').lower() != 'true': | ||
| print("Warning: IBIND_USE_OAUTH is not set to 'True'.") | ||
| print(" The client might not attempt OAuth 2.0 as expected by this example.") | ||
| # Proceeding anyway, as IbkrClient might still be configured via oauth_config directly below | ||
|
|
||
| print("Found required OAuth 2.0 environment variables. Initializing IbkrClient...") | ||
| # IbkrClient will use OAuth2Config by default if use_oauth=True and relevant OAuth2 env vars are present. | ||
| # Passing an explicit OAuth2Config() ensures it uses OAuth2 and loads from env vars. | ||
| try: | ||
| client = IbkrClient(cacert=cacert, use_oauth=True, oauth_config=OAuth2Config()) | ||
| print("IbkrClient initialized for OAuth 2.0.") | ||
| except Exception as e: | ||
| print(f"Error initializing IbkrClient: {e}") | ||
| client = None | ||
|
|
||
| if client: | ||
| print("\nAttempting a simple API call to confirm OAuth 2.0 authentication...") | ||
| try: | ||
| tickle_result = client.tickle() | ||
| if tickle_result and tickle_result.data and tickle_result.data.get('session') == 'authenticated': # Or other relevant check | ||
| print("Tickle successful! OAuth 2.0 authentication appears to be working.") | ||
| # Optionally print some part of tickle_result.data for confirmation | ||
| # print(f"Tickle data: {tickle_result.data}") | ||
| elif tickle_result and tickle_result.data: # Successful call but maybe not the expected auth confirmation | ||
| print("Tickle call successful, but session status might not be 'authenticated' or as expected.") | ||
| print(f"Tickle data: {tickle_result.data}") | ||
| else: | ||
| print("Tickle call failed or did not return expected data.") | ||
| if tickle_result: | ||
| print(f"Tickle result status: {tickle_result.status_code}, Raw response: {tickle_result.raw_response}") | ||
| else: | ||
| print("Tickle call returned no result object.") | ||
|
|
||
| except Exception as e: | ||
| print(f"\nAn error occurred during the API call: {e}") | ||
| import traceback | ||
| traceback.print_exc() | ||
| finally: | ||
| if hasattr(client, 'close'): | ||
| print("\nClosing client session.") | ||
| client.close() | ||
| else: | ||
| # This part is reached if client initialization failed earlier (e.g. missing env vars) | ||
| # The message about missing env vars or init error would have already been printed. | ||
| pass # No further action needed here, initial error messages suffice | ||
|
|
||
| print("\nExample finished.") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -211,58 +211,62 @@ def _request( | |
| """ | ||
| Wrapper function which allows overriding the default request and error handling logic in the subclass. | ||
| """ | ||
| request_params = filter_none(kwargs) | ||
| attempt = 1 | ||
| while attempt <= self._max_retries: | ||
| current_base_url = base_url if base_url is not None else self.base_url | ||
| request_url = f'{current_base_url}{endpoint.lstrip("/")}' | ||
|
|
||
| base_url = base_url if base_url is not None else self.base_url | ||
| all_headers = self._get_headers(method, request_url) # Get base headers from subclass | ||
| if extra_headers: | ||
| all_headers.update(extra_headers) # Add/override with any specific extra_headers | ||
|
|
||
| endpoint = endpoint.lstrip('/') | ||
| url = f'{base_url}{endpoint}' | ||
|
|
||
| headers = self._get_headers(request_method=method, request_url=url) | ||
| headers = {**headers, **(extra_headers or {})} | ||
|
|
||
| # we want to allow default values used by IBKR, so we remove all None parameters | ||
| kwargs = filter_none(kwargs) | ||
|
|
||
| # choose which function should be used to make a reqeust based on use_session field | ||
| request_function = self._session.request if self.use_session else requests.request | ||
|
|
||
| # we repeat the request attempts in case of ReadTimeouts up to max_retries | ||
| for attempt in range(self._max_retries + 1): | ||
| if log: | ||
| self.logger.info(f'{method} {url} {kwargs}{" (attempt: " + str(attempt) + ")" if attempt > 0 else ""}') | ||
|
|
||
| try: | ||
| response = request_function(method, url, verify=self.cacert, headers=headers, timeout=self._timeout, **kwargs) | ||
| result = Result(request={'url': url, **kwargs}) | ||
| return self._process_response(response, result) | ||
| self.logger.info(f'Attempt {attempt}: {method} {request_url} Headers: {all_headers} Kwargs: {request_params}') | ||
|
|
||
| except ReadTimeout as e: | ||
| if attempt >= self._max_retries: | ||
| raise TimeoutError(f'{self}: Reached max retries ({self._max_retries}) for {method} {url} {kwargs}') from e | ||
| result = Result(request={ | ||
| 'method': method, | ||
| 'url': request_url, | ||
| 'headers': all_headers, | ||
| 'params': request_params | ||
| }) | ||
|
|
||
| self.logger.info(f'{self}: Timeout for {method} {url} {kwargs}, retrying attempt {attempt + 1}/{self._max_retries}') | ||
| _LOGGER.info(f'{self}: Timeout for {method} {url} {kwargs}, retrying attempt {attempt + 1}/{self._max_retries}') | ||
|
|
||
| continue # Continue to the next iteration for a retry | ||
|
|
||
| except (ConnectionError, ChunkedEncodingError) as e: | ||
| self.logger.warning( | ||
| f'{self}: Connection error detected, resetting session and retrying attempt {attempt + 1}/{self._max_retries} :: {str(e)}' | ||
| ) | ||
| _LOGGER.warning( | ||
| f'{self}: Connection error detected, resetting session and retrying attempt {attempt + 1}/{self._max_retries} :: {str(e)}' | ||
| ) | ||
| self.close() | ||
| try: | ||
| if self.use_session: | ||
| self.make_session() # Recreate session automatically | ||
| continue # Retry the request with a fresh session | ||
|
|
||
| except ExternalBrokerError: | ||
| raise | ||
|
|
||
| response = self._session.request( | ||
| method, | ||
| request_url, | ||
| headers=all_headers, | ||
| timeout=self._timeout, | ||
| verify=self.cacert, | ||
| **request_params | ||
| ) | ||
| else: | ||
| response = requests.request( | ||
| method, | ||
| request_url, | ||
| headers=all_headers, | ||
| timeout=self._timeout, | ||
| verify=self.cacert, | ||
| **request_params | ||
| ) | ||
| return self._process_response(response, result) | ||
| except (ReadTimeout, Timeout) as e: | ||
| self.logger.warning(f'Attempt {attempt} timed out for {method} {request_url}: {e}') | ||
| if attempt == self._max_retries: | ||
| raise TimeoutError(f'{self}: Max retries reached for {method} {request_url}') from e | ||
| attempt += 1 | ||
| except ChunkedEncodingError as e: | ||
| # Treat ChunkedEncodingError similar to a timeout for retry purposes | ||
| self.logger.warning(f'Attempt {attempt} failed with ChunkedEncodingError for {method} {request_url}: {e}') | ||
| if attempt == self._max_retries: | ||
| raise ExternalBrokerError(f'{self}: Max retries reached for {method} {request_url} after ChunkedEncodingError') from e | ||
| attempt += 1 | ||
| except Exception as e: | ||
| self.logger.exception(e) | ||
| raise ExternalBrokerError(f'{self}: request error: {str(e)}') from e | ||
| self.logger.error(f'{self}: Request {method} {request_url} failed: {e}') | ||
| raise ExternalBrokerError(f'{self}: Request {method} {request_url} failed') from e | ||
| # This line should not be reached if logic is correct, but as a fallback: | ||
| raise ExternalBrokerError(f'{self}: Request {method} {request_url} failed after {self._max_retries} retries without specific exception.') | ||
|
Comment on lines
+214
to
+269
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm struggling to understand this rewrite. Is there any change here that is required for the OAuth 2.0 flow we're introducing? It seems like just replacing one way of doing requests by another way. Some changes you introduce raise some questions:
|
||
|
|
||
| def _process_response(self, response, result: Result) -> Result: | ||
| try: | ||
|
|
||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.