isl-tool 1.1.0__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.
isl_tool/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Information Security Lab Tool (ETH Zürich)"""
2
+
3
+ __version__ = "1.1.0"
4
+
5
+ from .lib import API, APIError
6
+
7
+ __all__ = ["API", "APIError", "__version__"]
isl_tool/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
isl_tool/cli.py ADDED
@@ -0,0 +1,355 @@
1
+ import argparse
2
+ import errno
3
+ import os
4
+ import sys
5
+ import webbrowser
6
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
7
+ from pathlib import Path
8
+ from urllib.parse import urljoin, urlsplit
9
+
10
+ from . import __version__, colors, oidc
11
+ from .lib import API
12
+
13
+ PORTS = [53682, 53683, 53684, 53685]
14
+
15
+ GITLAB = "https://gitlab.ethz.ch"
16
+ CLIENT_ID = "d23d5238b28b43203b4430eaa42de7caea7ed6b8cdf9021787417ae6576cfdbe"
17
+
18
+ api = API("https://isl-2026-1.inf.ethz.ch")
19
+
20
+
21
+ def main():
22
+ program = argparse.ArgumentParser(
23
+ prog="isl-tool", description="Information Security Lab Tool (ETH Zürich)"
24
+ )
25
+ program.add_argument(
26
+ "-v",
27
+ "--version",
28
+ action="version",
29
+ version=__version__,
30
+ help="output the version number",
31
+ )
32
+ commands = program.add_subparsers(title="commands", metavar="command")
33
+
34
+ def add_command(name, description, action):
35
+ command = commands.add_parser(name, help=description, description=description)
36
+ command.set_defaults(action=action)
37
+ return command
38
+
39
+ add_command(
40
+ "login", "authenticate for access to student environment", lambda args: login()
41
+ )
42
+
43
+ command = add_command(
44
+ "submit",
45
+ "submit solution for grading",
46
+ lambda args: submit(args.exercise, args.file),
47
+ )
48
+ command.add_argument("exercise", help="name of exercise to be submitted")
49
+ command.add_argument("file", help="file containing solution to exercise")
50
+
51
+ add_command(
52
+ "results",
53
+ "list the current grading results for your submissions",
54
+ lambda args: results(),
55
+ )
56
+
57
+ command = add_command(
58
+ "submission",
59
+ "show the submission file considered for the grading result of an exercise",
60
+ lambda args: submission(args.exercise),
61
+ )
62
+ command.add_argument("exercise", help="name of exercise")
63
+
64
+ # Equivalent of the help command commander adds implicitly
65
+ command = add_command(
66
+ "help",
67
+ "display help for command",
68
+ lambda args: commands.choices.get(args.command, program).print_help(),
69
+ )
70
+ command.add_argument("command", nargs="?", help="command to display help for")
71
+
72
+ args = program.parse_args()
73
+ if not hasattr(args, "action"):
74
+ program.print_help(sys.stderr)
75
+ sys.exit(1)
76
+ try:
77
+ args.action(args)
78
+ except KeyboardInterrupt:
79
+ sys.exit(130)
80
+
81
+
82
+ def auth_token_path():
83
+ return str(Path.home() / ".isl-auth-token")
84
+
85
+
86
+ def auth_token():
87
+ try:
88
+ with open(auth_token_path(), encoding="utf-8") as f:
89
+ return f.read().strip()
90
+ except OSError as e:
91
+ print(
92
+ colors.red(f"Error: unable to retrieve authentication token: {e}"),
93
+ file=sys.stderr,
94
+ )
95
+ print(file=sys.stderr)
96
+ print("Have you logged in using `isl-tool login` yet?", file=sys.stderr)
97
+ sys.exit(1)
98
+
99
+
100
+ def write_stdout(data):
101
+ sys.stdout.write(data)
102
+ sys.stdout.flush()
103
+
104
+
105
+ class CallbackServer(ThreadingHTTPServer):
106
+ # On Windows, SO_REUSEADDR allows binding a port that is already in use, which would defeat the port fallback
107
+ allow_reuse_address = os.name != "nt"
108
+ # Let handle_request() return regularly so that the login loop can check whether the callback was handled
109
+ timeout = 0.5
110
+
111
+
112
+ def listen_on_first_available_port(handler, ports):
113
+ for port in ports:
114
+ try:
115
+ return CallbackServer(("127.0.0.1", port), handler), port
116
+ except OSError as err:
117
+ if err.errno != errno.EADDRINUSE:
118
+ raise
119
+ raise RuntimeError("No available callback port")
120
+
121
+
122
+ def obtain_id_token():
123
+ config = oidc.discovery(GITLAB, CLIENT_ID)
124
+
125
+ code_verifier = oidc.random_pkce_code_verifier()
126
+ state = oidc.random_state()
127
+ nonce = oidc.random_nonce()
128
+
129
+ redirect_uri = None
130
+ # (id_token, error) of the first handled callback
131
+ outcome = []
132
+
133
+ class CallbackHandler(BaseHTTPRequestHandler):
134
+ def do_GET(self):
135
+ url = urljoin(redirect_uri, self.path)
136
+
137
+ if urlsplit(url).path != "/callback":
138
+ return self.respond(404)
139
+
140
+ try:
141
+ tokens = oidc.authorization_code_grant(
142
+ config,
143
+ url,
144
+ pkce_code_verifier=code_verifier,
145
+ expected_state=state,
146
+ expected_nonce=nonce,
147
+ )
148
+
149
+ self.respond(200, "Login successful. You can close this tab.")
150
+ outcome.append((tokens["id_token"], None))
151
+ except Exception as err:
152
+ self.respond(400, "Login failed.")
153
+ outcome.append((None, err))
154
+
155
+ def respond(self, status, body=""):
156
+ data = body.encode("utf-8")
157
+ self.send_response(status)
158
+ self.send_header("Content-Length", str(len(data)))
159
+ self.end_headers()
160
+ self.wfile.write(data)
161
+
162
+ def log_message(self, format, *args):
163
+ pass
164
+
165
+ server, port = listen_on_first_available_port(CallbackHandler, PORTS)
166
+ try:
167
+ redirect_uri = f"http://127.0.0.1:{port}/callback"
168
+ auth_url = oidc.build_authorization_url(
169
+ config,
170
+ {
171
+ "redirect_uri": redirect_uri,
172
+ "scope": "openid profile email",
173
+ "code_challenge": oidc.calculate_pkce_code_challenge(code_verifier),
174
+ "code_challenge_method": "S256",
175
+ "state": state,
176
+ "nonce": nonce,
177
+ },
178
+ )
179
+
180
+ print(f"Listening on {redirect_uri}")
181
+ print("Opening browser for login...")
182
+
183
+ try:
184
+ opened = webbrowser.open(auth_url)
185
+ except webbrowser.Error:
186
+ opened = False
187
+ if not opened:
188
+ print(f"Open this URL:\n{auth_url}")
189
+
190
+ while not outcome:
191
+ server.handle_request()
192
+ finally:
193
+ server.server_close()
194
+
195
+ id_token, error = outcome[0]
196
+ if error is not None:
197
+ raise error
198
+ return id_token
199
+
200
+
201
+ def login():
202
+ try:
203
+ id_token = obtain_id_token()
204
+ except Exception as err:
205
+ print(colors.red(f"Error: {err}"), file=sys.stderr)
206
+ sys.exit(1)
207
+
208
+ try:
209
+ token = api.login(id_token)
210
+ except Exception as err:
211
+ print(colors.red(f"Error: {err}"), file=sys.stderr)
212
+ return
213
+
214
+ try:
215
+ path = auth_token_path()
216
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
217
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
218
+ f.write(token)
219
+ print(colors.green("Login successful!"))
220
+ print(colors.gray(f"Authentication token stored at {path}"))
221
+ except OSError as e:
222
+ print(
223
+ f"Error: failed to write authentication token to home directory: {e}",
224
+ file=sys.stderr,
225
+ )
226
+
227
+
228
+ def status():
229
+ # Request container status from the API
230
+ try:
231
+ status = api.status(auth_token())
232
+
233
+ # Print the status
234
+ text = status["status"]
235
+ if text == "running":
236
+ text = colors.green(text)
237
+ elif text == "stopped":
238
+ text = colors.yellow(text)
239
+ elif text == "uninitialised":
240
+ text = colors.gray(text)
241
+ else:
242
+ text = colors.red(text)
243
+ print(f"Status:\t{text}")
244
+
245
+ # Print connection information
246
+ if status["status"] == "running":
247
+ print()
248
+ print(
249
+ f"Your container is available at {status['host']} on port {status['port']}."
250
+ )
251
+ print(
252
+ "You can connect to it using: "
253
+ + colors.yellow(f"ssh student@{status['host']} -p {status['port']}")
254
+ )
255
+
256
+ except Exception as err:
257
+ print(colors.red(f"Error: {err}"), file=sys.stderr)
258
+
259
+
260
+ def reset():
261
+ # Request container reset from the API
262
+ try:
263
+ status = api.reset(auth_token())
264
+
265
+ # Print login credentials
266
+ print(colors.green("Successfully reset container!"))
267
+ print()
268
+ print("Username:\t" + colors.yellow("student"))
269
+ print("Password:\t" + colors.yellow(f"{status['password']}"))
270
+ print(
271
+ colors.italic(
272
+ "Take note of this password! You will need to reset your container to reset it!"
273
+ )
274
+ )
275
+ print()
276
+
277
+ # Print connection information
278
+ print(
279
+ f"Your container is available at {status['host']} on port {status['port']}."
280
+ )
281
+ print(
282
+ "You can connect to it using: "
283
+ + colors.yellow(f"ssh student@{status['host']} -p {status['port']}")
284
+ )
285
+
286
+ except Exception as err:
287
+ print(colors.red(f"Error: {err}"), file=sys.stderr)
288
+
289
+
290
+ def start():
291
+ # Request container start from the API
292
+ try:
293
+ api.start(auth_token())
294
+ except Exception as err:
295
+ if str(err) == "container does not exist":
296
+ print("Container does not exist yet, resetting...")
297
+ print()
298
+ reset()
299
+ else:
300
+ print(colors.red(f"Error: {err}"), file=sys.stderr)
301
+
302
+
303
+ def stop():
304
+ # Request container stop from the API
305
+ try:
306
+ api.stop(auth_token())
307
+ except Exception as err:
308
+ print(colors.red(f"Error: {err}"), file=sys.stderr)
309
+
310
+
311
+ def submit(exercise, solution):
312
+ # Read solution file
313
+ try:
314
+ with open(solution, "rb") as f:
315
+ data = f.read()
316
+ except OSError as err:
317
+ print(colors.red(f"Error reading solution file: {err}"), file=sys.stderr)
318
+ return
319
+
320
+ # Submit solution for grading
321
+ try:
322
+ api.grade_exercise(
323
+ auth_token(), exercise, data.decode("utf-8", errors="replace"), write_stdout
324
+ )
325
+ except Exception as err:
326
+ print(colors.red(f"Error: {err}"), file=sys.stderr)
327
+
328
+
329
+ def results():
330
+ # Request grader results from the API
331
+ try:
332
+ results = api.results(auth_token())
333
+ if not results:
334
+ print("No submissions yet.")
335
+ else:
336
+ for exercise, score in results.items():
337
+ text = colors.gray("no submission")
338
+ if score is not None:
339
+ text = f"{score['received_points']}/{score['max_points']} points"
340
+ text = (
341
+ colors.green(text)
342
+ if score["received_points"] == score["max_points"]
343
+ else colors.yellow(text)
344
+ )
345
+ print(f"{exercise}: {text}")
346
+ except Exception as err:
347
+ print(colors.red(f"Error: {err}"), file=sys.stderr)
348
+
349
+
350
+ def submission(exercise):
351
+ # Request grader submission from the API
352
+ try:
353
+ write_stdout(api.submission(auth_token(), exercise))
354
+ except Exception as err:
355
+ print(colors.red(f"Error: {err}"), file=sys.stderr)
isl_tool/colors.py ADDED
@@ -0,0 +1,36 @@
1
+ """ANSI text styles, covering the subset of the `colors` npm package used by the Node.js version of isl-tool."""
2
+
3
+ import os
4
+ import sys
5
+
6
+ if sys.platform == "win32":
7
+ from colorama import just_fix_windows_console
8
+
9
+ just_fix_windows_console()
10
+
11
+
12
+ def supports_color():
13
+ if os.environ.get("FORCE_COLOR"):
14
+ return os.environ["FORCE_COLOR"] != "0"
15
+ if "NO_COLOR" in os.environ or os.environ.get("TERM") == "dumb":
16
+ return False
17
+ return sys.stdout.isatty()
18
+
19
+
20
+ enabled = supports_color()
21
+
22
+
23
+ def style(open_code, close_code):
24
+ def apply(text):
25
+ if not enabled or not text:
26
+ return text
27
+ return f"\x1b[{open_code}m{text}\x1b[{close_code}m"
28
+
29
+ return apply
30
+
31
+
32
+ red = style(31, 39)
33
+ green = style(32, 39)
34
+ yellow = style(33, 39)
35
+ gray = style(90, 39)
36
+ italic = style(3, 23)
isl_tool/lib.py ADDED
@@ -0,0 +1,166 @@
1
+ from http import HTTPStatus
2
+ from urllib.parse import urljoin, urlsplit, urlunsplit
3
+
4
+ import requests
5
+ from websockets.exceptions import ConnectionClosed, InvalidStatus
6
+ from websockets.sync.client import connect
7
+
8
+
9
+ class APIError(Exception):
10
+ pass
11
+
12
+
13
+ def status_text(status):
14
+ try:
15
+ return HTTPStatus(status).phrase
16
+ except ValueError:
17
+ return ""
18
+
19
+
20
+ def decode(data):
21
+ return data.decode("utf-8", errors="replace")
22
+
23
+
24
+ def api_error(err):
25
+ response = err.response
26
+ if response is not None and response.status_code == HTTPStatus.UNAUTHORIZED:
27
+ return APIError("unauthorized request")
28
+ elif response is not None and response.status_code == HTTPStatus.FORBIDDEN:
29
+ return APIError("unauthorized user")
30
+ elif response is not None and response.status_code == HTTPStatus.TOO_MANY_REQUESTS:
31
+ return APIError("too many requests (you have exceeded your rate limit)")
32
+ elif response is not None:
33
+ msg = f"request failed with status: {response.status_code} {status_text(response.status_code)}"
34
+ if response.content:
35
+ msg += "\n" + decode(response.content)
36
+ return APIError(msg)
37
+ else:
38
+ return APIError(f"request failed: {type(err).__name__}")
39
+
40
+
41
+ def ws_response_error(status, body):
42
+ # Prefer the message sent by the server (e.g. submission limit reached)
43
+ if body.strip():
44
+ return APIError(body.strip())
45
+ elif status == HTTPStatus.UNAUTHORIZED:
46
+ return APIError("unauthorized request")
47
+ elif status == HTTPStatus.FORBIDDEN:
48
+ return APIError("unauthorized user")
49
+ elif status == HTTPStatus.TOO_MANY_REQUESTS:
50
+ return APIError("too many requests (you have exceeded your rate limit)")
51
+ elif status == HTTPStatus.NOT_FOUND:
52
+ return APIError("invalid exercise name")
53
+ return APIError(f"request failed with status: {status} {status_text(status)}")
54
+
55
+
56
+ class API:
57
+ def __init__(self, base_url):
58
+ self.base_url = base_url
59
+
60
+ def _get(self, path, token):
61
+ response = requests.get(
62
+ urljoin(self.base_url, path), headers={"Authorization": f"Bearer {token}"}
63
+ )
64
+ response.raise_for_status()
65
+ return response
66
+
67
+ def login(self, auth_token):
68
+ try:
69
+ return decode(self._get("auth", auth_token).content)
70
+ except requests.RequestException as err:
71
+ if (
72
+ err.response is not None
73
+ and err.response.status_code == HTTPStatus.UNAUTHORIZED
74
+ ):
75
+ raise APIError("invalid credentials") from err
76
+ else:
77
+ raise api_error(err) from err
78
+
79
+ def status(self, token):
80
+ try:
81
+ return self._get("env/status", token).json()
82
+ except requests.RequestException as err:
83
+ raise api_error(err) from err
84
+
85
+ def reset(self, token):
86
+ try:
87
+ return self._get("env/reset", token).json()
88
+ except requests.RequestException as err:
89
+ raise api_error(err) from err
90
+
91
+ def start(self, token):
92
+ try:
93
+ self._get("env/start", token)
94
+ except requests.RequestException as err:
95
+ if (
96
+ err.response is not None
97
+ and err.response.status_code == HTTPStatus.NOT_FOUND
98
+ ):
99
+ raise APIError("container does not exist") from err
100
+ else:
101
+ raise api_error(err) from err
102
+
103
+ def stop(self, token):
104
+ try:
105
+ self._get("env/stop", token)
106
+ except requests.RequestException as err:
107
+ if (
108
+ err.response is not None
109
+ and err.response.status_code == HTTPStatus.NOT_FOUND
110
+ ):
111
+ raise APIError("container does not exist") from err
112
+ else:
113
+ raise api_error(err) from err
114
+
115
+ def grade_exercise(
116
+ self, token, exercise, solution, output_callback=lambda data: None
117
+ ):
118
+ url = urlsplit(urljoin(self.base_url, f"grader/submit/{exercise}"))
119
+ if url.scheme == "https":
120
+ url = url._replace(scheme="wss")
121
+ elif url.scheme == "http":
122
+ url = url._replace(scheme="ws")
123
+ else:
124
+ raise APIError(f"unsupported protocol in base URL: {url.scheme}:")
125
+ try:
126
+ # No keepalive pings: the grader stops reading (and thus answering pings) while grading
127
+ with connect(
128
+ urlunsplit(url),
129
+ additional_headers={"Authorization": f"Bearer {token}"},
130
+ ping_interval=None,
131
+ max_size=None,
132
+ ) as ws:
133
+ ws.send(solution)
134
+ while True:
135
+ try:
136
+ data = ws.recv()
137
+ except ConnectionClosed as err:
138
+ # The grader closes the TCP connection without a close frame once it is done,
139
+ # so only a closure caused by an actual error (e.g. a reset) is a failure
140
+ if err.__cause__ is not None:
141
+ raise err.__cause__
142
+ return
143
+ output_callback(data if isinstance(data, str) else decode(data))
144
+ except InvalidStatus as err:
145
+ # Server rejected the upgrade (e.g. 403/429): use the body so its message isn't lost
146
+ raise ws_response_error(
147
+ err.response.status_code, decode(err.response.body or b"")
148
+ ) from err
149
+
150
+ def results(self, token):
151
+ try:
152
+ return self._get("grader/results", token).json()
153
+ except requests.RequestException as err:
154
+ raise api_error(err) from err
155
+
156
+ def submission(self, token, exercise):
157
+ try:
158
+ return decode(self._get(f"grader/submission/{exercise}", token).content)
159
+ except requests.RequestException as err:
160
+ if (
161
+ err.response is not None
162
+ and err.response.status_code == HTTPStatus.NOT_FOUND
163
+ ):
164
+ raise APIError("no submission or invalid exercise name") from err
165
+ else:
166
+ raise api_error(err) from err
isl_tool/oidc.py ADDED
@@ -0,0 +1,198 @@
1
+ """Minimal OpenID Connect client for the authorization code flow with PKCE (public client).
2
+
3
+ Implements the subset of the `openid-client` npm package used by the Node.js version of isl-tool.
4
+ As with `openid-client` (unless non-repudiation checks are enabled), the ID Token's signature is
5
+ not verified because it is received directly from the token endpoint over TLS. Its claims are.
6
+ """
7
+
8
+ import base64
9
+ import hashlib
10
+ import json
11
+ import secrets
12
+ import time
13
+ from urllib.parse import parse_qs, parse_qsl, urlencode, urlsplit, urlunsplit
14
+
15
+ import requests
16
+
17
+ TIMEOUT = 30
18
+ CLOCK_TOLERANCE = 30
19
+
20
+
21
+ class OIDCError(Exception):
22
+ pass
23
+
24
+
25
+ class Configuration:
26
+ def __init__(self, server_metadata, client_id):
27
+ self.server_metadata = server_metadata
28
+ self.client_id = client_id
29
+
30
+ def endpoint(self, name):
31
+ url = self.server_metadata.get(name)
32
+ if not isinstance(url, str):
33
+ raise OIDCError(f"authorization server metadata does not contain {name}")
34
+ return url
35
+
36
+
37
+ def normalize_issuer(issuer):
38
+ return issuer.rstrip("/")
39
+
40
+
41
+ def discovery(server, client_id):
42
+ response = requests.get(
43
+ f"{normalize_issuer(server)}/.well-known/openid-configuration",
44
+ headers={"Accept": "application/json"},
45
+ timeout=TIMEOUT,
46
+ )
47
+ response.raise_for_status()
48
+ metadata = response.json()
49
+ issuer = metadata.get("issuer") if isinstance(metadata, dict) else None
50
+ if not isinstance(issuer, str) or normalize_issuer(issuer) != normalize_issuer(
51
+ server
52
+ ):
53
+ raise OIDCError(
54
+ '"issuer" in authorization server metadata does not match the expected value'
55
+ )
56
+ return Configuration(metadata, client_id)
57
+
58
+
59
+ def random_pkce_code_verifier():
60
+ return secrets.token_urlsafe(32)
61
+
62
+
63
+ def random_state():
64
+ return secrets.token_urlsafe(32)
65
+
66
+
67
+ def random_nonce():
68
+ return secrets.token_urlsafe(32)
69
+
70
+
71
+ def calculate_pkce_code_challenge(code_verifier):
72
+ digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
73
+ return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
74
+
75
+
76
+ def build_authorization_url(config, parameters):
77
+ url = urlsplit(config.endpoint("authorization_endpoint"))
78
+ query = parse_qsl(url.query)
79
+ query.append(("client_id", config.client_id))
80
+ query.append(("response_type", "code"))
81
+ query.extend(parameters.items())
82
+ return urlunsplit(url._replace(query=urlencode(query)))
83
+
84
+
85
+ def authorization_code_grant(
86
+ config, current_url, pkce_code_verifier, expected_state, expected_nonce
87
+ ):
88
+ url = urlsplit(current_url)
89
+ params = parse_qs(url.query, keep_blank_values=True)
90
+
91
+ def param(name):
92
+ values = params.get(name, [])
93
+ if len(values) > 1:
94
+ raise OIDCError(f'"{name}" parameter must be provided only once')
95
+ return values[0] if values else None
96
+
97
+ # Validate the authorization response
98
+ iss = param("iss")
99
+ if iss is None and config.server_metadata.get(
100
+ "authorization_response_iss_parameter_supported"
101
+ ):
102
+ raise OIDCError('response parameter "iss" (issuer) missing')
103
+ if iss is not None and iss != config.server_metadata["issuer"]:
104
+ raise OIDCError('unexpected "iss" (issuer) response parameter value')
105
+ state = param("state")
106
+ if state is None:
107
+ raise OIDCError('response parameter "state" missing')
108
+ if state != expected_state:
109
+ raise OIDCError('unexpected "state" response parameter value')
110
+ error = param("error")
111
+ if error is not None:
112
+ description = param("error_description")
113
+ raise OIDCError(
114
+ f"authorization response from the server is an error: {error}"
115
+ + (f" ({description})" if description else "")
116
+ )
117
+ if param("id_token") is not None or param("token") is not None:
118
+ raise OIDCError("implicit and hybrid flows are not supported")
119
+ code = param("code")
120
+ if not code:
121
+ raise OIDCError('response parameter "code" missing')
122
+
123
+ # Exchange the authorization code at the token endpoint
124
+ response = requests.post(
125
+ config.endpoint("token_endpoint"),
126
+ data={
127
+ "grant_type": "authorization_code",
128
+ "code": code,
129
+ "redirect_uri": urlunsplit(url._replace(query="", fragment="")),
130
+ "code_verifier": pkce_code_verifier,
131
+ "client_id": config.client_id,
132
+ },
133
+ headers={"Accept": "application/json"},
134
+ timeout=TIMEOUT,
135
+ )
136
+ try:
137
+ tokens = response.json()
138
+ except ValueError:
139
+ tokens = None
140
+ if response.status_code != 200:
141
+ if isinstance(tokens, dict) and isinstance(tokens.get("error"), str):
142
+ description = tokens.get("error_description")
143
+ raise OIDCError(
144
+ f"server responded with an error in the response body: {tokens['error']}"
145
+ + (f" ({description})" if description else "")
146
+ )
147
+ raise OIDCError(
148
+ f"unexpected token endpoint response status code: {response.status_code}"
149
+ )
150
+ if not isinstance(tokens, dict):
151
+ raise OIDCError("token endpoint response body must be a JSON object")
152
+ for name in ("access_token", "token_type", "id_token"):
153
+ if not isinstance(tokens.get(name), str):
154
+ raise OIDCError(
155
+ f'token endpoint response body "{name}" property must be a string'
156
+ )
157
+
158
+ validate_id_token_claims(
159
+ config, decode_jwt_claims(tokens["id_token"]), expected_nonce
160
+ )
161
+ return tokens
162
+
163
+
164
+ def decode_jwt_claims(jwt):
165
+ try:
166
+ _, payload, _ = jwt.split(".")
167
+ claims = json.loads(
168
+ base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4))
169
+ )
170
+ except ValueError as err:
171
+ raise OIDCError("failed to parse the ID Token") from err
172
+ if not isinstance(claims, dict):
173
+ raise OIDCError("ID Token claims set must be a JSON object")
174
+ return claims
175
+
176
+
177
+ def validate_id_token_claims(config, claims, expected_nonce):
178
+ for claim in ("aud", "exp", "iat", "iss", "sub"):
179
+ if claim not in claims:
180
+ raise OIDCError(f'ID Token "{claim}" claim missing')
181
+ if claims["iss"] != config.server_metadata["issuer"]:
182
+ raise OIDCError('unexpected ID Token "iss" (issuer) claim value')
183
+ audience = claims["aud"] if isinstance(claims["aud"], list) else [claims["aud"]]
184
+ if config.client_id not in audience:
185
+ raise OIDCError('unexpected ID Token "aud" (audience) claim value')
186
+ if (len(audience) > 1 or "azp" in claims) and claims.get("azp") != config.client_id:
187
+ raise OIDCError('unexpected ID Token "azp" (authorized party) claim value')
188
+ if (
189
+ not isinstance(claims["exp"], (int, float))
190
+ or claims["exp"] <= time.time() - CLOCK_TOLERANCE
191
+ ):
192
+ raise OIDCError(
193
+ 'unexpected ID Token "exp" (expiration time) claim value, expiration is past current timestamp'
194
+ )
195
+ if not isinstance(claims["iat"], (int, float)):
196
+ raise OIDCError('ID Token "iat" (issued at) claim must be a number')
197
+ if claims.get("nonce") != expected_nonce:
198
+ raise OIDCError('unexpected ID Token "nonce" claim value')
@@ -0,0 +1,50 @@
1
+ Metadata-Version: 2.5
2
+ Name: isl-tool
3
+ Version: 1.1.0
4
+ Summary: Information Security Lab Tool (ETH Zürich)
5
+ Author: csrrw
6
+ Requires-Python: >=3.9
7
+ Requires-Dist: colorama>=0.4.6; sys_platform == 'win32'
8
+ Requires-Dist: requests>=2.27
9
+ Requires-Dist: websockets>=15.0
10
+ Description-Content-Type: text/markdown
11
+
12
+ # isl-tool
13
+
14
+ Information Security Lab Tool (ETH Zürich). Python port of the `isl-tool` npm
15
+ package.
16
+
17
+ ## Usage
18
+
19
+ Requires Python >= 3.9. Similar to `npx isl-tool@latest`, the tool can be run
20
+ without installing it, always using the most recent version, with
21
+ [uv](https://docs.astral.sh/uv/) or [pipx](https://pipx.pypa.io/):
22
+
23
+ ```shell
24
+ uvx isl-tool@latest --help
25
+ pipx run --no-cache isl-tool --help
26
+ ```
27
+
28
+ Alternatively, install it with `pip install isl-tool` and invoke `isl-tool` (or
29
+ `python -m isl_tool`).
30
+
31
+ On first use, authenticate using the `login` command. This opens a browser for
32
+ logging in with ETH GitLab and stores the issued token in `~/.isl-auth-token`,
33
+ which subsequent commands use to authenticate.
34
+
35
+ ```shell
36
+ isl-tool login
37
+ isl-tool submit <exercise> <file>
38
+ isl-tool results
39
+ isl-tool submission <exercise>
40
+ ```
41
+
42
+ ## Publishing
43
+
44
+ Increment the version number in `src/isl_tool/__init__.py`, then build and upload the package:
45
+
46
+ ```shell
47
+ python -m pip install --upgrade build twine
48
+ python -m build
49
+ python -m twine upload dist/*
50
+ ```
@@ -0,0 +1,10 @@
1
+ isl_tool/__init__.py,sha256=7jxAOct8cgvgy9haLPqYpQ0hA5V3KW1LMlvy5gqD6cQ,151
2
+ isl_tool/__main__.py,sha256=MSmt_5Xg84uHqzTN38JwgseJK8rsJn_11A8WD99VtEo,61
3
+ isl_tool/cli.py,sha256=q0CotZFIXF38As9FCycNmb1FBMX9z0VWKyIUZQ0gJ5Q,10740
4
+ isl_tool/colors.py,sha256=jG0V4z9Ud7Kdqsfj3Oc5oXwL-C5fIHKTEPmdH0h0r7w,811
5
+ isl_tool/lib.py,sha256=4xNrxl_6NmKKuvcu4zsYwQ_AKJ68-vU3O10HgZ8lI-w,6120
6
+ isl_tool/oidc.py,sha256=eQ2yB3JIrf4VwLE5NL6PZLi-qccGd7C09N9j107zIc0,7125
7
+ isl_tool-1.1.0.dist-info/METADATA,sha256=IEMPqW5QszVijqenBF_LJrY0xmU0OQO6wUO-H9wTv5E,1336
8
+ isl_tool-1.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
9
+ isl_tool-1.1.0.dist-info/entry_points.txt,sha256=Oj28HN2vu5VR7SS1fO7Un2OAOZ1Cu1EIgJ3pfbxgv7c,47
10
+ isl_tool-1.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ isl-tool = isl_tool.cli:main