insightfactory-cli 1.0.0.dev1__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.
if_cli/oauth.py ADDED
@@ -0,0 +1,389 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import json
6
+ import secrets
7
+ import subprocess
8
+ import sys
9
+ import time
10
+ from http.server import BaseHTTPRequestHandler, HTTPServer
11
+ from typing import Any
12
+ from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
13
+
14
+ from if_cli.cache import (
15
+ CacheEntry,
16
+ clear_refresh_token_if_matches,
17
+ is_fresh,
18
+ load_cache,
19
+ store_tokens,
20
+ )
21
+ from if_cli.config import Profile, meaningful
22
+ from if_cli.constants import DEFAULT_SCOPE, LOOPBACK_HOST
23
+ from if_cli.http import fetch_with_timeout, validate_http_url
24
+ from if_cli.runtime import CliError, die, escape_html, is_record
25
+
26
+ CALLBACK_TIMEOUT_SECONDS = 300
27
+ RECOVERY_POLL_ATTEMPTS = 10
28
+ RECOVERY_POLL_INTERVAL_MS = 40
29
+
30
+
31
+ def base64url(data: bytes) -> str:
32
+ return base64.b64encode(data).decode("ascii").replace("+", "-").replace("/", "_").rstrip("=")
33
+
34
+
35
+ def create_pkce_challenge(verifier: str) -> str:
36
+ return base64url(hashlib.sha256(verifier.encode("utf-8")).digest())
37
+
38
+
39
+ def open_browser(url: str, launcher: str | None = None) -> None:
40
+ """Open ``url`` with the platform browser launcher.
41
+
42
+ A missing desktop launcher must not terminate a headless login: the
43
+ authorization URL is always printed as a fallback.
44
+ """
45
+ if launcher is not None:
46
+ command, args = launcher, [url]
47
+ elif sys.platform == "darwin":
48
+ command, args = "open", [url]
49
+ elif sys.platform == "win32":
50
+ command, args = "rundll32", ["url.dll,FileProtocolHandler", url]
51
+ else:
52
+ command, args = "xdg-open", [url]
53
+ try:
54
+ subprocess.Popen(
55
+ [command, *args],
56
+ stdout=subprocess.DEVNULL,
57
+ stderr=subprocess.DEVNULL,
58
+ start_new_session=True,
59
+ )
60
+ except OSError:
61
+ pass
62
+
63
+
64
+ def build_callback_url(port: int) -> str:
65
+ return f"http://{LOOPBACK_HOST}:{port}/callback"
66
+
67
+
68
+ def _discover(host: str) -> dict[str, Any]:
69
+ response = fetch_with_timeout(f"{host}/.well-known/oauth-authorization-server")
70
+ if not response.ok:
71
+ die(f"OAuth discovery failed for {host} (HTTP {response.status})")
72
+ try:
73
+ body: object = response.json()
74
+ except json.JSONDecodeError:
75
+ die(f"OAuth discovery for {host} did not return valid JSON")
76
+ if not is_record(body):
77
+ die(f"OAuth discovery for {host} did not return a JSON object")
78
+ authorization_endpoint = body.get("authorization_endpoint")
79
+ token_endpoint = body.get("token_endpoint")
80
+ if not isinstance(authorization_endpoint, str) or not isinstance(token_endpoint, str):
81
+ die(f"OAuth discovery for {host} is missing authorization_endpoint or token_endpoint")
82
+ metadata: dict[str, Any] = dict(body)
83
+ metadata["authorization_endpoint"] = validate_http_url(authorization_endpoint, "OAuth authorization endpoint")
84
+ metadata["token_endpoint"] = validate_http_url(token_endpoint, "OAuth token endpoint")
85
+ return metadata
86
+
87
+
88
+ class TokenRequestError(Exception):
89
+ def __init__(self, status: int, body: str, oauth_error: str | None = None) -> None:
90
+ super().__init__(f"token request failed (HTTP {status}): {body}")
91
+ self.status = status
92
+ self.oauth_error = oauth_error
93
+
94
+ @property
95
+ def invalidates_refresh_token(self) -> bool:
96
+ return self.oauth_error == "invalid_grant"
97
+
98
+
99
+ def _parse_expires_in(value: object) -> float | None:
100
+ if isinstance(value, bool):
101
+ parsed = float("nan")
102
+ elif isinstance(value, (int, float)):
103
+ parsed = float(value)
104
+ elif isinstance(value, str) and value.strip():
105
+ try:
106
+ parsed = float(value)
107
+ except ValueError:
108
+ parsed = float("nan")
109
+ else:
110
+ parsed = float("nan")
111
+ if parsed == parsed and parsed >= 0:
112
+ return parsed
113
+ return None
114
+
115
+
116
+ def _token_request(token_endpoint: str, form: dict[str, str]) -> dict[str, object]:
117
+ response = fetch_with_timeout(
118
+ token_endpoint,
119
+ method="POST",
120
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
121
+ body=urlencode(form),
122
+ )
123
+ if not response.ok:
124
+ body = response.text()
125
+ oauth_error: str | None = None
126
+ try:
127
+ parsed: object = json.loads(body)
128
+ if is_record(parsed):
129
+ error_value = parsed.get("error")
130
+ if isinstance(error_value, str):
131
+ oauth_error = error_value
132
+ except json.JSONDecodeError:
133
+ pass
134
+ raise TokenRequestError(response.status, body, oauth_error)
135
+ try:
136
+ tokens = response.json()
137
+ except json.JSONDecodeError:
138
+ die("token response did not include a valid access token")
139
+ if not is_record(tokens) or not isinstance(tokens.get("access_token"), str) or not tokens.get("access_token"):
140
+ die("token response did not include a valid access token")
141
+ expires_in = _parse_expires_in(tokens.get("expires_in"))
142
+ result: dict[str, object] = {"access_token": tokens["access_token"]}
143
+ if isinstance(tokens.get("refresh_token"), str):
144
+ result["refresh_token"] = tokens["refresh_token"]
145
+ if expires_in is not None:
146
+ result["expires_in"] = expires_in
147
+ return result
148
+
149
+
150
+ class _CallbackServer:
151
+ def __init__(self, port: int, expected_state: str) -> None:
152
+ self.expected_state = expected_state
153
+ self.result: dict[str, str] | None = None
154
+ handler = self._handler()
155
+ try:
156
+ self.server = HTTPServer((LOOPBACK_HOST, port), handler)
157
+ except OSError as error:
158
+ die(
159
+ f"cannot listen on {LOOPBACK_HOST}:{port} ({error.strerror or error}) — "
160
+ "stop whatever is using the port and retry"
161
+ )
162
+
163
+ def _handler(self) -> type[BaseHTTPRequestHandler]:
164
+ callback = self
165
+
166
+ class Handler(BaseHTTPRequestHandler):
167
+ def log_message(self, format: str, *args: Any) -> None:
168
+ return
169
+
170
+ def do_GET(self) -> None:
171
+ parsed = urlparse(self.path)
172
+ if parsed.path != "/callback":
173
+ self.send_response(404)
174
+ self.end_headers()
175
+ return
176
+ params = dict(parse_qsl(parsed.query, keep_blank_values=True))
177
+ if params.get("state") != callback.expected_state:
178
+ self.send_response(400)
179
+ self.send_header("Content-Type", "text/html; charset=utf-8")
180
+ self.send_header("Connection", "close")
181
+ self.end_headers()
182
+ self.wfile.write(b"<h2>Login callback ignored because its state did not match.</h2>")
183
+ return
184
+ code = params.get("code")
185
+ self.send_response(200)
186
+ self.send_header("Content-Type", "text/html; charset=utf-8")
187
+ self.send_header("Connection", "close")
188
+ self.end_headers()
189
+ if code:
190
+ body = "<h2>Login complete — you can close this tab and return to the terminal.</h2>"
191
+ else:
192
+ error = escape_html(params.get("error") or "")
193
+ description = escape_html(params.get("error_description") or "")
194
+ body = f"<h2>Login failed.</h2><pre>{error}: {description}</pre>"
195
+ self.wfile.write(body.encode("utf-8"))
196
+ callback.result = params
197
+
198
+ return Handler
199
+
200
+ def wait(self) -> dict[str, str]:
201
+ deadline = time.monotonic() + CALLBACK_TIMEOUT_SECONDS
202
+ try:
203
+ while self.result is None:
204
+ remaining = deadline - time.monotonic()
205
+ if remaining <= 0:
206
+ die("timed out waiting for the browser callback (5 min)")
207
+ self.server.timeout = remaining
208
+ self.server.handle_request()
209
+ finally:
210
+ self.server.server_close()
211
+ result = self.result
212
+ if result is None:
213
+ die("timed out waiting for the browser callback (5 min)")
214
+ return result
215
+
216
+
217
+ def wait_for_callback(port: int, expected_state: str) -> dict[str, str]:
218
+ return _CallbackServer(port, expected_state).wait()
219
+
220
+
221
+ def login(profile: Profile, no_browser: bool, options: dict[str, str] | None = None) -> CacheEntry:
222
+ options = options or {}
223
+ metadata = _discover(profile["host"])
224
+ published_client_id = meaningful(
225
+ metadata.get("insightfactory_cli_client_id")
226
+ if isinstance(metadata.get("insightfactory_cli_client_id"), str)
227
+ else None
228
+ )
229
+ published_organization = meaningful(
230
+ metadata.get("insightfactory_organization_id")
231
+ if isinstance(metadata.get("insightfactory_organization_id"), str)
232
+ else None
233
+ )
234
+ stored_client_id = meaningful(profile.get("client_id"))
235
+ stored_organization = meaningful(profile.get("organization"))
236
+ pinned_client_id = meaningful(options.get("client_id_override"))
237
+ pinned_organization = meaningful(options.get("organization_override"))
238
+
239
+ superseded_client_id = (
240
+ pinned_client_id is None
241
+ and stored_client_id is not None
242
+ and published_client_id is not None
243
+ and stored_client_id != published_client_id
244
+ )
245
+
246
+ client_id = pinned_client_id or (
247
+ published_client_id if superseded_client_id else (stored_client_id or published_client_id)
248
+ )
249
+ organization = pinned_organization or (
250
+ (published_organization or stored_organization)
251
+ if superseded_client_id
252
+ else (stored_organization or published_organization)
253
+ )
254
+
255
+ if superseded_client_id:
256
+ organization_changed = organization != stored_organization and stored_organization is not None
257
+ sys.stderr.write(
258
+ f"warning: profile '{profile['name']}' has client_id {stored_client_id}, but {profile['host']} "
259
+ f"now advertises {published_client_id}.\n"
260
+ + (
261
+ f" Its organization {organization} also replaces the profile's {stored_organization}.\n"
262
+ if organization_changed
263
+ else ""
264
+ )
265
+ + " Using the advertised client. Remove client_id from the profile to silence this,\n"
266
+ + " or pass --client-id (and --organization) on each login to keep overriding it.\n"
267
+ )
268
+ if not client_id:
269
+ die(
270
+ "no client id: this factory's discovery document does not publish insightfactory_cli_client_id — "
271
+ "pass --client-id (and --organization) with the factory's public client details"
272
+ )
273
+
274
+ verifier = base64url(secrets.token_bytes(48))
275
+ challenge = create_pkce_challenge(verifier)
276
+ state = base64url(secrets.token_bytes(24))
277
+ redirect_uri = build_callback_url(profile["callback_port"])
278
+ params = {
279
+ "response_type": "code",
280
+ "client_id": client_id,
281
+ "redirect_uri": redirect_uri,
282
+ "scope": DEFAULT_SCOPE,
283
+ "audience": profile["audience"],
284
+ "state": state,
285
+ "code_challenge": challenge,
286
+ "code_challenge_method": "S256",
287
+ }
288
+ if organization:
289
+ params["organization"] = organization
290
+
291
+ parsed_auth = urlparse(metadata["authorization_endpoint"])
292
+ query = dict(parse_qsl(parsed_auth.query, keep_blank_values=True))
293
+ query.update(params)
294
+ authorization_url = urlunparse(parsed_auth._replace(query=urlencode(query)))
295
+
296
+ callback_server = _CallbackServer(profile["callback_port"], state)
297
+ if no_browser:
298
+ sys.stderr.write(f"Open this URL in a browser logged into the factory:\n\n {authorization_url}\n\n")
299
+ else:
300
+ sys.stderr.write(f"Opening browser for {profile['host']} …\n")
301
+ open_browser(authorization_url)
302
+ sys.stderr.write(f"If the browser did not open, visit:\n {authorization_url}\n")
303
+
304
+ try:
305
+ callback_result = callback_server.wait()
306
+ except CliError:
307
+ raise
308
+ except Exception as error:
309
+ die(str(error))
310
+
311
+ if callback_result.get("state") != state:
312
+ die("state mismatch in callback — aborting")
313
+ if not callback_result.get("code"):
314
+ die(f"authorization failed: {callback_result.get('error')}: {callback_result.get('error_description')}")
315
+
316
+ tokens = _token_request(
317
+ metadata["token_endpoint"],
318
+ {
319
+ "grant_type": "authorization_code",
320
+ "client_id": client_id,
321
+ "code": callback_result["code"],
322
+ "code_verifier": verifier,
323
+ "redirect_uri": redirect_uri,
324
+ },
325
+ )
326
+ return store_tokens(profile["host"], tokens, metadata["token_endpoint"], client_id, replace=True)
327
+
328
+
329
+ def _refresh(host: str, entry: CacheEntry, refresh_token: str, client_id: str) -> CacheEntry:
330
+ tokens = _token_request(
331
+ entry["token_endpoint"],
332
+ {
333
+ "grant_type": "refresh_token",
334
+ "client_id": client_id,
335
+ "refresh_token": refresh_token,
336
+ },
337
+ )
338
+ return store_tokens(host, tokens, entry["token_endpoint"], client_id)
339
+
340
+
341
+ def _wait_for_fresh_token(host: str) -> str | None:
342
+ for attempt in range(RECOVERY_POLL_ATTEMPTS):
343
+ entry = load_cache()["tokens"].get(host)
344
+ if entry and is_fresh(entry):
345
+ return entry["access_token"]
346
+ if attempt + 1 < RECOVERY_POLL_ATTEMPTS:
347
+ time.sleep(RECOVERY_POLL_INTERVAL_MS / 1000)
348
+ return None
349
+
350
+
351
+ def get_valid_token(profile: Profile) -> str:
352
+ entry = load_cache()["tokens"].get(profile["host"])
353
+ if not entry:
354
+ die(f"no cached token for {profile['host']} — run: if-cli login -p {profile['name']}")
355
+ if is_fresh(entry):
356
+ return entry["access_token"]
357
+ refresh_token = entry.get("refresh_token")
358
+ if refresh_token:
359
+ client_id = entry.get("client_id")
360
+ if not client_id:
361
+ clear_refresh_token_if_matches(profile["host"], refresh_token)
362
+ die(
363
+ f"could not refresh token for {profile['host']}: no client id cached — "
364
+ f"run: if-cli login -p {profile['name']}"
365
+ )
366
+ try:
367
+ return _refresh(profile["host"], entry, refresh_token, client_id)["access_token"]
368
+ except Exception as error:
369
+ failure: BaseException = error
370
+ if isinstance(error, TokenRequestError) and error.invalidates_refresh_token:
371
+ cleared = clear_refresh_token_if_matches(profile["host"], refresh_token)
372
+ if cleared:
373
+ recovered_access_token = _wait_for_fresh_token(profile["host"])
374
+ if recovered_access_token:
375
+ return recovered_access_token
376
+ else:
377
+ recovered = load_cache()["tokens"].get(profile["host"])
378
+ recovered_refresh = recovered.get("refresh_token") if recovered else None
379
+ recovered_client_id = recovered.get("client_id") if recovered else None
380
+ if recovered and recovered_refresh and recovered_refresh != refresh_token and recovered_client_id:
381
+ try:
382
+ return _refresh(profile["host"], recovered, recovered_refresh, recovered_client_id)[
383
+ "access_token"
384
+ ]
385
+ except Exception as retry_error:
386
+ failure = retry_error
387
+ detail = f": {failure}" if isinstance(failure, Exception) else ""
388
+ die(f"could not refresh token for {profile['host']}{detail} — run: if-cli login -p {profile['name']}")
389
+ die(f"token for {profile['host']} expired and no refresh token cached — run: if-cli login -p {profile['name']}")
if_cli/runtime.py ADDED
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import os
6
+ import signal
7
+ import sys
8
+ from datetime import datetime
9
+ from typing import NoReturn, TypeGuard
10
+
11
+ _exit_code = 0
12
+
13
+
14
+ class CliError(Exception):
15
+ pass
16
+
17
+
18
+ def die(message: str) -> NoReturn:
19
+ raise CliError(message)
20
+
21
+
22
+ def set_exit_code(code: int) -> None:
23
+ global _exit_code
24
+ _exit_code = code
25
+
26
+
27
+ def get_exit_code() -> int:
28
+ return _exit_code
29
+
30
+
31
+ def is_record(value: object) -> TypeGuard[dict[str, object]]:
32
+ return isinstance(value, dict)
33
+
34
+
35
+ def jwt_exp(token: str) -> int | None:
36
+ try:
37
+ payload = token.split(".")[1]
38
+ padding = "=" * ((4 - len(payload) % 4) % 4)
39
+ data: object = json.loads(base64.urlsafe_b64decode(payload + padding))
40
+ if not is_record(data):
41
+ return None
42
+ exp = data.get("exp")
43
+ return exp if isinstance(exp, int) else None
44
+ except (IndexError, ValueError, json.JSONDecodeError, TypeError):
45
+ return None
46
+
47
+
48
+ def format_expiry(epoch_seconds: int) -> str:
49
+ return datetime.fromtimestamp(epoch_seconds).strftime("%c")
50
+
51
+
52
+ def escape_html(value: str) -> str:
53
+ return (
54
+ value.replace("&", "&amp;")
55
+ .replace("<", "&lt;")
56
+ .replace(">", "&gt;")
57
+ .replace('"', "&quot;")
58
+ .replace("'", "&#39;")
59
+ )
60
+
61
+
62
+ def install_broken_pipe_handlers() -> None:
63
+ """Treat a closed stdout pipe as a normal end of output rather than a crash.
64
+
65
+ Downstream readers such as `if-cli profiles | head -1` close the pipe as
66
+ soon as they have the line they want. Ignoring SIGPIPE turns the subsequent
67
+ write into BrokenPipeError, which run_with_handlers converts into a quiet
68
+ exit that still reports any failure the command already recorded.
69
+ """
70
+ try:
71
+ signal.signal(signal.SIGPIPE, signal.SIG_IGN)
72
+ except (AttributeError, ValueError, OSError):
73
+ pass
74
+
75
+
76
+ def handle_broken_pipe() -> NoReturn:
77
+ try:
78
+ sys.stdout.flush()
79
+ except (BrokenPipeError, OSError):
80
+ pass
81
+ try:
82
+ devnull = os.open(os.devnull, os.O_WRONLY)
83
+ os.dup2(devnull, sys.stdout.fileno())
84
+ except (OSError, ValueError, AttributeError):
85
+ pass
86
+ # Python 3.14 still flushes the original TextIOWrapper at shutdown. If that
87
+ # wrapper is already in an EPIPE error state, CPython maps the ignored
88
+ # exception to exit status 120 and overwrites SystemExit. Point sys.stdout
89
+ # at a fresh /dev/null stream so the shutdown flush succeeds.
90
+ try:
91
+ sys.stdout = open(os.devnull, "w", encoding="utf-8", errors="replace")
92
+ except OSError:
93
+ pass
94
+ raise SystemExit(get_exit_code() if isinstance(get_exit_code(), int) else 0)