Skip to content

Conversation

@climbercarmich
Copy link

@climbercarmich climbercarmich commented May 15, 2025

Hi maintainers,

Closes #102 .

This PR extends IBind's authentication capabilities by adding support for the OAuth 2.0 Client Credentials Grant flow. This complements the existing OAuth 1.0a implementation and further enables fully headless authentication with the IBKR Client Portal API. I've aimed to follow the project's contribution guidelines in preparing this feature.

Key changes include:

  • A new ibind.oauth.oauth2 module with OAuth2Config for configuration and logic for token management.
  • Integration of OAuth 2.0 into IbkrClient, handling automatic SSO bearer token acquisition and session management.
  • Addition of new environment variables for OAuth 2.0 configuration, documented in the README.
  • A new example script, examples/rest_09_oauth2.py, to demonstrate practical usage.
  • An end-to-end test script, test/e2e/test_ibind_oauth2.py (which loads credentials from a root .env file), to verify the E2E functionality.
  • Updates to README.md with information on OAuth 2.0 setup and usage.
  • Necessary updates to pyproject.toml and requirements-dev.txt for dependencies (pycryptodome for core functionality, python-dotenv for examples/dev).
  • The codebase for this PR has been formatted using Ruff.

Further Notes:

  • This implementation provides the full end-to-end OAuth 2.0 functionality as demonstrated by the E2E test script. More granular unit tests for the OAuth module could be a future enhancement for the project.
  • I used the official IBKR OAuth 2.0 setup guide for this implementation and can clarify details from it if helpful during the review process.

Please let me know if you have any questions or feedback.
Thanks!

authenticating with the IBKR Client Portal API. This allows for
fully headless operation without needing manual gateway logins.

Key changes:
- New `ibind.oauth.oauth2` module with `OAuth2Config` and token management.
- Integrated OAuth 2.0 into `IbkrClient`, including automatic
  SSO bearer token acquisition and session handling.
- Added new environment variables for OAuth 2.0 configuration (see README).
- Included a new example `examples/rest_09_oauth2.py` demonstrating usage.
- Added an end-to-end test script `test/e2e/test_ibind_oauth2.py`
  (loads credentials from root `.env`).
- Updated README.md with information on OAuth 2.0 setup and usage.
- Updated pyproject.toml and requirements-dev.txt for python-dotenv.
@weklund
Copy link
Contributor

weklund commented May 15, 2025

This is great! Thanks so much for putting the time and effort into this! Before I go deep and review the code itself, I'd like to understand how we can gain confidence in this change for IBind.

Things I'm curious about:

  • What's the current IBKR pattern for OAuth2? Did you mention they have a support page for this now? Is it officially supported?
  • How we as IBind maintainers plan to throughly test this as not all of us has an IBKR account that can support OAuth use
  • What's the current DX for consumers to decide which OAuth to use? Did we just have a config for it? (Yes I understand it's probably defined in the code, but at a high level would like to know from requirements POV)
  • What was your reasoning for using pycryptodome vs cryptography ? We've talked about OAuth2 and even refactoring OAuth1 to use cryptography because it's has a dedicated security team, higher level constructs, plus pycryptodome makes it easier for us to implement cryptography incorrectly

What do you think?

@climbercarmich
Copy link
Author

Hi Wes,

Thanks so much for taking a look and for the positive feedback! I'm happy to clarify these points:

  1. What's the current IBKR pattern for OAuth2? Did you mention they have a support page for this now? Is it officially supported?
    • Yes, Interactive Brokers now officially supports OAuth 2.0 for authenticating with their Client Portal API, though it seems primarily targeted at their business users at this stage.
    • I've been in touch with IBKR API Integration, and they've provided documentation and Postman collections outlining the specific OAuth 2.0 workflow. The general flow involves:
      1. Requesting an OAuth 2.0 Access Token (/oauth2/api/v1/token).
      2. Creating an SSO Session for the CP Web API (/gw/api/v1/sso-sessions).
      3. Validating the SSO Session (/v1/api/sso/validate).
      4. Initializing a brokerage session to access trading & market data (/v1/api/iserver/auth/ssodh/init).
    • This implementation follows their guidelines. The successful execution of the end-to-end tests against their systems with this implementation gives us confidence that it aligns with their requirements.

Python Code I was sent:
Websocket tester.txt
Authentication.txt
CP API tester.txt

  1. How we as IBind maintainers plan to thoroughly test this as not all of us have an IBKR account that can support OAuth use?

    • This is a valid concern. I've personally tested this implementation thoroughly with an OAuth 2.0 enabled account and am currently using this fork for my own applications that require it. The end-to-end test script (test/e2e/test_ibind_oauth2.py) reflects these successful interactions.
  2. What's the current DX for consumers to decide which OAuth to use? Did we just have a config for it?

    • Yes, the developer experience is designed to be consistent with how OAuth 1.0a is handled in ibind and offers flexibility.

    • To use OAuth 2.0, a consumer would initialize an OAuth2Config object. This can be done in two ways:

      • Explicitly: By passing credentials directly to the constructor:
        from ibind import OAuth2Config
        oauth2_config_instance = OAuth2Config(
            client_id="YOUR_CLIENT_ID",
            client_key_id="YOUR_CLIENT_KEY_ID",
            private_key_pem="YOUR_PRIVATE_KEY_PEM_STRING",
            username="YOUR_IBKR_USERNAME"
        )
      • Via Environment Variables: If the arguments are omitted, OAuth2Config() will attempt to load the credentials from the following environment variables:
        • IBIND_OAUTH2_CLIENT_ID
        • IBIND_OAUTH2_CLIENT_KEY_ID
        • IBIND_OAUTH2_PRIVATE_KEY_PEM
        • IBIND_OAUTH2_USERNAME
    • Then, this oauth_config instance is passed to the IBKRClient:

      from ibind import IBKRClient
      # Assuming oauth2_config_instance is created as shown above
      client = IBKRClient(use_oauth=True, oauth_config=oauth2_config_instance)
    • This mirrors the existing pattern for OAuth 1.0a. The IBKRClient inspects the type of the oauth_config object to determine which OAuth flow to use.

    • The example script examples/rest_09_oauth2.py demonstrates initializing OAuth2Config (and loading from a .env file, which sets up these environment variables) and then passing it to IBKRClient.

I hope this provides a clearer picture. This implementation has been working reliably for me, and I've aimed to align it closely with existing ibind patterns. Please let me know your further thoughts or any specific areas you'd like to discuss or see adjusted.

Copy link
Owner

@Voyz Voyz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@climbercarmich what a fantastic initiative and contribution 👏 First of all, thank you for putting in the time and writing all this! It's awesome how you've attempted to follow various coding standards already present in the library, and did due diligence on providing thorough comments and updating things where necessary. Honestly, superb job! 🙌

I finished the review - I know it's a lot so please feel free to take your time addressing these points.

One comment that I want to leave here as it applies to the entire change is that I'd change almost all of the _LOGGER.info calls to _LOGGER.debug calls, or get rid of them completely. I think that the info calls should inform the user of something that is noteworthy for them - for example that OAuth2.0 will be used, or that their IP will be automatically deduced - but not of the result of every step along the process if things go nominally.

Also, thanks @weklund for dropping in some great questions. I think I can add 2 cents to question number two:

How we as IBind maintainers plan to throughly test this as not all of us has an IBKR account that can support OAuth use

In all fairness, we don't. OAuth 1.0a was also contributed without me having access to OAuth and it worked out well. This means that we need to count on users doing these verifications for us. This makes the maintenance more difficult, but frankly I think it's the only way to do it, and we just need to accept it. Ideally, I'd have several accounts, some with OAuth some without, but I don't currently have resources to cover so many bases on my own - this may change as we share the maintenance responsibility more.

Comment on lines 58 to 73
# --- Add python-dotenv logic to load .env from project root ---
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
# Project root is one level up from the 'examples' directory
PROJECT_ROOT = os.path.dirname(SCRIPT_DIR)
dotenv_path_project_root = os.path.join(PROJECT_ROOT, '.env')

try:
from dotenv import load_dotenv
if os.path.exists(dotenv_path_project_root):
print(f"Loading environment variables from project root: {dotenv_path_project_root}")
load_dotenv(dotenv_path=dotenv_path_project_root)
else:
print(f".env file not found in project root ({PROJECT_ROOT}). Will rely on system environment or pre-set vars.")
except ImportError:
print("python-dotenv library not found. Cannot load .env file. Ensure it is installed (`uv pip install python-dotenv`).")
# --- End of python-dotenv logic ---
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Loading in environment variables is straightforward enough that each user can figure it out on their own. Dontenv is one way of doing it, but not the only one, and it does introduce a dependency unnecessarily. Hence, I think we shouldn't suggest using it in this way

Comment on lines 90 to 113
# Check if core OAuth2 env vars are present to decide which path to take for the example
# This is just for the example's logic; in your own code, you'd choose one method.
if (os.getenv('IBIND_OAUTH2_CLIENT_ID') and
os.getenv('IBIND_OAUTH2_CLIENT_KEY_ID') and
os.getenv('IBIND_OAUTH2_PRIVATE_KEY_PEM') and
os.getenv('IBIND_OAUTH2_USERNAME')):
print("Found OAuth 2.0 environment variables. Initializing IbkrClient with use_oauth=True.")
print("Ensure IBIND_USE_OAUTH is also set to True in your environment, or use_oauth=True is passed explicitly.")
# When using env vars, IbkrClient will internally create an OAuth2Config if it detects
# that it should be using OAuth2 based on some logic (e.g. if specific OAuth2 vars are present)
# or if you explicitly pass an OAuth2Config instance.
# For this example, assuming IBIND_USE_OAUTH=True is set in the environment
# and IbkrClient correctly picks OAuth2Config based on available env vars.
# A more explicit way when relying on env vars for OAuth2 would be:
# client = IbkrClient(cacert=cacert, use_oauth=True, oauth_config=OAuth2Config())
# This tells it to definitely use OAuth2Config, which will then pull from env vars.
client = IbkrClient(cacert=cacert, use_oauth=True, oauth_config=OAuth2Config())
print("IbkrClient initialized. Will attempt API calls.")
else:
print("Core OAuth 2.0 environment variables not found.")
print("Please set IBIND_OAUTH2_CLIENT_ID, IBIND_OAUTH2_CLIENT_KEY_ID, IBIND_OAUTH2_PRIVATE_KEY_PEM, and IBIND_OAUTH2_USERNAME.")
print("Alternatively, configure OAuth2Config dynamically in the script (see example comments).")
print("Exiting example as configuration is missing.")
client = None # Ensure client is None if not configured
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a little too verbose. We already specified what env vars are required in the header of this file and we can safely assume the user understood it the first time around. While it definitely gets points for double checking the environment, it does introduce noise and make the example less readable. Also, that if client: check later may be confusing to some. I think the examples should be as minimal as possible, to act as an aid in documentation, even if it is at a cost of some good practices. In other words: the code you've built is absolutely valid and useful, just a little too involved for an example file.

Comment on lines 117 to 181
print('\n#### Attempting to tickle (check connection/authentication) ####')
tickle_result = client.tickle()
if tickle_result and tickle_result.data:
print(f'Tickle successful: {tickle_result.data}')
else:
print(f'Tickle call did not return expected data or failed. Result: {tickle_result}')

print('\n\n#### get_accounts ####')
# Ensure account_id is set if operations require it, though portfolio_accounts might not.
# client.account_id = 'YourSpecificAccountId' # If needed for other calls
accounts_result = client.portfolio_accounts()
if accounts_result and accounts_result.data:
accounts = accounts_result.data
print(accounts)
# Set account_id from the first found account for subsequent calls if needed
if isinstance(accounts, list) and len(accounts) > 0 and 'accountId' in accounts[0]:
client.account_id = accounts[0]['accountId']
print(f"Set client.account_id to: {client.account_id}")
else:
print("Could not determine accountId from portfolio_accounts response.")
else:
print(f'get_accounts call did not return expected data or failed. Result: {accounts_result}')

# Check authentication status if available
if hasattr(client, 'authentication_status'):
print('\n\n#### authentication_status ####')
auth_status_result = client.authentication_status()
if auth_status_result and auth_status_result.data:
print(auth_status_result.data)
else:
print(f'authentication_status call did not return expected data or failed. Result: {auth_status_result}')

if client.account_id: # Proceed only if account_id was set
print('\n\n#### get_ledger ####')
ledger_result = client.get_ledger(client.account_id)
if ledger_result and ledger_result.data:
ledger = ledger_result.data
for currency, subledger in ledger.items():
print(f'\t Ledger currency: {currency}')
print(f'\t cash balance: {subledger.get("cashbalance", "N/A")}')
print(f'\t net liquidation value: {subledger.get("netliquidationvalue", "N/A")}')
print(f'\t stock market value: {subledger.get("stockmarketvalue", "N/A")}')
print()
else:
print(f'get_ledger call did not return expected data or failed. Result: {ledger_result}')

print('\n#### get_positions ####')
positions_result = client.positions(client.account_id)
if positions_result and positions_result.data:
positions = positions_result.data
for position in positions:
print(f'\t Position {position.get("ticker", "N/A")}: {position.get("position", 0)} (${position.get("mktValue", 0.0)})')
else:
print(f'get_positions call did not return expected data or failed. Result: {positions_result}')
else:
print("\nSkipping Ledger and Positions calls as client.account_id is not set.")

except Exception as e:
print(f"\nAn error occurred during API calls: {e}")
import traceback
traceback.print_exc()
finally:
if hasattr(client, 'close'):
print("\nClosing client session.")
client.close() # Important to close to allow oauth_shutdown to be called
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly as in the other comment, showcasing all these functionalities is unnecessary in this example file. The focus really only should be on OAuth 2.0, followed by a simple call to verify that the setup was successful: more of "hey look, it worked!", less of "and now you can do all these things with it!". Again, nothing wrong with the code you wrote in principle, just too long for an example file.

auto_register_shutdown: bool = var.IBIND_AUTO_REGISTER_SHUTDOWN,
use_oauth: bool = var.IBIND_USE_OAUTH,
oauth_config: 'OAuthConfig' = None,
oauth_config: Union[OAuthConfig, OAuth1aConfig, OAuth2Config] = None, # Updated typing
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that # Updated typing seems like a WIP note left behind

Comment on lines 20 to 22
from ibind.oauth import OAuthConfig # Base class
from ibind.oauth.oauth1a import OAuth1aConfig
from ibind.oauth.oauth2 import OAuth2Config, authenticate_oauth2
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oauth 1.0a and now also 2.0 are optional extensions of IBind, they require additional dependencies to be installed, hence importing these modules has to happen only once _use_oauth is set to True. Doing it here will cause an import error for users who don't use OAuth. See the way it is carried out currently on the master branch

ibind/var.py Outdated
""" IBKR WebSocket API URL when using OAuth 2.0. Defaults to IBIND_WS_URL or wss://api.ibkr.com/v1/api/ws in OAuth2Config. """

IBIND_OAUTH2_IP_ADDRESS = os.getenv('IBIND_OAUTH2_IP_ADDRESS', None)
""" Optional: Pre-configured IP address for OAuth 2.0. If None, it will be auto-fetched. """
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Optional: prefix here is redundant, a lot of these env vars are optional, indicated by None default

pyproject.toml Outdated
Comment on lines 30 to 31
dev = [
"python-dotenv>=0.21" # For .env loading in tests/scripts
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to my earlier comment, I wouldn't add this. It can be set up by the user (or in this case - the developer) based on their preferences

ruff>=0.9.4,<0.10.0
bandit>=1.8.2,<2.0.0

python-dotenv>=1.0.1,<2.0.0
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above on the dotenv topic

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Super useful that you've prepared and shared this 🙌 Despite this being an e2e test, I'd still suggest it is built using a testing suite such as unittest - with setup, acting and assertions. Note we're currently discussing introducing pytest in #96.

@Voyz
Copy link
Owner

Voyz commented May 16, 2025

A separate shout out to @hughandersen who introduced the OAuth 1.0a and helped set up foundation for how this PR could be introduced.

Additionally, kudos to @janfrederik who's accurately deduced that OAuth 2.0 may soon become a viable option against my suggestions that it may not be the case - your comment helped us not having to rewrite a bunch of things right now, thanks and good foresight! 🙌

@hughandersen
Copy link
Contributor

Thanks @Voyz, the addition of OAuth 1.0a to your package seems to have been well received.
Fyi OAuth 2.0 is easier to set up, and I believe IB will roll it out some time in the future.

@janfrederik
Copy link

A separate shout out to @hughandersen who introduced the OAuth 1.0a and helped set up foundation for how this PR could be introduced.

Additionally, kudos to @janfrederik who's accurately deduced that OAuth 2.0 may soon become a viable option against my suggestions that it may not be the case - your comment helped us not having to rewrite a bunch of things right now, thanks and good foresight! 🙌

Thank you, @Voyz.

Thanks @Voyz, the addition of OAuth 1.0a to your package seems to have been well received. Fyi OAuth 2.0 is easier to set up, and I believe IB will roll it out some time in the future.

I hope they roll out that soon to individual accounts as well. oauth2 has been a while sitting there at IBKR...

This commit addresses all "Easy" and "Medium" effort tasks outlined
in the `ibind_oauth2_imp_plan.md`. These changes include:

- Dependency updates (pytest, dotenv removal).
- Numerous logging level adjustments (info to debug).
- Code simplifications and clarifications in `IbkrClient` and `oauth2.py`.
- Refinement of OAuth 2.0 header handling and request payload for SSO.
- Conditional imports for OAuth modules.
- Docstring and comment improvements.
- Restructuring of E2E tests using pytest.
- Streamlining of the OAuth 2.0 example script.

As a result of these improvements and fixes, all OAuth 2.0 E2E
tests in `test/e2e/test_ibind_oauth2.py` are now passing.

Ref: exponencia-capital-engine/docs/trading/ibind_oauth2_imp_plan.md
This commit completes two significant refactoring tasks outlined in the
OAuth 2.0 implementation plan (docs/trading/ibind_oauth2_imp_plan.md):

1.  Refactored `IbkrClient.oauth_init` for OAuth 2.0:
    -   Moved the brokerage session initialization logic (after obtaining
        the SSO token) into a new dedicated function,
        `establish_oauth2_brokerage_session`, within
        `ibind/oauth/oauth2.py`.
    -   This function now accepts the `IbkrClient` instance and performs
        the necessary /sso/validate and brokerage session initialization
        calls, improving clarity and reducing nesting in `IbkrClient`.

2.  Refactored `ibind.oauth.oauth2.OAuth2Handler`:
    -   The `OAuth2Handler` class now uses the main `IbkrClient` instance
        for making all HTTP requests, instead of managing its own
        `requests.Session`.
    -   This change standardizes request handling, logging, and error
        checking, leveraging the existing mechanisms within `RestClient`
        and `IbkrClient`.

These changes enhance the modularity and maintainability of the OAuth 2.0
authentication flow. E2E tests for OAuth 2.0 pass successfully with
these modifications.
@climbercarmich
Copy link
Author

Hi @Voyz and @weklund,

Thanks again for taking the time to provide such detailed feedback and for your insightful questions – it's really appreciated! I've pushed up some updates to this PR that I hope address everything you've raised. I've also merged in the latest from master to keep things current.

Addressing @weklund's questions:

  • IBKR OAuth 2.0 Pattern & Support: This PR implements OAuth 2.0 using a server-to-server flow. To make configuration clear, there's now an OAuth2Config dataclass where users can provide their credentials for this type of connection.
  • Testing by Maintainers: Totally understand the challenge here. As @Voyz mentioned, feedback from the community using this will be key. I've continued to run the end-to-end tests with my own OAuth 2.0 setup, and everything is passing after these latest changes.
  • Developer Experience (Choosing OAuth Version): The choice of which OAuth version to use is now handled by the IbkrClient's use_oauth and oauth_config parameters:
    • If use_oauth is False, no OAuth is used.
    • If use_oauth is True:
      • Users will need to provide an OAuth2Config instance to oauth_config to use OAuth 2.0.
      • If they provide an OAuth1aConfig instance, the existing OAuth 1.0a flow will be used.
      • To make sure the configuration is explicit and correct, if oauth_config isn't provided when use_oauth is true, an exception is now raised.

Addressing @Voyz's feedback:

  • Thanks again for the thorough review and the encouraging words! I've worked through your specific points.
  • Logging Levels: Good call on the logging. I've gone through and changed most of the detailed operational logs in the OAuth 2.0 flow from _LOGGER.info to _LOGGER.debug. _LOGGER.info is now used more for key user-facing events, as you suggested.
  • Specific Code Review Points & Refactoring: I've actioned your detailed comments. As you recommended, this involved a fair bit of refactoring in the core OAuth 2.0 logic:
    1. The OAuth 2.0 session initialization logic that was in IbkrClient has been pulled out into its own function (establish_oauth2_brokerage_session, which now lives in ibind.oauth.oauth2.py). This new function has a more streamlined structure and less nesting.
    2. I've also refactored OAuth2Handler to use the main IbkrClient instance's request methods and session. The idea here was to standardize how HTTP requests are made and ensure everything is consistent with the client's existing patterns for requests, logging, and error handling.
      The goal with these changes was to make the OAuth 2.0 integration clearer, more robust, and a better fit with the existing client structure.
  • Other improvements: Alongside those bigger refactoring pieces, I've also addressed other items from your review. For instance, OAuth dependency imports are now conditional (so users who don't need OAuth won't hit an ImportError), URL parameter handling is more consistent, the _get_headers method is easier to read, I've added some helper methods to OAuth2Config (like has_sso_bearer_token()), updated dataclass comments, and switched the E2E test script over to use pytest fixtures. I also made the necessary updates to pyproject.toml and the example script regarding python-dotenv.

Summary of Recent Changes:

  1. The branch is now up-to-date with Voyz:master.
  2. The core OAuth 2.0 logic for getting tokens and setting up the brokerage session has been significantly refactored, as detailed above.
  3. A number of smaller fixes and improvements, based on your review feedback, have been made in IbkrClient, OAuth2Config, and the example script.
  4. All end-to-end tests for the OAuth 2.0 flow are passing with these latest changes.

I think these updates cover the points from your review and help make the OAuth 2.0 support more solid. Let me know what you think, or if there’s anything else!

Thanks!

Copy link
Owner

@Voyz Voyz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great progress @climbercarmich, following up with another round of review. Appreciate your patience, as I was away on short holiday last week 🙏

Great job on refactoring of the OAuth2.0 logic - it is much more readable now and it really helped me review it. Thanks! I helped you simplify it a little more still.

You mention:

For instance, OAuth dependency imports are now conditional (so users who don't need OAuth won't hit an ImportError)

This doesn't seem to be the case. The OAuth modules are still imported globally and will cause an error if we don't install it correctly. You want to move:

from ibind.oauth.oauth1a import OAuth1aConfig, generate_oauth_headers, req_live_session_token
from ibind.oauth.oauth2 import OAuth2Config, authenticate_oauth2, establish_oauth2_brokerage_session

to

        if self._use_oauth:
            from ibind.oauth.oauth1a import OAuth1aConfig, generate_oauth_headers, req_live_session_token
            from ibind.oauth.oauth2 import OAuth2Config, authenticate_oauth2, establish_oauth2_brokerage_session

updated dataclass comments

That also doesn't seem to be the case, but maybe I didn't express myself correctly. I'd suggest changing:

class OAuth2Config(OAuthConfig):
    """
    Dataclass encapsulating OAuth 2.0 configuration parameters.
    """

    # --- Core OAuth 2.0 Parameters ---
    client_id: str = var.IBIND_OAUTH2_CLIENT_ID
    client_key_id: str = var.IBIND_OAUTH2_CLIENT_KEY_ID

to:

class OAuth2Config(OAuthConfig):
    """
    Dataclass encapsulating OAuth 2.0 configuration parameters.
    """

    client_id: str = var.IBIND_OAUTH2_CLIENT_ID
    """ OAuth 2.0 Client ID. """

    client_key_id: str = var.IBIND_OAUTH2_CLIENT_KEY_ID
    """ OAuth 2.0 Client Key ID. """

Other than that, fantastic job with things like rewriting the e2e test file, removing dotenv dependency, changing logs from info to debug, and overall pushing this PR forward 👏

Comment on lines +214 to +269
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.')
Copy link
Owner

Choose a reason for hiding this comment

The 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:

  • moving url and header generation to within the attempts loop; these stay constant on each attempt and shouldn't need regeneration on each attempt, don't they?
  • adding if attempt == self._max_retries: breaking in ChunkedEncodingError - what's the reasoning behind it?
  • adding Timeout exception on top of ReadTimeout - do we ever run into 'Timeout'?

auto_register_shutdown: bool = var.IBIND_AUTO_REGISTER_SHUTDOWN,
use_oauth: bool = var.IBIND_USE_OAUTH,
oauth_config: 'OAuthConfig' = None,
oauth_config: Optional[Union[OAuthConfig, OAuth1aConfig, OAuth2Config]] = None,
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OAuth1aConfig and OAuth2Config are concrete implementations of OAuthConfig - there is no need for this change, is there?

Comment on lines +22 to +23
from ibind.oauth.oauth1a import OAuth1aConfig, generate_oauth_headers, req_live_session_token
from ibind.oauth.oauth2 import OAuth2Config, authenticate_oauth2, establish_oauth2_brokerage_session
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These need to be imported only if user sets use_oauth to True. OAuth is an optional extension, some users don't have its dependencies installed. I already commented on this in the previous review, please look into how the current master branch is handing this.


self._use_oauth = use_oauth
self.oauth_config = oauth_config
self.account_id = account_id # Set account_id early for logger
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is redundant


if url is None:
url = f'https://{host}:{port}{base_route}'
if self.oauth_config is None:
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is placed outside of if self._use_oauth:, which will cause to the exception always being thrown for users who don't use Oauth

except Exception as e_init_generic:
_LOGGER.error(f"{client}: A generic error occurred during initialize_brokerage_session(compete=True): {e_init_generic}")

except Exception as e_validate_sequence:
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this try/catch is too large, and non-specific. I'd recommend removing it

_LOGGER.error(f"An unexpected error occurred during OAuth 2.0 authentication process: {e}", exc_info=True)
return None

def establish_oauth2_brokerage_session(client: 'IbkrClient') -> None:
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I took the liberty to address the issues I've raised in this function and reduce its size by turning the reattempt process into a for loop:

def establish_oauth2_brokerage_session(client: 'IbkrClient', max_retries:int=2) -> None:
    """
    Establishes the brokerage session for an OAuth 2.0 authenticated client.

    This involves validating the SSO session and then initializing the brokerage session.
    """
    _LOGGER.debug(f"{client}: OAuth 2.0: Attempting to establish brokerage session (/sso/validate and initialize).")

    validation_result = client.validate()
    _LOGGER.debug(f"{client}: /sso/validate result: {validation_result.data if validation_result else 'No result'}")

    if not (validation_result.data.get('RESULT') or validation_result.data.get('authenticated')):
        _LOGGER.warning(
            f"{client}: /sso/validate did not indicate a clear success. "
            f"Cannot proceed with brokerage session initialization. "
            f"Validation data: {validation_result}"
        )
        return

    _LOGGER.debug(f"{client}: /sso/validate successful. Now attempting to initialize brokerage session.")
    for attempt in range(max_retries + 1):
        try:
            _LOGGER.debug(f"{client}: Calling initialize_brokerage_session(compete=True).")
            init_result = client.initialize_brokerage_session(compete=True)
            _LOGGER.debug(f"{client}: initialize_brokerage_session(compete=True) result: {init_result}")

            auth_status_after_init = client.authentication_status()
            _LOGGER.debug(f"{client}: /iserver/auth/status (after compete=True init): {auth_status_after_init}")
            if not auth_status_after_init.data.get('authenticated'):
                _LOGGER.warning(f"{client}: Still not authenticated after compete=True init.")

        except ExternalBrokerError as e_init_compete_true:
            _LOGGER.error(f"{client}: establishing oauth2 brokerage session failed: {e_init_compete_true}")
            if attempt >= max_retries:
                raise e_init_compete_true
            if e_init_compete_true.status_code == 500 and "failed to generate sso dh token" in str(e_init_compete_true):
                _LOGGER.info(f"{client}: Retrying initialize_brokerage_session with compete=False due to DH token error. Retrying attempt {attempt + 1}/{max_retries}")
                continue
            else:
                # we don't retry for non-DH token errors
                break
            # else: other error from compete=True, not the DH token one. We just log it above.
        except Exception as e_init_generic:
            _LOGGER.error(f"{client}: A generic error occurred during initialize_brokerage_session(compete=True): {e_init_generic}")
            # we don't retry for generic errors
            break

"requests>=2.31",
"websocket-client>=1.7"
"websocket-client>=1.7",
"pycryptodome>=3.21" # Essential for OAuth 2.0 JWT signing
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this needs to be in the [project.optional-dependencies] as OAuth is not included in the default install

Comment on lines -29 to +30
oauth = [
'pycryptodome>=3.21',
'urllib3>=2.3',
dev = [
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this change also doesn't make sense, we need these dependencies

Comment on lines +51 to +53
# Configure basic logging for the test script itself - Pytest handles this.
# logger = logging.getLogger(__name__)
# logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is already called above, isn't it?

@art1c0
Copy link

art1c0 commented May 31, 2025

@climbercarmich thanks for the great work!
I'm already using OAuth1a and I'd also like to test your implementation of OAuth2: the question is how do I get required config parameters? Is there a process similar to OAuth1a setup (special IBKR page) or it's done purely via their support team?

@Voyz
Copy link
Owner

Voyz commented Jun 23, 2025

hey @climbercarmich just wanted to follow up on this as this contribution is great and I wouldn't want it to get left behind - although I understand if you're unavailable at the moment to look into it more. I know I've left detailed PR reviews here, so if you'd need more help feel free to reach out to me at [email protected] and let's chat about how we could finalise this in more detail.

@climbercarmich
Copy link
Author

@art1c0 you have to contact IBKR support directly currently.

@Voyz sorry not had any spare time in the last month. Hopefully it will be possible for me to finish this contribution in July. Thanks for your detailed review too.

@arau-j
Copy link

arau-j commented Sep 7, 2025

Dear ibind maintainer team, I would like to voice my support for a timely addition of oauth 2.

We have a ibkr business account with oauth 2 (setup through ibkr support email), we were also sent the same implementation examples: (Websocket tester.txt
Authentication.txt
CP API tester.txt)

Our internal implementation / fork matches @climbercarmich implementation ~90% and we would switch to the official implementation right away once it is merged.

@Voyz
Copy link
Owner

Voyz commented Sep 13, 2025

hey @arau-j thanks for showing your support to this PR 👍 It has been last reviewed in May but hasn't received further updates from its author - I'm assuming he's been busy and couldn't pick it up again. Should anyone feel like continuing their work, I'm happy to provide support and review further updates to this PR

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: Support for IBKR OAuth 2.0 Authentication

7 participants