traceforge-pcb 1.0.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.
- traceforge/__init__.py +3 -0
- traceforge/__main__.py +3 -0
- traceforge/acp_server.py +570 -0
- traceforge/agent.py +680 -0
- traceforge/altium/__init__.py +6 -0
- traceforge/altium/cfb.py +70 -0
- traceforge/altium/cli.py +60 -0
- traceforge/altium/model.py +163 -0
- traceforge/altium/outputs.py +24 -0
- traceforge/altium/pcb.py +301 -0
- traceforge/altium/project.py +328 -0
- traceforge/altium/schematic.py +400 -0
- traceforge/altium/tools.py +166 -0
- traceforge/approval.py +219 -0
- traceforge/attachments.py +346 -0
- traceforge/background.py +253 -0
- traceforge/bench/__init__.py +5 -0
- traceforge/bench/adapters.py +337 -0
- traceforge/bench/checks.py +410 -0
- traceforge/bench/cli.py +121 -0
- traceforge/bench/design.py +439 -0
- traceforge/bench/kicad.py +99 -0
- traceforge/bench/partscheck.py +159 -0
- traceforge/bench/review.py +100 -0
- traceforge/bench/runner.py +226 -0
- traceforge/bench/score.py +204 -0
- traceforge/bench/tasks.py +102 -0
- traceforge/chat.py +1185 -0
- traceforge/checks.py +328 -0
- traceforge/checks_sides.py +118 -0
- traceforge/cli.py +2224 -0
- traceforge/clipboard.py +107 -0
- traceforge/config.py +299 -0
- traceforge/context.py +276 -0
- traceforge/design/__init__.py +1 -0
- traceforge/design/constraints.py +397 -0
- traceforge/design/generate.py +1789 -0
- traceforge/design/outline.py +905 -0
- traceforge/design/schematic_layout.py +932 -0
- traceforge/design/schematic_lint.py +329 -0
- traceforge/design/schematic_route.py +207 -0
- traceforge/design/sheets.py +854 -0
- traceforge/design/sides.py +328 -0
- traceforge/design/spec.py +462 -0
- traceforge/design/specsheet.py +2351 -0
- traceforge/design/specsheet_render.py +183 -0
- traceforge/desktop/__init__.py +109 -0
- traceforge/desktop/keys.py +90 -0
- traceforge/desktop/project.py +443 -0
- traceforge/desktop/review.py +20 -0
- traceforge/desktop/server.py +1277 -0
- traceforge/desktop/static/app.css +223 -0
- traceforge/desktop/static/app.js +683 -0
- traceforge/desktop/static/highlight.js +77 -0
- traceforge/desktop/static/index.html +89 -0
- traceforge/desktop/static/md.js +90 -0
- traceforge/desktop/static/viewer.js +213 -0
- traceforge/desktop/symbols.py +212 -0
- traceforge/desktop/update.py +65 -0
- traceforge/diagnostics.py +67 -0
- traceforge/errata.py +219 -0
- traceforge/fab.py +108 -0
- traceforge/fab_layers.py +44 -0
- traceforge/features/__init__.py +6 -0
- traceforge/features/candidates.py +117 -0
- traceforge/features/cli.py +200 -0
- traceforge/features/package.py +433 -0
- traceforge/features/revdiff.py +224 -0
- traceforge/features/sarif.py +145 -0
- traceforge/features/viewer.py +265 -0
- traceforge/firmware/__init__.py +3 -0
- traceforge/firmware/cli.py +174 -0
- traceforge/firmware/drivers.py +372 -0
- traceforge/firmware/drivers_data.py +256 -0
- traceforge/firmware/mcus.py +177 -0
- traceforge/firmware/netlist.py +55 -0
- traceforge/firmware/pinmap.py +296 -0
- traceforge/firmware/scaffold.py +466 -0
- traceforge/firmware/toolchain.py +262 -0
- traceforge/hooks.py +237 -0
- traceforge/kicad/__init__.py +0 -0
- traceforge/kicad/cli.py +519 -0
- traceforge/kicad/diffpair.py +174 -0
- traceforge/kicad/edgegeom.py +191 -0
- traceforge/kicad/edit.py +405 -0
- traceforge/kicad/escape.py +1410 -0
- traceforge/kicad/formats.py +1073 -0
- traceforge/kicad/impedance.py +320 -0
- traceforge/kicad/install.py +75 -0
- traceforge/kicad/libraries.py +490 -0
- traceforge/kicad/models3d.py +942 -0
- traceforge/kicad/outputs.py +81 -0
- traceforge/kicad/pcb.py +787 -0
- traceforge/kicad/planes.py +1242 -0
- traceforge/kicad/project.py +136 -0
- traceforge/kicad/route.py +2698 -0
- traceforge/kicad/routegeom.py +164 -0
- traceforge/kicad/schematic.py +268 -0
- traceforge/kicad/sides.py +34 -0
- traceforge/kicad/stackup.py +424 -0
- traceforge/kicad_plugin/__init__.py +12 -0
- traceforge/kicad_plugin/actions.py +92 -0
- traceforge/kicad_plugin/cli.py +66 -0
- traceforge/kicad_plugin/client.py +86 -0
- traceforge/kicad_plugin/context.py +257 -0
- traceforge/kicad_plugin/discovery.py +81 -0
- traceforge/kicad_plugin/install.py +203 -0
- traceforge/kicad_plugin/overlay.py +60 -0
- traceforge/kicad_plugin/payload/action_plugin.py +65 -0
- traceforge/kicad_plugin/payload/ipc_main.py +42 -0
- traceforge/kicad_plugin/payload/plugin.json +90 -0
- traceforge/kicad_plugin/payload/requirements.txt +1 -0
- traceforge/kicad_plugin/views.py +202 -0
- traceforge/langchain.py +57 -0
- traceforge/local.py +580 -0
- traceforge/mcp_server.py +304 -0
- traceforge/models.py +310 -0
- traceforge/next_steps.py +110 -0
- traceforge/parts/__init__.py +52 -0
- traceforge/parts/alternates.py +488 -0
- traceforge/parts/bom.py +423 -0
- traceforge/parts/brief.py +705 -0
- traceforge/parts/catalog.py +297 -0
- traceforge/parts/cli_sourcing.py +156 -0
- traceforge/parts/data/duty_rates.yaml +37 -0
- traceforge/parts/data/hs_codes.yaml +103 -0
- traceforge/parts/datasheet.py +278 -0
- traceforge/parts/fx.py +100 -0
- traceforge/parts/model.py +185 -0
- traceforge/parts/net.py +187 -0
- traceforge/parts/offers.py +46 -0
- traceforge/parts/packages.py +397 -0
- traceforge/parts/pinout.py +714 -0
- traceforge/parts/providers/__init__.py +17 -0
- traceforge/parts/providers/base.py +100 -0
- traceforge/parts/providers/jlc.py +364 -0
- traceforge/parts/providers/keyed.py +661 -0
- traceforge/parts/report.py +508 -0
- traceforge/parts/supply.py +330 -0
- traceforge/parts/verify.py +455 -0
- traceforge/parts/web.py +575 -0
- traceforge/plans.py +70 -0
- traceforge/preferences.py +127 -0
- traceforge/prices.py +130 -0
- traceforge/prices.yaml +28 -0
- traceforge/prompts/__init__.py +54 -0
- traceforge/prompts/system.md +103 -0
- traceforge/providers.py +764 -0
- traceforge/remote/__init__.py +33 -0
- traceforge/remote/client.py +422 -0
- traceforge/remote/oidc.py +240 -0
- traceforge/remote/orgs.py +510 -0
- traceforge/remote/server.py +690 -0
- traceforge/remote/store.py +100 -0
- traceforge/review_changes.py +187 -0
- traceforge/routing.py +99 -0
- traceforge/runtime.py +435 -0
- traceforge/sessions.py +324 -0
- traceforge/sexpr.py +279 -0
- traceforge/sim/__init__.py +17 -0
- traceforge/sim/acmath.py +234 -0
- traceforge/sim/circuit.py +499 -0
- traceforge/sim/cli.py +162 -0
- traceforge/sim/engine.py +172 -0
- traceforge/sim/ltspice.py +175 -0
- traceforge/sim/measure.py +123 -0
- traceforge/sim/models.py +76 -0
- traceforge/sim/project.py +218 -0
- traceforge/sim/rawfile.py +123 -0
- traceforge/sim/results.py +298 -0
- traceforge/sim/tune.py +172 -0
- traceforge/skill_commands.py +165 -0
- traceforge/skill_library/circuit-simulation/SKILL.md +30 -0
- traceforge/skill_library/compliance-review/SKILL.md +51 -0
- traceforge/skill_library/design-review/SKILL.md +47 -0
- traceforge/skill_library/errata-hunt/SKILL.md +30 -0
- traceforge/skill_library/firmware/SKILL.md +26 -0
- traceforge/skill_library/power-budget/SKILL.md +27 -0
- traceforge/skill_library/release-package/SKILL.md +29 -0
- traceforge/skill_library/sourcing-brief/SKILL.md +24 -0
- traceforge/skill_library/thermal-review/SKILL.md +15 -0
- traceforge/skills.py +131 -0
- traceforge/thermal.py +557 -0
- traceforge/tools/__init__.py +48 -0
- traceforge/tools/agent_tools.py +213 -0
- traceforge/tools/base.py +303 -0
- traceforge/tools/batch.py +155 -0
- traceforge/tools/connections.py +245 -0
- traceforge/tools/files.py +777 -0
- traceforge/tools/firmware_tools.py +218 -0
- traceforge/tools/kicad_tools.py +1072 -0
- traceforge/tools/models3d_tools.py +141 -0
- traceforge/tools/parts_tools.py +473 -0
- traceforge/tools/processes.py +246 -0
- traceforge/tools/review_tools.py +178 -0
- traceforge/tools/sim_tools.py +230 -0
- traceforge/tui.py +620 -0
- traceforge/updates.py +124 -0
- traceforge/validate/__init__.py +77 -0
- traceforge/validate/builtins.py +1091 -0
- traceforge/validate/circuits.py +1240 -0
- traceforge/validate/config.py +410 -0
- traceforge/validate/default.yaml +113 -0
- traceforge/validate/design.py +191 -0
- traceforge/validate/explain.py +112 -0
- traceforge/validate/grounding.py +169 -0
- traceforge/validate/ipc2221.py +350 -0
- traceforge/validate/multilayer.py +805 -0
- traceforge/validate/netmodel.py +261 -0
- traceforge/validate/partcheck.py +472 -0
- traceforge/validate/report.py +280 -0
- traceforge/validate/review.py +322 -0
- traceforge/validate/runner.py +662 -0
- traceforge/workspace.py +171 -0
- traceforge_pcb-1.0.0.dist-info/METADATA +463 -0
- traceforge_pcb-1.0.0.dist-info/RECORD +219 -0
- traceforge_pcb-1.0.0.dist-info/WHEEL +4 -0
- traceforge_pcb-1.0.0.dist-info/entry_points.txt +2 -0
- traceforge_pcb-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Single sign-on for the hosted server through OpenID Connect (authorization code + PKCE).
|
|
2
|
+
|
|
3
|
+
Flow: ``start`` builds the IdP login URL (state, nonce and PKCE verifier are kept in
|
|
4
|
+
memory for ten minutes); the IdP redirects back with a code; ``finish`` exchanges it at the
|
|
5
|
+
token endpoint, validates the ID token with PyJWT against the IdP's JWKS, maps groups to a
|
|
6
|
+
role and returns the claims. The caller turns that into a seat and an API key.
|
|
7
|
+
|
|
8
|
+
ID token checks: asymmetric signature only (``none`` and ``HS*`` are refused, so a
|
|
9
|
+
token cannot be forged with the public key or without a key), key chosen by ``kid`` from
|
|
10
|
+
the JWKS (refetched once when unknown), ``iss`` equals the configured issuer (and the
|
|
11
|
+
discovery document's issuer must match too), ``aud`` contains the client id (``azp`` must
|
|
12
|
+
equal it when there are several), ``exp``/``iat``/``sub`` required with 60 s clock leeway,
|
|
13
|
+
and ``nonce`` equals the one issued for that login.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import base64
|
|
19
|
+
import hashlib
|
|
20
|
+
import secrets
|
|
21
|
+
import time
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Any
|
|
24
|
+
from urllib.parse import urlencode
|
|
25
|
+
|
|
26
|
+
import httpx
|
|
27
|
+
import jwt
|
|
28
|
+
|
|
29
|
+
ALLOWED_ALGS = ("RS256", "RS384", "RS512", "ES256", "ES384", "PS256", "EdDSA")
|
|
30
|
+
LEEWAY = 60
|
|
31
|
+
LOGIN_TTL = 600
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class OIDCError(Exception):
|
|
35
|
+
def __init__(self, code: str, message: str):
|
|
36
|
+
super().__init__(message)
|
|
37
|
+
self.code = code
|
|
38
|
+
self.message = message
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class OIDCConfig:
|
|
43
|
+
issuer: str
|
|
44
|
+
client_id: str
|
|
45
|
+
redirect_uri: str
|
|
46
|
+
org: str # organisation that SSO users join
|
|
47
|
+
client_secret: str = "" # from the environment, never from a file in a project
|
|
48
|
+
groups_claim: str = "groups"
|
|
49
|
+
group_roles: dict[str, str] = field(default_factory=dict) # IdP group -> admin | member
|
|
50
|
+
default_role: str = "member"
|
|
51
|
+
allowed_domains: list[str] = field(default_factory=list) # empty: any email domain
|
|
52
|
+
auto_provision: bool = True # create a seat on first login (else invite first)
|
|
53
|
+
scopes: str = "openid email profile"
|
|
54
|
+
require_https: bool = True
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def from_dict(cls, data: dict[str, Any], client_secret: str = "") -> OIDCConfig:
|
|
58
|
+
known = {k: v for k, v in data.items() if k in cls.__dataclass_fields__}
|
|
59
|
+
return cls(**{**known, "client_secret": client_secret or known.get("client_secret", "")})
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def role_for(config: OIDCConfig, claims: dict[str, Any]) -> str:
|
|
63
|
+
"""admin when any group maps to admin, else the first mapped role, else the default."""
|
|
64
|
+
groups = claims.get(config.groups_claim) or []
|
|
65
|
+
if isinstance(groups, str):
|
|
66
|
+
groups = [groups]
|
|
67
|
+
roles = {config.group_roles[g] for g in groups if g in config.group_roles}
|
|
68
|
+
if "admin" in roles:
|
|
69
|
+
return "admin"
|
|
70
|
+
return "member" if roles else config.default_role
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class OIDCClient:
|
|
74
|
+
def __init__(self, config: OIDCConfig, http: httpx.Client | None = None):
|
|
75
|
+
self.config = config
|
|
76
|
+
self.http = http or httpx.Client(timeout=10, follow_redirects=False)
|
|
77
|
+
self._discovery: dict[str, Any] | None = None
|
|
78
|
+
self._keys: dict[str, Any] = {}
|
|
79
|
+
self._keys_fetched = 0.0
|
|
80
|
+
self._logins: dict[str, dict[str, str]] = {}
|
|
81
|
+
|
|
82
|
+
def _check_url(self, url: str) -> str:
|
|
83
|
+
if self.config.require_https and not url.startswith("https://"):
|
|
84
|
+
local = url.startswith(("http://127.0.0.1", "http://localhost"))
|
|
85
|
+
if not local:
|
|
86
|
+
raise OIDCError("insecure_idp", f"refusing non-HTTPS identity provider URL {url}")
|
|
87
|
+
return url
|
|
88
|
+
|
|
89
|
+
def discover(self) -> dict[str, Any]:
|
|
90
|
+
if self._discovery is None:
|
|
91
|
+
base = self._check_url(self.config.issuer.rstrip("/"))
|
|
92
|
+
try:
|
|
93
|
+
doc = (
|
|
94
|
+
self.http.get(f"{base}/.well-known/openid-configuration")
|
|
95
|
+
.raise_for_status()
|
|
96
|
+
.json()
|
|
97
|
+
)
|
|
98
|
+
except (httpx.HTTPError, ValueError) as exc:
|
|
99
|
+
raise OIDCError("discovery_failed", f"OIDC discovery failed: {exc}") from exc
|
|
100
|
+
if str(doc.get("issuer", "")).rstrip("/") != self.config.issuer.rstrip("/"):
|
|
101
|
+
raise OIDCError(
|
|
102
|
+
"issuer_mismatch",
|
|
103
|
+
"discovery document issuer differs from the configured issuer",
|
|
104
|
+
)
|
|
105
|
+
for key in ("authorization_endpoint", "token_endpoint", "jwks_uri"):
|
|
106
|
+
if not doc.get(key):
|
|
107
|
+
raise OIDCError("discovery_failed", f"discovery document lacks {key}")
|
|
108
|
+
self._check_url(doc[key])
|
|
109
|
+
self._discovery = doc
|
|
110
|
+
return self._discovery
|
|
111
|
+
|
|
112
|
+
def _jwks(self, force: bool = False) -> dict[str, Any]:
|
|
113
|
+
if force or not self._keys or time.time() - self._keys_fetched > 3600:
|
|
114
|
+
if force and time.time() - self._keys_fetched < 10:
|
|
115
|
+
return self._keys # do not let a stream of unknown kids hammer the IdP
|
|
116
|
+
try:
|
|
117
|
+
data = self.http.get(self.discover()["jwks_uri"]).raise_for_status().json()
|
|
118
|
+
except (httpx.HTTPError, ValueError) as exc:
|
|
119
|
+
raise OIDCError(
|
|
120
|
+
"jwks_failed", f"could not fetch the IdP signing keys: {exc}"
|
|
121
|
+
) from exc
|
|
122
|
+
self._keys = {k["kid"]: k for k in data.get("keys", []) if k.get("kid")}
|
|
123
|
+
self._keys_fetched = time.time()
|
|
124
|
+
return self._keys
|
|
125
|
+
|
|
126
|
+
# ------------------------------------------------------------------ tokens
|
|
127
|
+
|
|
128
|
+
def validate_id_token(self, token: str, nonce: str | None) -> dict[str, Any]:
|
|
129
|
+
try:
|
|
130
|
+
header = jwt.get_unverified_header(token)
|
|
131
|
+
except jwt.PyJWTError as exc:
|
|
132
|
+
raise OIDCError("bad_token", f"malformed ID token: {exc}") from exc
|
|
133
|
+
alg = header.get("alg")
|
|
134
|
+
if alg not in ALLOWED_ALGS:
|
|
135
|
+
raise OIDCError("bad_algorithm", f"ID token algorithm {alg!r} is not accepted")
|
|
136
|
+
kid = header.get("kid")
|
|
137
|
+
jwk = self._jwks().get(kid) or self._jwks(force=True).get(kid)
|
|
138
|
+
if jwk is None:
|
|
139
|
+
raise OIDCError("unknown_key", "ID token is signed with a key the IdP does not publish")
|
|
140
|
+
try:
|
|
141
|
+
key = jwt.PyJWK(jwk).key
|
|
142
|
+
claims = jwt.decode(
|
|
143
|
+
token,
|
|
144
|
+
key,
|
|
145
|
+
algorithms=[alg],
|
|
146
|
+
audience=self.config.client_id,
|
|
147
|
+
issuer=self.config.issuer,
|
|
148
|
+
leeway=LEEWAY,
|
|
149
|
+
options={"require": ["exp", "iat", "iss", "aud", "sub"]},
|
|
150
|
+
)
|
|
151
|
+
except jwt.ExpiredSignatureError as exc:
|
|
152
|
+
raise OIDCError("expired", "ID token has expired") from exc
|
|
153
|
+
except jwt.InvalidAudienceError as exc:
|
|
154
|
+
raise OIDCError("bad_audience", "ID token was issued for a different client") from exc
|
|
155
|
+
except jwt.InvalidIssuerError as exc:
|
|
156
|
+
raise OIDCError("bad_issuer", "ID token has the wrong issuer") from exc
|
|
157
|
+
except jwt.InvalidSignatureError as exc:
|
|
158
|
+
raise OIDCError("bad_signature", "ID token signature does not verify") from exc
|
|
159
|
+
except jwt.PyJWTError as exc:
|
|
160
|
+
raise OIDCError("bad_token", f"invalid ID token: {exc}") from exc
|
|
161
|
+
aud = claims.get("aud")
|
|
162
|
+
if isinstance(aud, list) and len(aud) > 1 and claims.get("azp") != self.config.client_id:
|
|
163
|
+
raise OIDCError("bad_audience", "ID token azp does not match this client")
|
|
164
|
+
if nonce is not None and not secrets.compare_digest(str(claims.get("nonce", "")), nonce):
|
|
165
|
+
raise OIDCError("bad_nonce", "ID token nonce does not match this login")
|
|
166
|
+
return claims
|
|
167
|
+
|
|
168
|
+
# ------------------------------------------------------------------- login
|
|
169
|
+
|
|
170
|
+
def start(self) -> dict[str, str]:
|
|
171
|
+
doc = self.discover()
|
|
172
|
+
state, nonce = secrets.token_urlsafe(24), secrets.token_urlsafe(24)
|
|
173
|
+
verifier = secrets.token_urlsafe(48)
|
|
174
|
+
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(
|
|
175
|
+
b"="
|
|
176
|
+
)
|
|
177
|
+
now = time.time()
|
|
178
|
+
self._logins = {s: v for s, v in self._logins.items() if now - float(v["t"]) < LOGIN_TTL}
|
|
179
|
+
self._logins[state] = {"nonce": nonce, "verifier": verifier, "t": str(now)}
|
|
180
|
+
query = urlencode(
|
|
181
|
+
{
|
|
182
|
+
"response_type": "code",
|
|
183
|
+
"client_id": self.config.client_id,
|
|
184
|
+
"redirect_uri": self.config.redirect_uri,
|
|
185
|
+
"scope": self.config.scopes,
|
|
186
|
+
"state": state,
|
|
187
|
+
"nonce": nonce,
|
|
188
|
+
"code_challenge": challenge.decode(),
|
|
189
|
+
"code_challenge_method": "S256",
|
|
190
|
+
}
|
|
191
|
+
)
|
|
192
|
+
return {"url": f"{doc['authorization_endpoint']}?{query}", "state": state}
|
|
193
|
+
|
|
194
|
+
def finish(self, code: str, state: str) -> dict[str, Any]:
|
|
195
|
+
"""Exchange the code, validate the ID token; returns the claims plus the mapped role."""
|
|
196
|
+
login = self._logins.pop(state, None)
|
|
197
|
+
if login is None or time.time() - float(login["t"]) > LOGIN_TTL:
|
|
198
|
+
raise OIDCError("bad_state", "unknown or expired login; start again")
|
|
199
|
+
data = {
|
|
200
|
+
"grant_type": "authorization_code",
|
|
201
|
+
"code": code,
|
|
202
|
+
"redirect_uri": self.config.redirect_uri,
|
|
203
|
+
"client_id": self.config.client_id,
|
|
204
|
+
"code_verifier": login["verifier"],
|
|
205
|
+
}
|
|
206
|
+
if self.config.client_secret:
|
|
207
|
+
data["client_secret"] = self.config.client_secret
|
|
208
|
+
try:
|
|
209
|
+
response = self.http.post(self.discover()["token_endpoint"], data=data)
|
|
210
|
+
response.raise_for_status()
|
|
211
|
+
token = response.json().get("id_token")
|
|
212
|
+
except (httpx.HTTPError, ValueError) as exc:
|
|
213
|
+
raise OIDCError("token_exchange_failed", f"token exchange failed: {exc}") from exc
|
|
214
|
+
if not token:
|
|
215
|
+
raise OIDCError("token_exchange_failed", "the IdP returned no id_token")
|
|
216
|
+
claims = self.validate_id_token(token, login["nonce"])
|
|
217
|
+
email = str(claims.get("email") or "").lower()
|
|
218
|
+
if not email:
|
|
219
|
+
raise OIDCError("no_email", "the ID token has no email claim (request the email scope)")
|
|
220
|
+
if claims.get("email_verified") is False:
|
|
221
|
+
raise OIDCError("email_unverified", "the IdP says this email address is not verified")
|
|
222
|
+
domains = [d.lower().lstrip("@") for d in self.config.allowed_domains]
|
|
223
|
+
if domains and email.rsplit("@", 1)[-1] not in domains:
|
|
224
|
+
raise OIDCError("domain_not_allowed", f"{email} is not in an allowed email domain")
|
|
225
|
+
return {**claims, "email": email, "role": role_for(self.config, claims)}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def load_oidc_from_env() -> OIDCClient | None:
|
|
229
|
+
"""``TRACEFORGE_OIDC_CONFIG`` (a YAML file) plus ``TRACEFORGE_OIDC_CLIENT_SECRET``."""
|
|
230
|
+
import os
|
|
231
|
+
from pathlib import Path
|
|
232
|
+
|
|
233
|
+
import yaml
|
|
234
|
+
|
|
235
|
+
path = os.environ.get("TRACEFORGE_OIDC_CONFIG")
|
|
236
|
+
if not path:
|
|
237
|
+
return None
|
|
238
|
+
data = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {}
|
|
239
|
+
secret = os.environ.get("TRACEFORGE_OIDC_CLIENT_SECRET", "")
|
|
240
|
+
return OIDCClient(OIDCConfig.from_dict(data, secret))
|