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.
- simcon_toolkit/__init__.py +40 -0
- simcon_toolkit/__main__.py +7 -0
- simcon_toolkit/_kit/LICENSE +202 -0
- simcon_toolkit/_kit/NOTICE +37 -0
- simcon_toolkit/_kit/assets/parts/clip_frame.stl +0 -0
- simcon_toolkit/_kit/assets/parts/simple_plate.stl +0 -0
- simcon_toolkit/_kit/packages/.ruff.toml +10 -0
- simcon_toolkit/_kit/packages/cadmould_cloud/__init__.py +8 -0
- simcon_toolkit/_kit/packages/cadmould_cloud/auth.py +681 -0
- simcon_toolkit/_kit/packages/cadmould_cloud/client.py +235 -0
- simcon_toolkit/_kit/packages/cadmould_geometry/__init__.py +5 -0
- simcon_toolkit/_kit/packages/cadmould_geometry/mesh.py +210 -0
- simcon_toolkit/_kit/packages/cadmould_geometry/stl.py +168 -0
- simcon_toolkit/_kit/packages/cadmould_results/__init__.py +30 -0
- simcon_toolkit/_kit/packages/cadmould_results/loader.py +288 -0
- simcon_toolkit/_kit/packages/cadmould_scoring/__init__.py +7 -0
- simcon_toolkit/_kit/packages/cadmould_scoring/metrics.py +519 -0
- simcon_toolkit/_kit/pyproject.toml +232 -0
- simcon_toolkit/_kit/templates/_shared/AGENTS.base.md +101 -0
- simcon_toolkit/_kit/templates/gate-study/.gitignore +18 -0
- simcon_toolkit/_kit/templates/gate-study/AGENTS.md +46 -0
- simcon_toolkit/_kit/templates/gate-study/GATING_STUDY_PLAYBOOK.md +219 -0
- simcon_toolkit/_kit/templates/gate-study/INITIAL_PROMPT.md +26 -0
- simcon_toolkit/_kit/templates/gate-study/README.md +137 -0
- simcon_toolkit/_kit/templates/gate-study/main.py +344 -0
- simcon_toolkit/_kit/templates/gate-study/pipeline.py +281 -0
- simcon_toolkit/_kit/templates/process-window/.gitignore +20 -0
- simcon_toolkit/_kit/templates/process-window/AGENTS.md +49 -0
- simcon_toolkit/_kit/templates/process-window/METHOD.md +155 -0
- simcon_toolkit/_kit/templates/process-window/README.md +176 -0
- simcon_toolkit/_kit/templates/process-window/configs/simple-plate.yaml +116 -0
- simcon_toolkit/_kit/templates/process-window/doe_spec.schema.md +249 -0
- simcon_toolkit/_kit/templates/process-window/main.py +82 -0
- simcon_toolkit/_kit/templates/process-window/process_window/__init__.py +5 -0
- simcon_toolkit/_kit/templates/process-window/process_window/centre.py +298 -0
- simcon_toolkit/_kit/templates/process-window/process_window/design.py +144 -0
- simcon_toolkit/_kit/templates/process-window/process_window/economics.py +367 -0
- simcon_toolkit/_kit/templates/process-window/process_window/emit.py +591 -0
- simcon_toolkit/_kit/templates/process-window/process_window/guardrails.py +153 -0
- simcon_toolkit/_kit/templates/process-window/process_window/harness.py +360 -0
- simcon_toolkit/_kit/templates/process-window/process_window/identity.py +92 -0
- simcon_toolkit/_kit/templates/process-window/process_window/inspect_part.py +184 -0
- simcon_toolkit/_kit/templates/process-window/process_window/kpis.py +355 -0
- simcon_toolkit/_kit/templates/process-window/process_window/material_card.py +163 -0
- simcon_toolkit/_kit/templates/process-window/process_window/probe_proxy.py +169 -0
- simcon_toolkit/_kit/templates/process-window/process_window/run_confirm.py +403 -0
- simcon_toolkit/_kit/templates/process-window/process_window/run_epsilon_floor.py +198 -0
- simcon_toolkit/_kit/templates/process-window/process_window/run_feedback.py +322 -0
- simcon_toolkit/_kit/templates/process-window/process_window/run_refine.py +279 -0
- simcon_toolkit/_kit/templates/process-window/process_window/run_screening.py +370 -0
- simcon_toolkit/_kit/templates/process-window/process_window/run_sweep.py +166 -0
- simcon_toolkit/_kit/templates/process-window/process_window/setup_campaign.py +312 -0
- simcon_toolkit/_kit/templates/process-window/process_window/surrogate.py +201 -0
- simcon_toolkit/_kit/templates/process-window/process_window/test_centre.py +169 -0
- simcon_toolkit/_kit/templates/process-window/process_window/test_design.py +113 -0
- simcon_toolkit/_kit/templates/process-window/process_window/test_guardrails.py +157 -0
- simcon_toolkit/_kit/templates/process-window/process_window/test_surrogate.py +127 -0
- simcon_toolkit/_kit/templates/process-window/process_window/units.py +152 -0
- simcon_toolkit/_kit/templates/quoting/.gitignore +24 -0
- simcon_toolkit/_kit/templates/quoting/AGENTS.md +58 -0
- simcon_toolkit/_kit/templates/quoting/INTERVIEW.md +147 -0
- simcon_toolkit/_kit/templates/quoting/METHOD.md +256 -0
- simcon_toolkit/_kit/templates/quoting/PROMPT.md +46 -0
- simcon_toolkit/_kit/templates/quoting/QUOTING_PLAYBOOK.md +245 -0
- simcon_toolkit/_kit/templates/quoting/README.md +158 -0
- simcon_toolkit/_kit/templates/quoting/main.py +484 -0
- simcon_toolkit/_kit/templates/quoting/parts/.gitkeep +0 -0
- simcon_toolkit/_kit/templates/quoting/quoting/__init__.py +11 -0
- simcon_toolkit/_kit/templates/quoting/quoting/costing.py +725 -0
- simcon_toolkit/_kit/templates/quoting/quoting/geometry.py +398 -0
- simcon_toolkit/_kit/templates/quoting/quoting/shop.py +193 -0
- simcon_toolkit/_kit/templates/quoting/quoting/state.py +260 -0
- simcon_toolkit/_kit/templates/quoting/quoting/study.py +577 -0
- simcon_toolkit/_kit/templates/quoting/quoting/toolkit.py +50 -0
- simcon_toolkit/_kit/templates/quoting/shop/README.md +43 -0
- simcon_toolkit/_kit/templates/quoting/shop/commercial.md +86 -0
- simcon_toolkit/_kit/templates/quoting/shop/lessons.md +94 -0
- simcon_toolkit/_kit/templates/quoting/shop/machines.md +68 -0
- simcon_toolkit/_kit/templates/quoting/shop/materials.md +92 -0
- simcon_toolkit/_kit/templates/quoting/shop/shop-profile.md +87 -0
- simcon_toolkit/_kit/templates/quoting/shop/tooling.md +145 -0
- simcon_toolkit/_kit/templates/run-one-simulation/.gitignore +16 -0
- simcon_toolkit/_kit/templates/run-one-simulation/AGENTS.md +41 -0
- simcon_toolkit/_kit/templates/run-one-simulation/README.md +133 -0
- simcon_toolkit/_kit/templates/run-one-simulation/main.py +216 -0
- simcon_toolkit/_kit/templates.toml +83 -0
- simcon_toolkit/choices.py +11 -0
- simcon_toolkit/cli.py +381 -0
- simcon_toolkit/generate.py +590 -0
- simcon_toolkit/instructions.py +152 -0
- simcon_toolkit/manifest.py +86 -0
- simcon_toolkit/project.py +356 -0
- simcon_toolkit/wizard.py +160 -0
- simcon_toolkit-0.1.0.dist-info/METADATA +48 -0
- simcon_toolkit-0.1.0.dist-info/RECORD +97 -0
- simcon_toolkit-0.1.0.dist-info/WHEEL +4 -0
- 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())
|