simcon-toolkit 0.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.
Files changed (97) hide show
  1. simcon_toolkit/__init__.py +40 -0
  2. simcon_toolkit/__main__.py +7 -0
  3. simcon_toolkit/_kit/LICENSE +202 -0
  4. simcon_toolkit/_kit/NOTICE +37 -0
  5. simcon_toolkit/_kit/assets/parts/clip_frame.stl +0 -0
  6. simcon_toolkit/_kit/assets/parts/simple_plate.stl +0 -0
  7. simcon_toolkit/_kit/packages/.ruff.toml +10 -0
  8. simcon_toolkit/_kit/packages/cadmould_cloud/__init__.py +8 -0
  9. simcon_toolkit/_kit/packages/cadmould_cloud/auth.py +681 -0
  10. simcon_toolkit/_kit/packages/cadmould_cloud/client.py +235 -0
  11. simcon_toolkit/_kit/packages/cadmould_geometry/__init__.py +5 -0
  12. simcon_toolkit/_kit/packages/cadmould_geometry/mesh.py +210 -0
  13. simcon_toolkit/_kit/packages/cadmould_geometry/stl.py +168 -0
  14. simcon_toolkit/_kit/packages/cadmould_results/__init__.py +30 -0
  15. simcon_toolkit/_kit/packages/cadmould_results/loader.py +288 -0
  16. simcon_toolkit/_kit/packages/cadmould_scoring/__init__.py +7 -0
  17. simcon_toolkit/_kit/packages/cadmould_scoring/metrics.py +519 -0
  18. simcon_toolkit/_kit/pyproject.toml +232 -0
  19. simcon_toolkit/_kit/templates/_shared/AGENTS.base.md +101 -0
  20. simcon_toolkit/_kit/templates/gate-study/.gitignore +18 -0
  21. simcon_toolkit/_kit/templates/gate-study/AGENTS.md +46 -0
  22. simcon_toolkit/_kit/templates/gate-study/GATING_STUDY_PLAYBOOK.md +219 -0
  23. simcon_toolkit/_kit/templates/gate-study/INITIAL_PROMPT.md +26 -0
  24. simcon_toolkit/_kit/templates/gate-study/README.md +137 -0
  25. simcon_toolkit/_kit/templates/gate-study/main.py +344 -0
  26. simcon_toolkit/_kit/templates/gate-study/pipeline.py +281 -0
  27. simcon_toolkit/_kit/templates/process-window/.gitignore +20 -0
  28. simcon_toolkit/_kit/templates/process-window/AGENTS.md +49 -0
  29. simcon_toolkit/_kit/templates/process-window/METHOD.md +155 -0
  30. simcon_toolkit/_kit/templates/process-window/README.md +176 -0
  31. simcon_toolkit/_kit/templates/process-window/configs/simple-plate.yaml +116 -0
  32. simcon_toolkit/_kit/templates/process-window/doe_spec.schema.md +249 -0
  33. simcon_toolkit/_kit/templates/process-window/main.py +82 -0
  34. simcon_toolkit/_kit/templates/process-window/process_window/__init__.py +5 -0
  35. simcon_toolkit/_kit/templates/process-window/process_window/centre.py +298 -0
  36. simcon_toolkit/_kit/templates/process-window/process_window/design.py +144 -0
  37. simcon_toolkit/_kit/templates/process-window/process_window/economics.py +367 -0
  38. simcon_toolkit/_kit/templates/process-window/process_window/emit.py +591 -0
  39. simcon_toolkit/_kit/templates/process-window/process_window/guardrails.py +153 -0
  40. simcon_toolkit/_kit/templates/process-window/process_window/harness.py +360 -0
  41. simcon_toolkit/_kit/templates/process-window/process_window/identity.py +92 -0
  42. simcon_toolkit/_kit/templates/process-window/process_window/inspect_part.py +184 -0
  43. simcon_toolkit/_kit/templates/process-window/process_window/kpis.py +355 -0
  44. simcon_toolkit/_kit/templates/process-window/process_window/material_card.py +163 -0
  45. simcon_toolkit/_kit/templates/process-window/process_window/probe_proxy.py +169 -0
  46. simcon_toolkit/_kit/templates/process-window/process_window/run_confirm.py +403 -0
  47. simcon_toolkit/_kit/templates/process-window/process_window/run_epsilon_floor.py +198 -0
  48. simcon_toolkit/_kit/templates/process-window/process_window/run_feedback.py +322 -0
  49. simcon_toolkit/_kit/templates/process-window/process_window/run_refine.py +279 -0
  50. simcon_toolkit/_kit/templates/process-window/process_window/run_screening.py +370 -0
  51. simcon_toolkit/_kit/templates/process-window/process_window/run_sweep.py +166 -0
  52. simcon_toolkit/_kit/templates/process-window/process_window/setup_campaign.py +312 -0
  53. simcon_toolkit/_kit/templates/process-window/process_window/surrogate.py +201 -0
  54. simcon_toolkit/_kit/templates/process-window/process_window/test_centre.py +169 -0
  55. simcon_toolkit/_kit/templates/process-window/process_window/test_design.py +113 -0
  56. simcon_toolkit/_kit/templates/process-window/process_window/test_guardrails.py +157 -0
  57. simcon_toolkit/_kit/templates/process-window/process_window/test_surrogate.py +127 -0
  58. simcon_toolkit/_kit/templates/process-window/process_window/units.py +152 -0
  59. simcon_toolkit/_kit/templates/quoting/.gitignore +24 -0
  60. simcon_toolkit/_kit/templates/quoting/AGENTS.md +58 -0
  61. simcon_toolkit/_kit/templates/quoting/INTERVIEW.md +147 -0
  62. simcon_toolkit/_kit/templates/quoting/METHOD.md +256 -0
  63. simcon_toolkit/_kit/templates/quoting/PROMPT.md +46 -0
  64. simcon_toolkit/_kit/templates/quoting/QUOTING_PLAYBOOK.md +245 -0
  65. simcon_toolkit/_kit/templates/quoting/README.md +158 -0
  66. simcon_toolkit/_kit/templates/quoting/main.py +484 -0
  67. simcon_toolkit/_kit/templates/quoting/parts/.gitkeep +0 -0
  68. simcon_toolkit/_kit/templates/quoting/quoting/__init__.py +11 -0
  69. simcon_toolkit/_kit/templates/quoting/quoting/costing.py +725 -0
  70. simcon_toolkit/_kit/templates/quoting/quoting/geometry.py +398 -0
  71. simcon_toolkit/_kit/templates/quoting/quoting/shop.py +193 -0
  72. simcon_toolkit/_kit/templates/quoting/quoting/state.py +260 -0
  73. simcon_toolkit/_kit/templates/quoting/quoting/study.py +577 -0
  74. simcon_toolkit/_kit/templates/quoting/quoting/toolkit.py +50 -0
  75. simcon_toolkit/_kit/templates/quoting/shop/README.md +43 -0
  76. simcon_toolkit/_kit/templates/quoting/shop/commercial.md +86 -0
  77. simcon_toolkit/_kit/templates/quoting/shop/lessons.md +94 -0
  78. simcon_toolkit/_kit/templates/quoting/shop/machines.md +68 -0
  79. simcon_toolkit/_kit/templates/quoting/shop/materials.md +92 -0
  80. simcon_toolkit/_kit/templates/quoting/shop/shop-profile.md +87 -0
  81. simcon_toolkit/_kit/templates/quoting/shop/tooling.md +145 -0
  82. simcon_toolkit/_kit/templates/run-one-simulation/.gitignore +16 -0
  83. simcon_toolkit/_kit/templates/run-one-simulation/AGENTS.md +41 -0
  84. simcon_toolkit/_kit/templates/run-one-simulation/README.md +133 -0
  85. simcon_toolkit/_kit/templates/run-one-simulation/main.py +216 -0
  86. simcon_toolkit/_kit/templates.toml +83 -0
  87. simcon_toolkit/choices.py +11 -0
  88. simcon_toolkit/cli.py +381 -0
  89. simcon_toolkit/generate.py +590 -0
  90. simcon_toolkit/instructions.py +152 -0
  91. simcon_toolkit/manifest.py +86 -0
  92. simcon_toolkit/project.py +356 -0
  93. simcon_toolkit/wizard.py +160 -0
  94. simcon_toolkit-0.1.0.dist-info/METADATA +48 -0
  95. simcon_toolkit-0.1.0.dist-info/RECORD +97 -0
  96. simcon_toolkit-0.1.0.dist-info/WHEEL +4 -0
  97. simcon_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,681 @@
1
+ """Auth0 login helper for the cloud platform.
2
+
3
+ [FORKED from cloud-solver-service: cloud-solver/scripts/auth_login.py. It is no longer a
4
+ verbatim copy: the dev/prod switch is removed here because templates point at production
5
+ only, and upstream needs both. Re-vendoring is therefore a merge, not a paste.]
6
+
7
+ This module implements **Authorization Code + PKCE with a loopback redirect** - the
8
+ flow Auth0 recommends for native/CLI clients. No client secret is required: PKCE
9
+ replaces it.
10
+
11
+ Usage
12
+ -----
13
+ ::
14
+
15
+ # Log in (opens a browser) and cache the token; prints cache path + expiry to stderr:
16
+ python packages/cadmould_cloud/auth.py
17
+
18
+ # Capture the raw token for another tool (the ONLY mode that prints it to stdout):
19
+ export CLOUD_SOLVER_TOKEN=$(python packages/cadmould_cloud/auth.py --print-token)
20
+
21
+ # Force a fresh interactive login, ignoring any cached/env token:
22
+ python packages/cadmould_cloud/auth.py --force
23
+
24
+ # Log out: revoke the refresh token at Auth0 and delete the cached token, then exit:
25
+ python packages/cadmould_cloud/auth.py --logout
26
+
27
+ Other overrides: ``--client-id``, ``--domain``, ``--audience``, ``--redirect-uri``,
28
+ ``--scope``. Most callers import ``get_access_token`` rather than running this directly.
29
+
30
+ Session persistence
31
+ --------------------
32
+ Tokens are cached at ``~/.cache/cloud-solver/token.json`` with the directory created
33
+ mode ``0700`` and the file ``chmod`` ed to ``0600``. A cache file (rather than an exported
34
+ environment variable) is the correct mechanism because a child process cannot set a
35
+ variable in its parent shell. The cached record holds ``access_token``, an optional
36
+ ``refresh_token``, the token ``exp`` (epoch seconds, decoded from the JWT - *no* signature
37
+ verification, freshness only) and the ``audience``.
38
+
39
+ Token resolution order (see ``get_access_token``)
40
+ --------------------------------------------------
41
+ 1. ``CLOUD_SOLVER_TOKEN`` environment variable (override) - unless ``force``.
42
+ 2. Cache file: fresh ``access_token`` -> return; expired with a ``refresh_token`` -> refresh
43
+ grant; else fall through.
44
+ 3. Interactive browser login.
45
+
46
+ Security
47
+ --------
48
+ The token, authorization code, and any OTP are never logged or printed. The default CLI
49
+ output is a status line on stderr (cache path + expiry); raw-token emission is opt-in
50
+ via ``--print-token``.
51
+
52
+ The live browser round-trip (``interactive_login``) is validated manually, not in CI.
53
+ Only the pure helpers below are unit-tested (network/browser mocked).
54
+ """
55
+
56
+ from __future__ import annotations
57
+
58
+ import argparse
59
+ import base64
60
+ import contextlib
61
+ import hashlib
62
+ import json
63
+ import os
64
+ import secrets
65
+ import sys
66
+ import threading
67
+ import time
68
+ import webbrowser
69
+ from datetime import UTC, datetime
70
+ from http.server import BaseHTTPRequestHandler, HTTPServer
71
+ from pathlib import Path
72
+ from typing import Any
73
+ from urllib.parse import parse_qs, urlencode, urlparse
74
+
75
+ import httpx
76
+
77
+ AUTH0_DOMAIN = "auth.simcon.ai"
78
+ AUTH0_AUDIENCE = "https://api.simcon.ai/cloud-solver"
79
+
80
+ # This is the desktop *licensing* application's client id, borrowed to request the
81
+ # cloud-solver audience because no dedicated cloud application exists yet. A client id is
82
+ # public by design (RFC 6749 section 2.2) and authenticates nothing on its own, so this is a
83
+ # wrong topology rather than a leaked secret. Override with --client-id, or skip the login
84
+ # entirely by exporting CLOUD_SOLVER_TOKEN.
85
+ AUTH0_CLIENT_ID = "YIcU4jTREmZn8SkvvsV7PxSGq2DCVV8b"
86
+ DEFAULT_REDIRECT_URI = "http://localhost:25000/callback"
87
+ DEFAULT_SCOPE = "openid profile email offline_access"
88
+ TOKEN_ENV_VAR = "CLOUD_SOLVER_TOKEN"
89
+
90
+ # Auth0 requires redirect_uri to EXACTLY match a registered callback, so the loopback
91
+ # server is pinned to this host/port/path.
92
+ _LOOPBACK_HOST = "localhost"
93
+ _LOOPBACK_PORT = 25000
94
+ _LOOPBACK_PATH = "/callback"
95
+ _LOGIN_TIMEOUT_S = 300
96
+
97
+
98
+ # --------------------------------------------------------------------------------------
99
+ # Pure helpers (no network, no browser, no real clock unless injected) — unit-tested.
100
+ # --------------------------------------------------------------------------------------
101
+
102
+
103
+ def generate_pkce() -> tuple[str, str]:
104
+ """Return ``(code_verifier, code_challenge)`` for PKCE S256 (RFC 7636).
105
+
106
+ The verifier is 43-128 chars from the unreserved set ``[A-Za-z0-9-._~]``; the challenge
107
+ is base64url-no-pad of ``sha256(verifier)``.
108
+ """
109
+ # token_urlsafe uses base64url alphabet (A-Za-z0-9-_), a subset of the RFC 7636
110
+ # unreserved charset, so the result is always valid. 64 bytes -> ~86 chars.
111
+ verifier = secrets.token_urlsafe(64)
112
+ if len(verifier) > 128:
113
+ verifier = verifier[:128]
114
+ digest = hashlib.sha256(verifier.encode("ascii")).digest()
115
+ challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
116
+ return verifier, challenge
117
+
118
+
119
+ def build_authorize_url(
120
+ *,
121
+ domain: str,
122
+ client_id: str,
123
+ redirect_uri: str,
124
+ audience: str,
125
+ scope: str,
126
+ state: str,
127
+ code_challenge: str,
128
+ prompt: str | None = None,
129
+ ) -> str:
130
+ """Build the Auth0 ``/authorize`` URL for the Authorization Code + PKCE flow.
131
+
132
+ ``prompt`` is the OIDC ``prompt`` parameter. Pass ``"login"`` to force Auth0 to show the
133
+ login form even when an SSO session cookie exists — useful for demoing the flow repeatedly
134
+ (clearing the local token cache alone does NOT clear Auth0's browser session).
135
+ """
136
+ params = {
137
+ "response_type": "code",
138
+ "client_id": client_id,
139
+ "redirect_uri": redirect_uri,
140
+ "audience": audience,
141
+ "scope": scope,
142
+ "state": state,
143
+ "code_challenge": code_challenge,
144
+ "code_challenge_method": "S256",
145
+ }
146
+ if prompt:
147
+ params["prompt"] = prompt
148
+ return f"https://{domain}/authorize?{urlencode(params)}"
149
+
150
+
151
+ def parse_redirect_query(query: str) -> dict[str, str]:
152
+ """Parse a callback query string into a flat ``{key: value}`` dict.
153
+
154
+ Used to extract ``code``/``state`` and to surface ``error``/``error_description``.
155
+ """
156
+ parsed = parse_qs(query, keep_blank_values=True)
157
+ return {key: values[0] for key, values in parsed.items() if values}
158
+
159
+
160
+ def _b64url_decode(segment: str) -> bytes:
161
+ """base64url-decode a string, restoring missing ``=`` padding."""
162
+ padding = "=" * (-len(segment) % 4)
163
+ return base64.urlsafe_b64decode(segment + padding)
164
+
165
+
166
+ def decode_jwt_exp(token: str) -> int | None:
167
+ """Return the JWT ``exp`` claim (epoch seconds) or ``None`` if absent/malformed.
168
+
169
+ NOTE: this does **not** verify the signature. It is used only to judge token freshness.
170
+ """
171
+ try:
172
+ parts = token.split(".")
173
+ if len(parts) < 2:
174
+ return None
175
+ payload = json.loads(_b64url_decode(parts[1]))
176
+ except (ValueError, json.JSONDecodeError):
177
+ return None
178
+ exp = payload.get("exp")
179
+ if isinstance(exp, bool) or not isinstance(exp, int):
180
+ return None
181
+ return exp
182
+
183
+
184
+ def is_expired(exp: int | None, *, now: float, skew_s: int = 60) -> bool:
185
+ """Return True if there is no ``exp`` or the token is within ``skew_s`` of expiring."""
186
+ if not isinstance(exp, (int, float)) or isinstance(exp, bool):
187
+ return True
188
+ return exp - skew_s <= now
189
+
190
+
191
+ def _record_matches(record: dict[str, Any], audience: str, domain: str, client_id: str) -> bool:
192
+ """Return True when a cached record was minted against exactly this configuration.
193
+
194
+ A record written before the identity was recorded has no ``domain``, so it fails here and
195
+ a fresh login replaces it. That is deliberate: it cannot be shown to be safe to reuse.
196
+ """
197
+ return (
198
+ record.get("audience") == audience and record.get("domain") == domain and record.get("client_id") == client_id
199
+ )
200
+
201
+
202
+ def cache_path() -> Path:
203
+ """Return the token cache path."""
204
+ return Path.home() / ".cache" / "cloud-solver" / "token.json"
205
+
206
+
207
+ def legacy_cache_paths() -> list[Path]:
208
+ """Return caches written before the environment switch was removed.
209
+
210
+ Those were named ``token-<env>.json``. Logging out has to find them: each one can hold a
211
+ refresh token that stays valid at Auth0 until it is revoked, so a logout that only deleted
212
+ the current file would report success while leaving a live credential on disk.
213
+ """
214
+ return sorted(cache_path().parent.glob("token-*.json"))
215
+
216
+
217
+ def read_cache(path: Path) -> dict[str, Any] | None:
218
+ """Return the parsed cache dict, or ``None`` if missing/corrupt."""
219
+ try:
220
+ with path.open("r", encoding="utf-8") as fh:
221
+ data = json.load(fh)
222
+ except (OSError, ValueError):
223
+ return None
224
+ if not isinstance(data, dict):
225
+ return None
226
+ return data
227
+
228
+
229
+ def write_cache(path: Path, data: dict[str, Any]) -> None:
230
+ """Write the cache dict, creating the dir ``0700`` and the file ``0600``."""
231
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
232
+ # mkdir's mode is masked by umask and a no-op if the dir already exists; set it explicitly.
233
+ with contextlib.suppress(OSError):
234
+ path.parent.chmod(0o700)
235
+ # create the file with 0600 from the start (no world-readable window)
236
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
237
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
238
+ json.dump(data, fh, indent=2)
239
+
240
+
241
+ def exchange_code(
242
+ *,
243
+ client: httpx.Client,
244
+ domain: str,
245
+ client_id: str,
246
+ code: str,
247
+ code_verifier: str,
248
+ redirect_uri: str,
249
+ ) -> dict[str, Any]:
250
+ """Exchange an authorization code + PKCE verifier for tokens at ``/oauth/token``."""
251
+ resp = client.post(
252
+ f"https://{domain}/oauth/token",
253
+ data={
254
+ "grant_type": "authorization_code",
255
+ "client_id": client_id,
256
+ "code": code,
257
+ "code_verifier": code_verifier,
258
+ "redirect_uri": redirect_uri,
259
+ },
260
+ )
261
+ _raise_for_token_error(resp, context="code exchange")
262
+ result: dict[str, Any] = resp.json()
263
+ return result
264
+
265
+
266
+ def refresh_token_grant(
267
+ *,
268
+ client: httpx.Client,
269
+ domain: str,
270
+ client_id: str,
271
+ refresh_token: str,
272
+ ) -> dict[str, Any]:
273
+ """Exchange a refresh token for fresh tokens at ``/oauth/token``."""
274
+ resp = client.post(
275
+ f"https://{domain}/oauth/token",
276
+ data={
277
+ "grant_type": "refresh_token",
278
+ "client_id": client_id,
279
+ "refresh_token": refresh_token,
280
+ },
281
+ )
282
+ _raise_for_token_error(resp, context="refresh")
283
+ result: dict[str, Any] = resp.json()
284
+ return result
285
+
286
+
287
+ def revoke_token(
288
+ *,
289
+ client: httpx.Client,
290
+ domain: str,
291
+ client_id: str,
292
+ token: str,
293
+ ) -> None:
294
+ """Revoke a refresh token via the RFC 7009 ``/oauth/revoke`` endpoint.
295
+
296
+ Our CLI app is a PUBLIC client (``token_endpoint_auth_method=none``), which Auth0 lets
297
+ revoke with just ``client_id`` + ``token`` (no secret). This stops the refresh token from
298
+ minting new access tokens. NOTE: an already-issued access token is a JWT validated offline
299
+ via JWKS, so it stays valid until its ``exp`` and cannot be revoked here — deleting the
300
+ local cache is what removes the only copy of it.
301
+ """
302
+ resp = client.post(
303
+ f"https://{domain}/oauth/revoke",
304
+ data={"client_id": client_id, "token": token},
305
+ )
306
+ # RFC 7009 §2.2: 200 on success OR for an unknown/invalid token. Anything else is a real error.
307
+ _raise_for_token_error(resp, context="revoke")
308
+
309
+
310
+ def logout(
311
+ *,
312
+ client_id: str | None = None,
313
+ domain: str | None = None,
314
+ revoke: bool = True,
315
+ ) -> dict[str, bool]:
316
+ """Invalidate the local session: revoke every refresh token found, then delete the caches.
317
+
318
+ Covers the current cache and any ``token-<env>.json`` left by an older build, so logging out
319
+ cannot leave a live refresh token behind. Returns ``{"cache_existed", "refresh_revoked"}``.
320
+ Revocation is best-effort - a network/Auth0 error never blocks deleting the local cache,
321
+ which is the primary goal. The access token JWT itself cannot be revoked before its ``exp``;
322
+ removing the cache deletes its only local copy.
323
+
324
+ ``refresh_revoked`` is only ever True for a record that named its own issuer. A record
325
+ written before that field existed is still sent to the default tenant, because that is
326
+ overwhelmingly where it came from - but RFC 7009 makes an unknown token indistinguishable
327
+ from a revoked one, so a success there proves nothing and must not be reported as one.
328
+ """
329
+ paths = [cache_path(), *legacy_cache_paths()]
330
+ existed = any(p.exists() for p in paths)
331
+ revoked = False
332
+ for p in paths:
333
+ cached = read_cache(p)
334
+ if revoke and cached and cached.get("refresh_token"):
335
+ try:
336
+ # Revoke at the tenant that minted this token: Auth0 scopes a refresh token to
337
+ # its own domain and client, so revoking at the wrong one leaves it live.
338
+ fallback_domain, _audience, fallback_client_id = resolve_config(client_id=client_id, domain=domain)
339
+ issuer_known = bool(cached.get("domain")) and bool(cached.get("client_id"))
340
+ with httpx.Client(timeout=30) as client:
341
+ revoke_token(
342
+ client=client,
343
+ domain=str(cached.get("domain") or fallback_domain),
344
+ client_id=str(cached.get("client_id") or fallback_client_id),
345
+ token=str(cached["refresh_token"]),
346
+ )
347
+ revoked = revoked or issuer_known
348
+ except (RuntimeError, ValueError, httpx.HTTPError):
349
+ pass # best-effort: still delete the local caches below
350
+ p.unlink(missing_ok=True)
351
+ return {"cache_existed": existed, "refresh_revoked": revoked}
352
+
353
+
354
+ def _raise_for_token_error(resp: httpx.Response, *, context: str) -> None:
355
+ """Raise a clear error on a non-2xx token response WITHOUT leaking any token material."""
356
+ if resp.is_success:
357
+ return
358
+ error = description = ""
359
+ try:
360
+ body = resp.json()
361
+ error = str(body.get("error", ""))
362
+ description = str(body.get("error_description", ""))
363
+ except ValueError:
364
+ pass
365
+ detail = f"{error}: {description}".strip(": ") or f"HTTP {resp.status_code}"
366
+ raise RuntimeError(f"Auth0 {context} failed ({detail})")
367
+
368
+
369
+ def build_cache_record(
370
+ token_response: dict[str, Any],
371
+ *,
372
+ audience: str,
373
+ domain: str,
374
+ client_id: str,
375
+ now: float | None = None,
376
+ ) -> dict[str, Any]:
377
+ """Assemble the on-disk cache record from a token response.
378
+
379
+ The record stores the full identity the token was minted against - audience, Auth0
380
+ domain and client id - because one cache file now serves every invocation. A token
381
+ obtained under ``--domain`` came from a different tenant and a later default call must
382
+ not reuse it, nor send its refresh token to the default revoke endpoint.
383
+
384
+ The ``exp`` prefers the JWT's own ``exp`` claim; if the access token has no ``exp`` it
385
+ falls back to ``now + expires_in`` (``now`` defaults to the real clock).
386
+ """
387
+ access_token = token_response["access_token"]
388
+ exp = decode_jwt_exp(access_token)
389
+ if exp is None:
390
+ base = time.time() if now is None else now
391
+ expires_in = token_response.get("expires_in")
392
+ if isinstance(expires_in, (int, float)):
393
+ exp = int(base + expires_in)
394
+ record: dict[str, Any] = {
395
+ "access_token": access_token,
396
+ "token_type": token_response.get("token_type", "Bearer"),
397
+ "audience": audience,
398
+ "domain": domain,
399
+ "client_id": client_id,
400
+ "exp": exp,
401
+ }
402
+ refresh = token_response.get("refresh_token")
403
+ if refresh:
404
+ record["refresh_token"] = refresh
405
+ return record
406
+
407
+
408
+ def resolve_config(
409
+ *,
410
+ client_id: str | None = None,
411
+ domain: str | None = None,
412
+ audience: str | None = None,
413
+ ) -> tuple[str, str, str]:
414
+ """Resolve ``(domain, audience, client_id)``, applying explicit overrides."""
415
+ resolved_client_id = client_id or AUTH0_CLIENT_ID
416
+ if not resolved_client_id:
417
+ raise ValueError("No client_id available: pass --client-id.")
418
+ return domain or AUTH0_DOMAIN, audience or AUTH0_AUDIENCE, resolved_client_id
419
+
420
+
421
+ # --------------------------------------------------------------------------------------
422
+ # Orchestration (network/browser/clock) — thin, NOT unit-tested (manual/live step).
423
+ # --------------------------------------------------------------------------------------
424
+
425
+
426
+ class _CallbackResult:
427
+ """Mutable holder for the captured callback query, shared with the handler thread."""
428
+
429
+ def __init__(self) -> None:
430
+ self.query: dict[str, str] | None = None
431
+
432
+
433
+ def _make_handler(result: _CallbackResult) -> type[BaseHTTPRequestHandler]:
434
+ class _Handler(BaseHTTPRequestHandler):
435
+ # Named by http.server's API, not by our own convention.
436
+ def do_GET(self) -> None:
437
+ parsed = urlparse(self.path)
438
+ if parsed.path != _LOOPBACK_PATH:
439
+ self.send_response(404)
440
+ self.end_headers()
441
+ return
442
+ result.query = parse_redirect_query(parsed.query)
443
+ self.send_response(200)
444
+ self.send_header("Content-Type", "text/html; charset=utf-8")
445
+ self.end_headers()
446
+ self.wfile.write(
447
+ b"<html><body><h2>Login complete.</h2>"
448
+ b"<p>You may close this tab and return to the terminal.</p></body></html>"
449
+ )
450
+
451
+ def log_message(self, *_args: Any) -> None: # noqa: ANN401
452
+ # Silence default stderr logging (could echo the code in the request line).
453
+ return
454
+
455
+ return _Handler
456
+
457
+
458
+ def interactive_login(
459
+ *,
460
+ domain: str,
461
+ client_id: str,
462
+ redirect_uri: str,
463
+ audience: str,
464
+ scope: str,
465
+ prompt: str | None = None,
466
+ ) -> dict[str, Any]:
467
+ """Run the full browser-based Authorization Code + PKCE login and return the token response.
468
+
469
+ ``prompt="login"`` forces Auth0 to re-show the login form even with an active SSO session.
470
+ """
471
+ # The loopback callback server below is pinned to _LOOPBACK_HOST:_LOOPBACK_PORT/_LOOPBACK_PATH,
472
+ # so a redirect_uri pointing anywhere else would be sent to Auth0 but never received here —
473
+ # the login would just hang until timeout. Fail fast instead.
474
+ parsed = urlparse(redirect_uri)
475
+ if (
476
+ parsed.scheme != "http"
477
+ or parsed.hostname != _LOOPBACK_HOST
478
+ or parsed.port != _LOOPBACK_PORT
479
+ or parsed.path != _LOOPBACK_PATH
480
+ ):
481
+ raise ValueError(
482
+ f"redirect_uri must be http://{_LOOPBACK_HOST}:{_LOOPBACK_PORT}{_LOOPBACK_PATH} "
483
+ "(the loopback callback server is pinned to it)"
484
+ )
485
+
486
+ verifier, challenge = generate_pkce()
487
+ state = secrets.token_urlsafe(32)
488
+ authorize_url = build_authorize_url(
489
+ domain=domain,
490
+ client_id=client_id,
491
+ redirect_uri=redirect_uri,
492
+ audience=audience,
493
+ scope=scope,
494
+ state=state,
495
+ code_challenge=challenge,
496
+ prompt=prompt,
497
+ )
498
+
499
+ result = _CallbackResult()
500
+ handler = _make_handler(result)
501
+ try:
502
+ server = HTTPServer((_LOOPBACK_HOST, _LOOPBACK_PORT), handler)
503
+ except OSError as exc:
504
+ raise RuntimeError(
505
+ f"Cannot bind {_LOOPBACK_HOST}:{_LOOPBACK_PORT} for the login redirect "
506
+ f"({exc}). Free port {_LOOPBACK_PORT} and retry — the redirect_uri "
507
+ "is pinned by the Auth0 callback registration."
508
+ ) from exc
509
+
510
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
511
+ thread.start()
512
+ try:
513
+ print("Opening browser for Auth0 login...", file=sys.stderr)
514
+ webbrowser.open(authorize_url)
515
+ deadline = time.time() + _LOGIN_TIMEOUT_S
516
+ while result.query is None and time.time() < deadline:
517
+ time.sleep(0.2)
518
+ finally:
519
+ server.shutdown()
520
+ server.server_close()
521
+ thread.join(timeout=5)
522
+
523
+ if result.query is None:
524
+ raise RuntimeError(f"Login timed out after {_LOGIN_TIMEOUT_S}s waiting for the redirect.")
525
+
526
+ query = result.query
527
+ if "error" in query:
528
+ raise RuntimeError(f"Auth0 login error ({query.get('error')}: {query.get('error_description', '')})")
529
+ if query.get("state") != state:
530
+ raise RuntimeError("State mismatch on callback — possible CSRF; aborting login.")
531
+ code = query.get("code")
532
+ if not code:
533
+ raise RuntimeError("Auth0 callback returned no authorization code.")
534
+
535
+ with httpx.Client(timeout=30) as client:
536
+ return exchange_code(
537
+ client=client,
538
+ domain=domain,
539
+ client_id=client_id,
540
+ code=code,
541
+ code_verifier=verifier,
542
+ redirect_uri=redirect_uri,
543
+ )
544
+
545
+
546
+ def get_access_token(
547
+ *,
548
+ client_id: str | None = None,
549
+ domain: str | None = None,
550
+ audience: str | None = None,
551
+ redirect_uri: str = DEFAULT_REDIRECT_URI,
552
+ scope: str = DEFAULT_SCOPE,
553
+ force: bool = False,
554
+ prompt: str | None = None,
555
+ ) -> str:
556
+ """Return a token, trying CLOUD_SOLVER_TOKEN, then the cache, refresh, then login.
557
+
558
+ ``prompt`` is forwarded to the interactive login (e.g. ``"login"`` to force the Auth0 form).
559
+ """
560
+ if not force:
561
+ env_token = os.environ.get(TOKEN_ENV_VAR)
562
+ if env_token:
563
+ return env_token
564
+
565
+ resolved_domain, resolved_audience, resolved_client_id = resolve_config(
566
+ client_id=client_id, domain=domain, audience=audience
567
+ )
568
+
569
+ path = cache_path()
570
+ if not force:
571
+ cached = read_cache(path)
572
+ if cached and not _record_matches(cached, resolved_audience, resolved_domain, resolved_client_id):
573
+ cached = None # minted against a different tenant or audience - don't reuse it
574
+ if cached:
575
+ access_token = cached.get("access_token")
576
+ if access_token and not is_expired(cached.get("exp"), now=time.time()):
577
+ return str(access_token)
578
+ refresh = cached.get("refresh_token")
579
+ if refresh:
580
+ try:
581
+ with httpx.Client(timeout=30) as client:
582
+ token_response = refresh_token_grant(
583
+ client=client,
584
+ domain=resolved_domain,
585
+ client_id=resolved_client_id,
586
+ refresh_token=refresh,
587
+ )
588
+ record = build_cache_record(
589
+ token_response,
590
+ audience=resolved_audience,
591
+ domain=resolved_domain,
592
+ client_id=resolved_client_id,
593
+ )
594
+ write_cache(path, record)
595
+ return str(record["access_token"])
596
+ except (RuntimeError, httpx.HTTPError):
597
+ pass # fall through to a full login
598
+
599
+ token_response = interactive_login(
600
+ domain=resolved_domain,
601
+ client_id=resolved_client_id,
602
+ redirect_uri=redirect_uri,
603
+ audience=resolved_audience,
604
+ scope=scope,
605
+ prompt=prompt,
606
+ )
607
+ record = build_cache_record(
608
+ token_response,
609
+ audience=resolved_audience,
610
+ domain=resolved_domain,
611
+ client_id=resolved_client_id,
612
+ )
613
+ write_cache(path, record)
614
+ return str(record["access_token"])
615
+
616
+
617
+ # --------------------------------------------------------------------------------------
618
+ # CLI
619
+ # --------------------------------------------------------------------------------------
620
+
621
+
622
+ def _format_expiry(exp: int | None) -> str:
623
+ if exp is None:
624
+ return "unknown"
625
+ return datetime.fromtimestamp(exp, tz=UTC).isoformat()
626
+
627
+
628
+ def _build_parser() -> argparse.ArgumentParser:
629
+ parser = argparse.ArgumentParser(description="Auth0 PKCE-loopback login for cloud-solver.")
630
+ parser.add_argument("--client-id", default=None)
631
+ parser.add_argument("--domain", default=None)
632
+ parser.add_argument("--audience", default=None)
633
+ parser.add_argument("--redirect-uri", default=DEFAULT_REDIRECT_URI)
634
+ parser.add_argument("--scope", default=DEFAULT_SCOPE)
635
+ parser.add_argument("--force", action="store_true", help="Ignore cache and re-login.")
636
+ parser.add_argument("--print-token", action="store_true", help="Print the raw token to stdout (for piping).")
637
+ parser.add_argument(
638
+ "--logout",
639
+ action="store_true",
640
+ help="Revoke the refresh token, delete the cached token, and exit.",
641
+ )
642
+ return parser
643
+
644
+
645
+ def main(argv: list[str] | None = None) -> int:
646
+ """Run the login CLI: log out, or resolve a token and report where it was cached."""
647
+ args = _build_parser().parse_args(argv)
648
+ path = cache_path()
649
+
650
+ if args.logout:
651
+ result = logout(client_id=args.client_id, domain=args.domain)
652
+ if result["cache_existed"]:
653
+ revoked = " (refresh token revoked)" if result["refresh_revoked"] else ""
654
+ print(f"Logged out: removed cached token{revoked} ({path})", file=sys.stderr)
655
+ else:
656
+ print("No cached token", file=sys.stderr)
657
+ return 0
658
+
659
+ try:
660
+ token = get_access_token(
661
+ client_id=args.client_id,
662
+ domain=args.domain,
663
+ audience=args.audience,
664
+ redirect_uri=args.redirect_uri,
665
+ scope=args.scope,
666
+ force=args.force,
667
+ )
668
+ except (RuntimeError, ValueError) as exc:
669
+ print(f"Login failed: {exc}", file=sys.stderr)
670
+ return 1
671
+
672
+ cached = read_cache(path) or {}
673
+ expiry = _format_expiry(cached.get("exp"))
674
+ print(f"logged in; token cached at {path}; expires {expiry}", file=sys.stderr)
675
+ if args.print_token:
676
+ print(token)
677
+ return 0
678
+
679
+
680
+ if __name__ == "__main__":
681
+ raise SystemExit(main())