ibauth 0.0.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ibauth-0.0.2/PKG-INFO ADDED
@@ -0,0 +1,47 @@
1
+ Metadata-Version: 2.4
2
+ Name: ibauth
3
+ Version: 0.0.2
4
+ Summary: Interactive Brokers OAuth
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: bump2version>=1.0.1
8
+ Requires-Dist: cryptography>=44.0.2
9
+ Requires-Dist: curlify>=2.2.1
10
+ Requires-Dist: mypy>=1.15.0
11
+ Requires-Dist: pre-commit>=4.1.0
12
+ Requires-Dist: pyjwt>=2.10.1
13
+ Requires-Dist: pyyaml>=6.0.2
14
+ Requires-Dist: requests>=2.32.3
15
+ Requires-Dist: ruff>=0.9.9
16
+ Requires-Dist: tenacity>=9.0.0
17
+
18
+ # IBKR Authentication Workflow
19
+
20
+ Documentation for the IBKR Web API is [here](https://www.interactivebrokers.com/campus/ibkr-api-page/webapi-ref/).
21
+
22
+ 1. Pull the repository.
23
+ 2. Create and activate a virtual environment.
24
+ 3. Install dependencies from `requirements.txt`.
25
+ 4. Create a YAML configuration file:
26
+
27
+ ```yaml
28
+ client_id: ""
29
+ client_key_id: ""
30
+ credential: ""
31
+ private_key_file: ""
32
+ ```
33
+
34
+ The private key file will usually have a `.pem` extension.
35
+ 5. Run the test script.
36
+
37
+ ```bash
38
+ python auth.py
39
+ ```
40
+
41
+ ## Installation
42
+
43
+ You can install from GitHub.
44
+
45
+ ```bash
46
+ pip3 install git+https://github.com/datawookie/ibkr-oauth-flow
47
+ ```
ibauth-0.0.2/README.md ADDED
@@ -0,0 +1,30 @@
1
+ # IBKR Authentication Workflow
2
+
3
+ Documentation for the IBKR Web API is [here](https://www.interactivebrokers.com/campus/ibkr-api-page/webapi-ref/).
4
+
5
+ 1. Pull the repository.
6
+ 2. Create and activate a virtual environment.
7
+ 3. Install dependencies from `requirements.txt`.
8
+ 4. Create a YAML configuration file:
9
+
10
+ ```yaml
11
+ client_id: ""
12
+ client_key_id: ""
13
+ credential: ""
14
+ private_key_file: ""
15
+ ```
16
+
17
+ The private key file will usually have a `.pem` extension.
18
+ 5. Run the test script.
19
+
20
+ ```bash
21
+ python auth.py
22
+ ```
23
+
24
+ ## Installation
25
+
26
+ You can install from GitHub.
27
+
28
+ ```bash
29
+ pip3 install git+https://github.com/datawookie/ibkr-oauth-flow
30
+ ```
@@ -0,0 +1,47 @@
1
+ Metadata-Version: 2.4
2
+ Name: ibauth
3
+ Version: 0.0.2
4
+ Summary: Interactive Brokers OAuth
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: bump2version>=1.0.1
8
+ Requires-Dist: cryptography>=44.0.2
9
+ Requires-Dist: curlify>=2.2.1
10
+ Requires-Dist: mypy>=1.15.0
11
+ Requires-Dist: pre-commit>=4.1.0
12
+ Requires-Dist: pyjwt>=2.10.1
13
+ Requires-Dist: pyyaml>=6.0.2
14
+ Requires-Dist: requests>=2.32.3
15
+ Requires-Dist: ruff>=0.9.9
16
+ Requires-Dist: tenacity>=9.0.0
17
+
18
+ # IBKR Authentication Workflow
19
+
20
+ Documentation for the IBKR Web API is [here](https://www.interactivebrokers.com/campus/ibkr-api-page/webapi-ref/).
21
+
22
+ 1. Pull the repository.
23
+ 2. Create and activate a virtual environment.
24
+ 3. Install dependencies from `requirements.txt`.
25
+ 4. Create a YAML configuration file:
26
+
27
+ ```yaml
28
+ client_id: ""
29
+ client_key_id: ""
30
+ credential: ""
31
+ private_key_file: ""
32
+ ```
33
+
34
+ The private key file will usually have a `.pem` extension.
35
+ 5. Run the test script.
36
+
37
+ ```bash
38
+ python auth.py
39
+ ```
40
+
41
+ ## Installation
42
+
43
+ You can install from GitHub.
44
+
45
+ ```bash
46
+ pip3 install git+https://github.com/datawookie/ibkr-oauth-flow
47
+ ```
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ ibauth.egg-info/PKG-INFO
4
+ ibauth.egg-info/SOURCES.txt
5
+ ibauth.egg-info/dependency_links.txt
6
+ ibauth.egg-info/requires.txt
7
+ ibauth.egg-info/top_level.txt
8
+ ibkr_oauth_flow/__init__.py
9
+ ibkr_oauth_flow/const.py
10
+ ibkr_oauth_flow/logger.py
11
+ ibkr_oauth_flow/util.py
@@ -0,0 +1,10 @@
1
+ bump2version>=1.0.1
2
+ cryptography>=44.0.2
3
+ curlify>=2.2.1
4
+ mypy>=1.15.0
5
+ pre-commit>=4.1.0
6
+ pyjwt>=2.10.1
7
+ pyyaml>=6.0.2
8
+ requests>=2.32.3
9
+ ruff>=0.9.9
10
+ tenacity>=9.0.0
@@ -0,0 +1 @@
1
+ ibkr_oauth_flow
@@ -0,0 +1,276 @@
1
+ import math
2
+ from typing import Any
3
+ import time
4
+ import yaml
5
+ from pathlib import Path
6
+
7
+ from cryptography.hazmat.primitives import serialization
8
+ from tenacity import retry, stop_after_attempt, wait_exponential
9
+
10
+ from .const import GRANT_TYPE, CLIENT_ASSERTION_TYPE, SCOPE, VALID_DOMAINS
11
+ from .util import make_jws, get, post, ReadTimeout, HTTPError
12
+
13
+ from .logger import logger
14
+
15
+
16
+ class IBKROAuthFlow:
17
+ def __init__(
18
+ self, client_id: str, client_key_id: str, credential: str, private_key_file: str, domain: str = "api.ibkr.com"
19
+ ):
20
+ if not client_id:
21
+ raise ValueError("Required parameter 'client_id' is missing.")
22
+
23
+ if not client_key_id:
24
+ raise ValueError("Required parameter 'client_key_id' is missing.")
25
+
26
+ if not credential:
27
+ raise ValueError("Required parameter 'credential' is missing.")
28
+
29
+ if not private_key_file:
30
+ raise ValueError("Required parameter 'private_key_file' is missing.")
31
+
32
+ if domain not in VALID_DOMAINS:
33
+ raise ValueError(f"Invalid domain: {domain}.")
34
+ else:
35
+ self.domain = domain
36
+
37
+ self.client_id = client_id
38
+ self.client_key_id = client_key_id
39
+ self.credential = credential
40
+
41
+ logger.info(f"Load private key from {private_key_file}.")
42
+ with open(private_key_file, "r") as file:
43
+ self.private_key = serialization.load_pem_private_key(
44
+ file.read().encode(),
45
+ password=None,
46
+ )
47
+
48
+ self.access_token = None
49
+ self.bearer_token = None
50
+
51
+ # These fields are set in the tickle() method.
52
+ #
53
+ self.authenticated = None
54
+ self.connected = None
55
+ self.competing = None
56
+
57
+ self.IP = None
58
+
59
+ @property
60
+ def url_oauth2(self) -> str:
61
+ return f"https://{self.domain}/oauth2"
62
+
63
+ @property
64
+ def url_gateway(self) -> str:
65
+ return f"https://{self.domain}/gw"
66
+
67
+ @property
68
+ def url_client_portal(self) -> str:
69
+ return f"https://{self.domain}"
70
+
71
+ @property
72
+ def domain(self) -> str:
73
+ return self._domain
74
+
75
+ @domain.setter
76
+ def domain(self, value: str) -> None:
77
+ """
78
+ Set and validate the domain.
79
+ """
80
+ if value not in VALID_DOMAINS:
81
+ raise ValueError(f"Invalid domain: {value}. Must be one of {VALID_DOMAINS}.")
82
+ logger.info(f"Domain: {value}")
83
+ self._domain = value
84
+
85
+ def _check_ip(self) -> Any:
86
+ """
87
+ Get public IP address.
88
+ """
89
+ logger.debug("Check public IP.")
90
+ IP = get("https://api.ipify.org", timeout=10).content.decode("utf8")
91
+
92
+ logger.info(f"Public IP: {IP}.")
93
+ if self.IP and self.IP != IP:
94
+ logger.warning("🚨 Public IP has changed.")
95
+
96
+ self.IP = IP
97
+ return IP
98
+
99
+ def _compute_client_assertion(self, url: str) -> Any:
100
+ now = math.floor(time.time())
101
+ header = {"alg": "RS256", "typ": "JWT", "kid": f"{self.client_key_id}"}
102
+
103
+ if url == f"{self.url_oauth2}/api/v1/token":
104
+ claims = {
105
+ "iss": f"{self.client_id}",
106
+ "sub": f"{self.client_id}",
107
+ "aud": "/token",
108
+ "exp": now + 20,
109
+ "iat": now - 10,
110
+ }
111
+
112
+ elif url == f"{self.url_gateway}/api/v1/sso-sessions":
113
+ claims = {
114
+ "ip": self.IP,
115
+ "credential": f"{self.credential}",
116
+ "iss": f"{self.client_id}",
117
+ "exp": now + 86400,
118
+ "iat": now,
119
+ }
120
+
121
+ logger.debug(f"Header: {header}.")
122
+ logger.debug(f"Claims: {claims}.")
123
+
124
+ return make_jws(header, claims, self.private_key)
125
+
126
+ def get_access_token(self) -> None:
127
+ """
128
+ Obtain an access token. This is the first step in the authentication
129
+ flow.
130
+
131
+ Returns:
132
+ str: The access token.
133
+ """
134
+ url = f"{self.url_oauth2}/api/v1/token"
135
+
136
+ headers = {"Content-Type": "application/x-www-form-urlencoded"}
137
+
138
+ form_data = {
139
+ "grant_type": GRANT_TYPE,
140
+ "client_assertion": self._compute_client_assertion(url),
141
+ "client_assertion_type": CLIENT_ASSERTION_TYPE,
142
+ "scope": SCOPE,
143
+ }
144
+
145
+ logger.info("Request access token.")
146
+ response = post(url=url, headers=headers, data=form_data)
147
+
148
+ self.access_token = response.json()["access_token"]
149
+
150
+ def get_bearer_token(self) -> None:
151
+ url = f"{self.url_gateway}/api/v1/sso-sessions"
152
+
153
+ headers = {
154
+ "Authorization": "Bearer " + self.access_token, # type: ignore
155
+ "Content-Type": "application/jwt",
156
+ }
157
+
158
+ # Initialise IP (it's embedded in the bearer token).
159
+ self._check_ip()
160
+
161
+ logger.info("Request bearer token.")
162
+ response = post(url=url, headers=headers, data=self._compute_client_assertion(url))
163
+
164
+ self.bearer_token = response.json()["access_token"]
165
+
166
+ @retry(stop=stop_after_attempt(5), wait=wait_exponential(multiplier=5)) # type: ignore
167
+ def ssodh_init(self) -> None:
168
+ """
169
+ Initialise a brokerage session.
170
+ """
171
+ url = f"{self.url_client_portal}/v1/api/iserver/auth/ssodh/init"
172
+
173
+ headers = {
174
+ "Authorization": "Bearer " + self.bearer_token, # type: ignore
175
+ "User-Agent": "python/3.11",
176
+ }
177
+
178
+ logger.info("Initiate a brokerage session.")
179
+ try:
180
+ response = post(url=url, headers=headers, json={"publish": True, "compete": True})
181
+ except HTTPError:
182
+ logger.error("â›” Error initiating a brokerage session.")
183
+ raise
184
+
185
+ logger.debug(f"Response content: {response.json()}.")
186
+
187
+ def validate_sso(self) -> None:
188
+ url = f"{self.url_client_portal}/v1/api/sso/validate"
189
+
190
+ headers = {
191
+ "Authorization": "Bearer " + self.bearer_token, # type: ignore
192
+ "User-Agent": "python/3.11",
193
+ }
194
+
195
+ logger.info("Validate brokerage session.")
196
+ response = get(url=url, headers=headers)
197
+
198
+ logger.debug(f"Response content: {response.json()}.")
199
+
200
+ @retry(stop=stop_after_attempt(5), wait=wait_exponential(multiplier=5)) # type: ignore
201
+ def tickle(self) -> str:
202
+ """
203
+ Keeps session alive.
204
+
205
+ Returns:
206
+ Session ID.
207
+ """
208
+ url = f"{self.url_client_portal}/v1/api/tickle"
209
+
210
+ headers = {
211
+ "Authorization": "Bearer " + self.bearer_token, # type: ignore
212
+ "User-Agent": "python/3.11",
213
+ }
214
+
215
+ logger.info("Send tickle.")
216
+ try:
217
+ response = get(url=url, headers=headers, timeout=10)
218
+ except (HTTPError, ReadTimeout):
219
+ logger.error("â›” Error connecting to session.")
220
+ self.get_bearer_token()
221
+ self.ssodh_init()
222
+ raise
223
+
224
+ self.session_id: str = response.json()["session"]
225
+ auth_status = response.json()["iserver"]["authStatus"]
226
+ self.authenticated = auth_status["authenticated"]
227
+ self.competing = auth_status["competing"]
228
+ self.connected = auth_status["connected"]
229
+
230
+ logger.debug(f"Session ID: {self.session_id}")
231
+ logger.debug(f"- authenticated: {self.authenticated}")
232
+ logger.debug(f"- competing: {self.competing}")
233
+ logger.debug(f"- connected: {self.connected}")
234
+
235
+ logger.debug(f"Response content: {response.json()}.")
236
+
237
+ return self.session_id
238
+
239
+ def logout(self) -> None:
240
+ url = f"{self.url_client_portal}/v1/api/logout"
241
+
242
+ if self.bearer_token is None:
243
+ logger.warning("🚨 Not terminating brokerage session (no bearer token found).")
244
+ return
245
+
246
+ headers = {
247
+ "Authorization": "Bearer " + self.bearer_token,
248
+ "User-Agent": "python/3.11",
249
+ }
250
+
251
+ logger.info("Terminate brokerage session.")
252
+ post(url=url, headers=headers)
253
+
254
+
255
+ def auth_from_yaml(path: str) -> IBKROAuthFlow:
256
+ """
257
+ Create an IBKROAuthFlow instance from a YAML configuration file.
258
+
259
+ Args:
260
+ path (str): The path to the YAML configuration file.
261
+
262
+ Returns:
263
+ IBKROAuthFlow: An instance of IBKROAuthFlow.
264
+ """
265
+ path_absolute = Path(path).resolve()
266
+ logger.info(f"Load configuration from {path_absolute}.")
267
+ with open(path_absolute, "r") as file:
268
+ config = yaml.safe_load(file)
269
+
270
+ return IBKROAuthFlow(
271
+ client_id=config["client_id"],
272
+ client_key_id=config["client_key_id"],
273
+ credential=config["credential"],
274
+ private_key_file=config["private_key_file"],
275
+ domain=config.get("domain", "api.ibkr.com"),
276
+ )
@@ -0,0 +1,14 @@
1
+ CLIENT_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
2
+ SCOPE = "sso-sessions.write"
3
+ GRANT_TYPE = "client_credentials"
4
+
5
+ VALID_DOMAINS = [
6
+ "api.ibkr.com",
7
+ "1.api.ibkr.com",
8
+ "2.api.ibkr.com",
9
+ "3.api.ibkr.com",
10
+ "4.api.ibkr.com",
11
+ "5.api.ibkr.com",
12
+ "6.api.ibkr.com",
13
+ "7.api.ibkr.com",
14
+ ]
@@ -0,0 +1,3 @@
1
+ import logging
2
+
3
+ logger = logging.getLogger("ibkr_oauth_flow")
@@ -0,0 +1,50 @@
1
+ import time
2
+ import jwt
3
+ import curlify
4
+ import requests
5
+ from typing import Any
6
+
7
+ from requests import Response
8
+ from requests.exceptions import HTTPError, ReadTimeout # noqa
9
+
10
+ from .logger import logger
11
+
12
+ __all__ = ["ReadTimeout", "HTTPError"]
13
+
14
+ from .const import *
15
+
16
+ RESP_HEADERS_TO_PRINT = ["Cookie", "Cache-Control", "Content-Type", "Host"]
17
+
18
+
19
+ def log_response(response: Response) -> None:
20
+ logger.debug(f"Request: {curlify.to_curl(response.request)}")
21
+ logger.debug(f"Response: {response.status_code} {response.text}")
22
+ response.raise_for_status()
23
+
24
+
25
+ def get(url: str, headers: dict[str, str] | None = None, timeout: float | None = None) -> requests.Response:
26
+ logger.debug(f"🔄 GET {url}")
27
+ response = requests.get(url, headers=headers, timeout=timeout)
28
+ log_response(response)
29
+ return response
30
+
31
+
32
+ def post(
33
+ url: str,
34
+ data: dict[str, Any] | None = None,
35
+ json: dict[str, Any] | None = None,
36
+ headers: dict[str, str] | None = None,
37
+ timeout: float | None = None,
38
+ ) -> requests.Response:
39
+ logger.debug(f"🔄 POST {url}")
40
+ response = requests.post(url, data=data, json=json, headers=headers, timeout=timeout)
41
+ log_response(response)
42
+ return response
43
+
44
+
45
+ def make_jws(header: dict[str, Any], claims: dict[str, Any], clientPrivateKey: Any) -> Any:
46
+ # Set expiration time.
47
+ claims["exp"] = int(time.time()) + 600
48
+ claims["iat"] = int(time.time())
49
+
50
+ return jwt.encode(claims, clientPrivateKey, algorithm="RS256", headers=header)
@@ -0,0 +1,34 @@
1
+ [project]
2
+ name = "ibauth"
3
+ version = "0.0.2"
4
+ description = "Interactive Brokers OAuth"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "bump2version>=1.0.1",
9
+ "cryptography>=44.0.2",
10
+ "curlify>=2.2.1",
11
+ "mypy>=1.15.0",
12
+ "pre-commit>=4.1.0",
13
+ "pyjwt>=2.10.1",
14
+ "pyyaml>=6.0.2",
15
+ "requests>=2.32.3",
16
+ "ruff>=0.9.9",
17
+ "tenacity>=9.0.0",
18
+ ]
19
+
20
+ [build-system]
21
+ requires = ["setuptools"]
22
+
23
+ [tool.mypy]
24
+ ignore_missing_imports = false
25
+
26
+ [[tool.mypy.overrides]]
27
+ module = ["requests", "requests.exceptions", "curlify", "yaml"]
28
+ ignore_missing_imports = true
29
+
30
+ [tool.ruff]
31
+ line-length = 120
32
+
33
+ [tool.ruff.lint]
34
+ select = ["I", "F"]
ibauth-0.0.2/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+