azul-client 9.0.24__py3-none-any.whl

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.
@@ -0,0 +1,73 @@
1
+ """A simple server that obtains the code generated by Keycloak.
2
+
3
+ A url is presented to the user, they navigate to it in a browser and proceed with authentication.
4
+ Then keycloak will redirect the browser to this web server and we extract the code generated by keycloak.
5
+ """
6
+
7
+ import sys
8
+ import urllib.parse
9
+ from http.server import BaseHTTPRequestHandler, HTTPServer
10
+
11
+
12
+ class OIDCResponseServer(BaseHTTPRequestHandler):
13
+ """Server to receive oidc callback to enable code exchange with PKCE."""
14
+
15
+ expected_path = ""
16
+ expected_state = ""
17
+ token_code = None
18
+
19
+ def do_GET(self):
20
+ """Handle get request from users browser."""
21
+ # expected like http://127.0.0.1:8080/authorisation-code/callback?code=XXXX&state=YYYY
22
+ try:
23
+ if not self.path.startswith(f"{OIDCResponseServer.expected_path}?"):
24
+ raise Exception(f"bad path '{self.path}' not start with '{self.expected_path}?'")
25
+ parsed = urllib.parse.urlparse(self.path)
26
+ qs = urllib.parse.parse_qs(parsed.query)
27
+ if OIDCResponseServer.expected_state != qs["state"][0]:
28
+ raise Exception("Returned state does not match")
29
+ OIDCResponseServer.token_code = qs["code"][0]
30
+
31
+ except Exception as e:
32
+ self.send_response(400)
33
+ self.send_header("Content-type", "text/html")
34
+ self.end_headers()
35
+ self.wfile.write(bytes("<html><head><title>Azul Client Auth</title></head>", "utf-8"))
36
+ self.wfile.write(bytes("<body>", "utf-8"))
37
+ self.wfile.write(bytes("<p>Failure</p>", "utf-8"))
38
+ self.wfile.write(bytes(f"<p>{str(e)}</p>", "utf-8"))
39
+ self.wfile.write(bytes("</body></html>", "utf-8"))
40
+
41
+ else:
42
+ self.send_response(200)
43
+ self.send_header("Content-type", "text/html")
44
+ self.end_headers()
45
+ self.wfile.write(bytes("<html><head><title>Azul Client Auth</title></head>", "utf-8"))
46
+ self.wfile.write(bytes("<body>", "utf-8"))
47
+ self.wfile.write(bytes("<p>Success</p>", "utf-8"))
48
+ self.wfile.write(bytes("<p>You may now close this tab.</p>", "utf-8"))
49
+ self.wfile.write(bytes("</body></html>", "utf-8"))
50
+
51
+
52
+ def receive_code(
53
+ expected_state: str, *, path: str = "/authorisation-code/callback", hostname: str = "localhost", port: int = 8080
54
+ ):
55
+ """Receive one http request from users browser, parse code and shut down."""
56
+ OIDCResponseServer.expected_path = path
57
+ OIDCResponseServer.expected_state = expected_state
58
+ server = HTTPServer((hostname, port), OIDCResponseServer)
59
+ print("Server started at http://%s:%s. Waiting for oauth callback." % (hostname, port), file=sys.stderr)
60
+
61
+ try:
62
+ while not OIDCResponseServer.token_code:
63
+ server.handle_request()
64
+ except KeyboardInterrupt:
65
+ pass
66
+ server.server_close()
67
+ print("Server stopped.")
68
+ return OIDCResponseServer.token_code
69
+
70
+
71
+ if __name__ == "__main__":
72
+ code = receive_code("51998", path="/test")
73
+ print(f"{code=}")
@@ -0,0 +1,215 @@
1
+ """Auth handling."""
2
+
3
+ import base64
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import random
8
+ import re
9
+ import sys
10
+ import time
11
+ import urllib.parse
12
+
13
+ import httpx
14
+
15
+ from azul_client import config
16
+
17
+ from . import callback
18
+
19
+
20
+ def _get_json(resp: httpx.Response, dbg: str) -> dict:
21
+ """Try to read json data out of the web request."""
22
+ if resp.status_code != 200:
23
+ raise Exception(f"Failed to fetch {dbg} - Code: {resp.status_code} - Content:\n{resp.content[:1000]}")
24
+ if "application/json" not in resp.headers.get("content-type"):
25
+ raise Exception(
26
+ f"Failed to fetch {dbg} - response mime is not json ({resp.headers.get('content-type')}). "
27
+ f"Is '{resp.url}' the correct endpoint? - "
28
+ f"Content:\n{resp.content[:1000]}"
29
+ )
30
+ try:
31
+ data = resp.json()
32
+ except json.decoder.JSONDecodeError:
33
+ raise Exception(
34
+ f"Failed to fetch {dbg} - response was not json. "
35
+ f"Is '{resp.url}' the correct endpoint? - "
36
+ f"Content:\n{resp.content[:1000]}"
37
+ )
38
+ return data
39
+
40
+
41
+ class OIDC:
42
+ """Handle authentication with Azul api."""
43
+
44
+ def __init__(self, cfg: config.Config) -> None:
45
+ self.cfg = cfg
46
+ self._oidc_info = None
47
+ # Requests used to force retries on status codes [500, 502, 503, 504]
48
+ verify = True
49
+ if not self.cfg.azul_verify_ssl:
50
+ print("NO VERIFY SSL", file=sys.stderr)
51
+ verify = False
52
+ # Setup client with retries.
53
+ self._verify = verify
54
+ self._local_client = httpx.Client(
55
+ timeout=httpx.Timeout(timeout=self.cfg.max_timeout),
56
+ )
57
+
58
+ def _get_oidc_info(self):
59
+ """Return oidc json document containing locations of required resources for auth."""
60
+ if not self._oidc_info:
61
+ self._fetch_well_known()
62
+ return self._oidc_info
63
+
64
+ def _get_authorization_endpoint(self):
65
+ return self._get_oidc_info()["authorization_endpoint"]
66
+
67
+ def _get_token_endpoint(self):
68
+ return self._get_oidc_info()["token_endpoint"]
69
+
70
+ def _fetch_well_known(self):
71
+ resp = self._local_client.get(self.cfg.oidc_url, timeout=httpx.Timeout(timeout=self.cfg.oidc_timeout))
72
+ self._oidc_info = _get_json(resp, "well known")
73
+
74
+ @config._lock_azul_config
75
+ def get_client(self):
76
+ """Return a httpx client object with an up to date authorization token."""
77
+ out_client = httpx.Client(
78
+ headers={"authorization": "Bearer " + self._get_access_token()},
79
+ mounts={
80
+ "http://": httpx.HTTPTransport(retries=5),
81
+ "https://": httpx.HTTPTransport(retries=5),
82
+ },
83
+ timeout=httpx.Timeout(timeout=self.cfg.max_timeout),
84
+ verify=self._verify,
85
+ )
86
+ return out_client
87
+
88
+ def _via_service_token(self):
89
+ """Retrieve a token from OIDC provider using service account flow."""
90
+ resp = self._local_client.post(
91
+ self._get_token_endpoint(),
92
+ data={
93
+ "response_type": "token",
94
+ "client_id": self.cfg.auth_client_id,
95
+ "client_secret": os.environ.get("AZUL_CLIENT_SECRET") or self.cfg.auth_client_secret,
96
+ "grant_type": "client_credentials",
97
+ "scope": self.cfg.auth_scopes,
98
+ },
99
+ timeout=httpx.Timeout(timeout=self.cfg.oidc_timeout),
100
+ )
101
+ return _get_json(resp, "via service token")
102
+
103
+ def _via_code_callback(self):
104
+ """Retrieve a token from OIDC provider using code callback."""
105
+ # prove that requestor of token is same as receiver with code challenge and verify
106
+ code_verifier = base64.urlsafe_b64encode(os.urandom(40)).decode("utf-8")
107
+ code_verifier = re.sub("[^a-zA-Z0-9]+", "", code_verifier)
108
+
109
+ code_challenge = hashlib.sha256(code_verifier.encode("utf-8")).digest()
110
+ code_challenge = base64.urlsafe_b64encode(code_challenge).decode("utf-8")
111
+ code_challenge = code_challenge.replace("=", "")
112
+
113
+ port = 8080
114
+ callback_url = f"http://localhost:{port}/client/callback"
115
+
116
+ state = str(random.randint(1000000, 9999999)) # nosec B311
117
+ params = {
118
+ "response_type": "code",
119
+ "client_id": self.cfg.auth_client_id,
120
+ "redirect_uri": callback_url,
121
+ "state": state,
122
+ "scope": self.cfg.auth_scopes,
123
+ "code_challenge": code_challenge,
124
+ "code_challenge_method": "S256",
125
+ }
126
+ url_ask_user = self._get_authorization_endpoint() + "?" + urllib.parse.urlencode(params)
127
+ print(f"Please navigate to the following url to continue authentication:\n{url_ask_user}", file=sys.stderr)
128
+ code = callback.receive_code(state, path="/client/callback", hostname="localhost", port=port)
129
+ if not code:
130
+ raise Exception("No token retrieval code was returned")
131
+
132
+ resp = self._local_client.post(
133
+ self._get_token_endpoint(),
134
+ data={
135
+ "grant_type": "authorization_code",
136
+ "code": code,
137
+ "client_id": self.cfg.auth_client_id,
138
+ "state": state,
139
+ "scope": self.cfg.auth_scopes,
140
+ "redirect_uri": callback_url,
141
+ "code_verifier": code_verifier,
142
+ },
143
+ timeout=httpx.Timeout(timeout=self.cfg.oidc_timeout),
144
+ )
145
+ return _get_json(resp, "via code callback")
146
+
147
+ def _via_refresh(self, tk: dict):
148
+ """Obtain new tokens using previously issued refresh token."""
149
+ refresh_token = tk.get("refresh_token", None)
150
+ # Full re-auth if there is no refresh token
151
+ if not refresh_token:
152
+ return self._get_token_non_refresh()
153
+ # need to refresh the current token
154
+ resp = self._local_client.post(
155
+ self._get_token_endpoint(),
156
+ data={
157
+ "grant_type": "refresh_token",
158
+ "client_id": self.cfg.auth_client_id,
159
+ "refresh_token": refresh_token,
160
+ "scope": self.cfg.auth_scopes,
161
+ },
162
+ timeout=httpx.Timeout(timeout=self.cfg.oidc_timeout),
163
+ )
164
+ if 400 <= resp.status_code < 500:
165
+ # maybe refresh token has expired, retry full auth
166
+ return None
167
+ return _get_json(resp, "via refresh")
168
+
169
+ def _get_token_non_refresh(self):
170
+ # use auth method nominated in config
171
+ atype = self.cfg.auth_type
172
+ if atype == "none":
173
+ # no access token is required
174
+ tk = {}
175
+ elif atype == "callback":
176
+ tk = self._via_code_callback()
177
+ elif atype == "service":
178
+ tk = self._via_service_token()
179
+ else:
180
+ raise NotImplementedError(atype)
181
+ return tk
182
+
183
+ def _get_token(self):
184
+ """Get auth tokens to Azul."""
185
+ if self.cfg.auth_type == "none":
186
+ # no access token is required
187
+ return {}
188
+
189
+ if self.cfg.auth_token:
190
+ if time.time() > self.cfg.auth_token_time + 60:
191
+ # refresh the token
192
+ tk = self._via_refresh(self.cfg.auth_token)
193
+ if not tk:
194
+ # maybe refresh token has expired
195
+ print("Warning - Refresh token likely has expired.", file=sys.stderr)
196
+ tk = self._get_token_non_refresh()
197
+
198
+ elif time.time() < self.cfg.auth_token_time + 60:
199
+ # return current token
200
+ return self.cfg.auth_token
201
+ else:
202
+ tk = self._get_token_non_refresh()
203
+
204
+ self.cfg.auth_token = tk
205
+ self.cfg.auth_token_time = int(time.time())
206
+ # save updated token to config file
207
+ self.cfg.save()
208
+ return tk
209
+
210
+ def _get_access_token(self) -> str:
211
+ """Get valid access token to authenticate with Azul."""
212
+ token = self._get_token().get("access_token", "none")
213
+ if not token and self.cfg.auth_type != "none":
214
+ raise Exception("Token required but was not found")
215
+ return token
@@ -0,0 +1,102 @@
1
+ Metadata-Version: 2.4
2
+ Name: azul-client
3
+ Version: 9.0.24
4
+ Summary: Interact with Azul using your terminal instead of clicking in the UI a thousand times!
5
+ Home-page: https://www.asd.gov.au/
6
+ Author: Azul
7
+ Author-email: azul@asd.gov.au
8
+ Classifier: Development Status :: 5 - Production/Stable
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Topic :: Software Development :: Libraries
17
+ Requires-Python: >=3.12
18
+ Description-Content-Type: text/markdown
19
+ Requires-Dist: cart
20
+ Requires-Dist: malpz
21
+ Requires-Dist: click
22
+ Requires-Dist: pendulum
23
+ Requires-Dist: rich
24
+ Requires-Dist: httpx
25
+ Requires-Dist: pydantic>2
26
+ Requires-Dist: pydantic-settings
27
+ Requires-Dist: tenacity
28
+ Requires-Dist: azul-bedrock
29
+ Requires-Dist: filelock
30
+ Dynamic: author
31
+ Dynamic: author-email
32
+ Dynamic: classifier
33
+ Dynamic: description
34
+ Dynamic: description-content-type
35
+ Dynamic: home-page
36
+ Dynamic: requires-dist
37
+ Dynamic: requires-python
38
+ Dynamic: summary
39
+
40
+ # Azul Client
41
+
42
+ Azul client is a near complete client for Azul's RestAPI.
43
+
44
+ Interact with Azul using your terminal instead of clicking in the UI a thousand times!
45
+
46
+ Tested on ubuntu 22.04.
47
+
48
+ ## Install
49
+
50
+ `pip install azul-client`
51
+
52
+ ## Setup
53
+
54
+ Azul Client requires a config file located at ~/.azul.ini
55
+
56
+ A default config will be generated on first run.
57
+
58
+ You will need to adjust the config options as appropriate.
59
+
60
+ ```yaml
61
+ [default]
62
+ azul_url = http://localhost
63
+ oidc_url = http://keycloak/.well-known/openid-configuration
64
+ auth_type = callback
65
+ auth_scopes =
66
+ auth_client_id = azul-web
67
+ auth_client_secret =
68
+ azul_verify_ssl = True
69
+ auth_token = {}
70
+ auth_token_time = 0
71
+ max_timeout = 300.0
72
+ oidc_timeout = 10.0
73
+ ```
74
+
75
+ ### Root CA
76
+
77
+ If you have extra Root CAs, you will need to make httpx aware of them or it will complain.
78
+
79
+ Ubuntu - `export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt`
80
+
81
+ Red Hat - `export SSL_CERT_FILE=/etc/ssl/certs/ca-bundle.crt`
82
+
83
+ Alternatively you can point to a certificate directory
84
+
85
+ `export SSL_CERT_DIR=/etc/ssl/certs`
86
+
87
+ This can be added to your ~/.bashrc to prevent you from having to do it for every terminal session.
88
+
89
+ ## Usage
90
+
91
+ For usage guidance refer to the [API](./docs/api.md) and [CLI](./docs/cli.md) documentation.
92
+
93
+ ## Integration test suite
94
+
95
+ The integration test suite is in the tests/integration folder.
96
+
97
+ The `setUpModule` method in the file `tests/integration/__init__.py` creates all files in azul that need to be available for querying and uploading child/dataless.
98
+ It also waits for those uploaded files to be available in Azul which means during tests you can assume those files exist.
99
+
100
+ It also exports the sha256's of the files it uploaded to ensure the tests can import those sha256's for their testing.
101
+
102
+ NOTE - the first time you run the test suite particularly if you've added new files to the module it may be slow. But all subsequent runs will be much faster.
@@ -0,0 +1,23 @@
1
+ azul_client/__init__.py,sha256=NLNKojPMcgSlD4B-56OivmC7Q02wbJHv5Vx4oXLgmrw,77
2
+ azul_client/client.py,sha256=yZNzcsuxoNA0T-GHTKFLXQuzP4-v8u-tMtmQ1Mc0zF0,17450
3
+ azul_client/config.py,sha256=p3exqEQaQmE85KvC05wCUyvgr29ybPMTey7moJG9zok,3652
4
+ azul_client/exceptions.py,sha256=WC8c5RmPpXaxGGEP5XnJD4q_CgFQAF3T6MsoTp9rOtU,1068
5
+ azul_client/api/__init__.py,sha256=KsQG1baHNvWlwvgKks7sUr3_vi_tCjbnEB-bInIKquc,2396
6
+ azul_client/api/base_api.py,sha256=ELIXpNZWIJvX6EOMdoWoOBSD19367mVgWDeCk8uImmo,6638
7
+ azul_client/api/binaries_data.py,sha256=ZhechdW_awfDFf6D2a6ut-tROkiUvCnZnQ_kcUIc3OI,20089
8
+ azul_client/api/binaries_meta.py,sha256=UvebsIKhXxATERJAlPsxvS1-gFT-7vVMETDIPCJGYDA,21237
9
+ azul_client/api/features.py,sha256=Kb_dJ4fVBufyvWjTJhpwmWXRR68-4A3vp10sDwYv-Yc,7089
10
+ azul_client/api/plugins.py,sha256=rk_qO70pNahROT5Pm4KawTNsbPmraUKEARgZK9gfaGI,2033
11
+ azul_client/api/purge.py,sha256=krOkKp-1UkEZXsAX71rnipxtYkHnVFOPHZmgV9tqPE0,2731
12
+ azul_client/api/security.py,sha256=zo6Vxz3HXqM_tZ7DpUH1q4RoRmCRj_mcfRdW7tLNbDw,1234
13
+ azul_client/api/sources.py,sha256=LMPOuQhRv5qnQufQBJLpc1i35NbhEJH7bQrO_e-F9sw,1911
14
+ azul_client/api/statistics.py,sha256=V9NDG5_GKXuMBxqCbdc05Ge1Nutjfdz6Ob64TfHrJsM,711
15
+ azul_client/api/users.py,sha256=neA8e-b7BhOXVj2ertADbXSpznrvY1Z5vv-M5tkMCBM,997
16
+ azul_client/oidc/__init__.py,sha256=6QPY9BzSjW_E8FB5LVuNvJuv2DxcGWaPSQ0UyG7ezHM,50
17
+ azul_client/oidc/callback.py,sha256=PRvaYoiYTxT5T1rP39xeAomGII1BrgGeKnjCx_CGZ2Y,3053
18
+ azul_client/oidc/oidc.py,sha256=_Ni9KGLcEkUWHYPYs0WyO8vop0lRL9IdXbutF01irug,8066
19
+ azul_client-9.0.24.dist-info/METADATA,sha256=KsDNcwjr9cG9Mlb6gaLxjHhTh8GdQ5NGyGArVrpD8Qc,3093
20
+ azul_client-9.0.24.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
21
+ azul_client-9.0.24.dist-info/entry_points.txt,sha256=FhUw3GodsTs_lRVuXubJQ-kaYkYA_yX_jYkKfuPQ7jE,48
22
+ azul_client-9.0.24.dist-info/top_level.txt,sha256=C1NP4kqciZywY_uBh4f4CDF-KqYKXe3bYI5ISHwJl7o,12
23
+ azul_client-9.0.24.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ azul = azul_client.client:cli
@@ -0,0 +1 @@
1
+ azul_client