Skip to content
Open
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
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ Closing message template:
```
Thanks for the contribution, however it appears to lack sufficient effort (eg. not consulting the documentation, stack trace, or method signatures) or contains unredacted AI generated code. To keep the issue/PR trackers focused and maintainable, we're closing this for now. Please review our contributing policies in the [CONTRIBUTING.md](https://github.com/Voyz/ibind/blob/master/CONTRIBUTING.md) file.

If you revise the contribution - focusing on the minimal relevant code, confirming it aligns with the library's API and demonstrating your attempt to tackle it - we'll be happy to take another look.
If you revise the contribution - focusing on the minimal relevant code, confirming it aligns with the library's API and demonstrating your attempt to tackle it - we'll be happy to take another look.
```

## Code Style
Expand Down
3 changes: 1 addition & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ else
PYTHONPATH=.:test $(PYTHON) -m pytest test/unit/ test/integration/ -v
endif

.PHONY: test-unit
.PHONY: test-unit
test-unit: ## Run only unit tests
ifeq ($(OS),Windows_NT)
cmd /c "set PYTHONPATH=.;test && $(PYTHON) -m pytest test/unit/ -v"
Expand Down Expand Up @@ -63,4 +63,3 @@ check-all: lint scan format test ## Run all checks (lint, scan, format, test)
.PHONY: help
help: # Show help for each of the Makefile recipes.
@grep -E '^[a-zA-Z0-9 -]+:.*#' Makefile | sort | while read -r l; do printf "\033[1;32m$$(echo $$l | cut -f 1 -d':')\033[00m:$$(echo $$l | cut -f 2- -d'#')\n"; done

4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ wrong? [Create an issue and let us know!][issues]*
</a>
</p>

IBind is an unofficial Python API client library for the [Interactive Brokers Client Portal Web API.][ibkr-docs] (recently rebranded to Web API 1.0 or CPAPI 1.0) It supports both REST and WebSocket APIs of the IBKR Web API 1.0. Now fully headless with [OAuth 1.0a][wiki-oauth1a] support.
IBind is an unofficial Python API client library for the [Interactive Brokers Client Portal Web API.][ibkr-docs] (recently rebranded to Web API 1.0 or CPAPI 1.0) It supports both REST and WebSocket APIs of the IBKR Web API 1.0. Now fully headless with support for [OAuth 1.0a][wiki-oauth1a] and OAuth 2.0.

_Note: IBind currently supports only the Web API 1.0 since the [newer Web API][web-api] seems to be still in beta and is not fully documented. Some of its features may work, but it is recommended to use the Web API 1.0's documentation for the time being. Once a complete version of the new Web API is released IBind will be extended to support it._

Expand All @@ -32,7 +32,7 @@ pip install ibind

## Authentication

IBind supports fully headless authentication using [OAuth 1.0a][wiki-oauth1a]. This means no longer needing to run any type software to communicate with IBKR API.
IBind supports fully headless authentication using [OAuth 1.0a][wiki-oauth1a] and OAuth 2.0. This means no longer needing to run any type of software to communicate with IBKR API.

Alternatively, use [IBeam][ibeam] along with this library for easier setup and maintenance of the CP Gateway.

Expand Down
4 changes: 2 additions & 2 deletions agents/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ The library is structured around two main client classes:
- **Purpose**: REST API client extending `RestClient` base class
- **Mixins**: Functionality is organized into mixins in `ibind/client/ibkr_client_mixins/`:
- `accounts_mixin.py` - Account operations
- `contract_mixin.py` - Contract/security operations
- `contract_mixin.py` - Contract/security operations
- `marketdata_mixin.py` - Market data operations
- `order_mixin.py` - Order management
- `portfolio_mixin.py` - Portfolio operations
Expand Down Expand Up @@ -108,4 +108,4 @@ The `examples/` directory contains comprehensive usage examples:
- WebSocket client requires careful lifecycle management (start/stop, subscription handling)
- Rate limiting and parallel request handling are built into the REST client
- All API endpoints follow IBKR's REST API documentation structure
- Environment variables can be configured via `.env` files (auto-patched via `patch_dotenv()`)
- Environment variables can be configured via `.env` files (auto-patched via `patch_dotenv()`)
73 changes: 73 additions & 0 deletions examples/rest_09_oauth2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""
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
from ibind.oauth.oauth2 import 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

ibind_logs_initialize()

client = IbkrClient(
cacert=os.getenv('IBIND_CACERT', False),
use_oauth=True,
oauth_config=OAuth2Config(),
)

try:
print(client.tickle().data)
finally:
client.close()
79 changes: 62 additions & 17 deletions ibind/client/ibkr_client.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

import importlib.util
import os
import time
Expand Down Expand Up @@ -78,16 +80,23 @@ def __init__(
auto_recreate_session (bool, optional): Whether to automatically recreate the session on connection errors. Defaults to True.
auto_register_shutdown (bool, optional): Whether to automatically register a shutdown handler for this client. Defaults to True.
use_oauth (bool, optional): Whether to use OAuth authentication. Defaults to False.
oauth_config (OAuthConfig, optional): The configuration for the OAuth authentication. OAuth1aConfig is used if not specified.
oauth_config (OAuthConfig, optional): The configuration for the OAuth authentication.
OAuth1aConfig is used if not specified.
"""
self._tickler: Optional[Tickler] = None
self._use_oauth = use_oauth
self.oauth_config = oauth_config

if self._use_oauth:
from ibind.oauth.oauth1a import OAuth1aConfig
from ibind.oauth.oauth2 import OAuth2Config

if self.oauth_config is None:
self.oauth_config = OAuth1aConfig()
elif not isinstance(self.oauth_config, (OAuth1aConfig, OAuth2Config)):
raise ValueError(f'Unsupported OAuth configuration type: {type(self.oauth_config)}')

# cast to OAuth1aConfig for type checking, since currently 1.0a is the only version used
self.oauth_config = cast(OAuth1aConfig, oauth_config) if oauth_config is not None else OAuth1aConfig()
self.oauth_config = cast('OAuthConfig', self.oauth_config)
url = url if url is not None and self.oauth_config.oauth_rest_url is None else self.oauth_config.oauth_rest_url

if url is None:
Expand All @@ -109,7 +118,7 @@ def __init__(

self.logger.info('#################')
self.logger.info(
f'New IbkrClient(base_url={self.base_url!r}, account_id={self.account_id!r}, ssl={self.cacert!r}, timeout={self._timeout}, max_retries={self._max_retries}, use_oauth={self._use_oauth})'
f'New IbkrClient(base_url={self.base_url!r}, account_id={self.account_id!r}, ssl={self.cacert!r}, timeout={self._timeout}, max_retries={self._max_retries}, use_oauth={self._use_oauth}, oauth_version={self.oauth_config.version() if self.oauth_config is not None else None})'
)

if self._use_oauth:
Expand All @@ -135,19 +144,32 @@ def _request(self, method: str, endpoint: str, base_url: str = None, extra_heade
raise

def _get_headers(self, request_method: str, request_url: str):
if (not self._use_oauth) or request_url == f'{self.base_url}{self.oauth_config.live_session_token_endpoint}':
# No need for extra headers if we don't use oauth or getting live session token
if not self._use_oauth or self.oauth_config is None:
return {}

# get headers for endpoints other than live session token request
from ibind.oauth.oauth1a import generate_oauth_headers
from ibind.oauth.oauth1a import OAuth1aConfig, generate_oauth_headers
from ibind.oauth.oauth2 import OAuth2Config

if isinstance(self.oauth_config, OAuth2Config):
if request_url in {self.oauth_config.token_url, self.oauth_config.sso_session_url}:
return {}

if not self.oauth_config.has_sso_bearer_token():
_LOGGER.error(f'{self}: OAuth 2.0 configured for {request_url}, but SSO bearer token is missing.')
return {}

return {'Authorization': f'Bearer {self.oauth_config.sso_bearer_token}'}

headers = generate_oauth_headers(
if not isinstance(self.oauth_config, OAuth1aConfig):
raise ValueError(f'Unsupported OAuth configuration type: {type(self.oauth_config)}')

if request_url == f'{self.base_url}{self.oauth_config.live_session_token_endpoint}':
return {}

return generate_oauth_headers(
oauth_config=self.oauth_config, request_method=request_method, request_url=request_url, live_session_token=self.live_session_token
)

return headers

def generate_live_session_token(self):
"""
Generates a new live session token for OAuth 1.0a authentication.
Expand Down Expand Up @@ -194,14 +216,28 @@ def oauth_init(self, maintain_oauth: bool, init_brokerage_session: bool):
"""
_LOGGER.info(f'{self}: Initialising OAuth {self.oauth_config.version()}')

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

if importlib.util.find_spec('Crypto') is None:
raise ImportError('Installation lacks OAuth support. Please install by using `pip install ibind[oauth]`')

# get live session token for OAuth authentication
self.generate_live_session_token()
if isinstance(self.oauth_config, OAuth2Config):
sso_token = authenticate_oauth2(self)
if not sso_token:
raise ExternalBrokerError('Failed to obtain OAuth 2.0 SSO Bearer Token during oauth_init')

if init_brokerage_session:
establish_oauth2_brokerage_session(self)

# validate the live session token once
from ibind.oauth.oauth1a import validate_live_session_token
if maintain_oauth:
self.start_tickler()
return

if not isinstance(self.oauth_config, OAuth1aConfig):
raise ValueError(f'Unsupported OAuth configuration type: {type(self.oauth_config)}')

self.generate_live_session_token()

success = validate_live_session_token(
live_session_token=self.live_session_token,
Expand Down Expand Up @@ -262,10 +298,19 @@ def oauth_shutdown(self):
This method stops the Tickler process, which keeps the session alive, and logs out from
the IBKR API to ensure a clean session termination.
"""
if not self._use_oauth or self.oauth_config is None:
return

_LOGGER.info(f'{self}: Shutting down OAuth')
self.stop_tickler()
self.logout()

from ibind.oauth.oauth2 import OAuth2Config

if isinstance(self.oauth_config, OAuth2Config):
self.oauth_config.sso_bearer_token = None
self.oauth_config.access_token = None

def handle_health_status(self, raise_exceptions: bool = False) -> bool:
warnings.warn("'handle_health_status' is deprecated. Calling it on a frequent basis is not recommended as IBKR expects /tickle call at most every 60 seconds. Use 'handle_auth_status' which utilises authentication_status() instead of tickle(), and use Tickler or manually ensure you call tickle() on a 60-second interval.", DeprecationWarning, stacklevel=2)

Expand Down Expand Up @@ -320,7 +365,7 @@ def _attempt_health_check(self, method: callable, raise_exceptions: bool = False
elif 'An attempt was made to access a socket in a way forbidden by its access permissions' in str(e):
_LOGGER.error('Connection to IBKR servers blocked during reauthentication. Check that nothing is blocking connectivity of the application')
elif e.status_code == 410 and 'gone' in str(e):
_LOGGER.error(f'OAuth 410 gone: recreate a new live session token, or try a different server, eg. "1.api.ibkr.com", "2.api.ibkr.com", etc.')
_LOGGER.error('OAuth 410 gone: recreate a new live session token, or try a different server, eg. "1.api.ibkr.com", "2.api.ibkr.com", etc.')
else:
_LOGGER.error(f'Unknown error checking IBKR connection during reauthentication: {exception_to_string(e)}')

Expand All @@ -330,4 +375,4 @@ def _attempt_health_check(self, method: callable, raise_exceptions: bool = False
_LOGGER.error(f'Error reauthenticating OAuth during reauthentication: {exception_to_string(e)}')
if raise_exceptions:
raise
return False
return False
Loading