baton-auth 0.1.3__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.
baton_auth/__init__.py ADDED
@@ -0,0 +1,45 @@
1
+ """baton: pass auth context between agents as leases, never as secrets."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from .crypto import CryptoError, zeroize
6
+ from .models import (
7
+ BatonError,
8
+ Context,
9
+ Credential,
10
+ Lease,
11
+ LeaseError,
12
+ RefreshError,
13
+ UnknownContext,
14
+ UnknownCredential,
15
+ VaultLocked,
16
+ )
17
+
18
+ try:
19
+ __version__ = version("baton-auth")
20
+ except PackageNotFoundError: # running from an unpackaged source tree
21
+ __version__ = "0+local"
22
+
23
+ __all__ = [
24
+ "BatonError",
25
+ "Context",
26
+ "Credential",
27
+ "CryptoError",
28
+ "Lease",
29
+ "LeaseError",
30
+ "RefreshError",
31
+ "UnknownContext",
32
+ "UnknownCredential",
33
+ "VaultLocked",
34
+ "Vault",
35
+ "zeroize",
36
+ "__version__",
37
+ ]
38
+
39
+
40
+ def __getattr__(name: str):
41
+ if name == "Vault":
42
+ from .vault import Vault
43
+
44
+ return Vault
45
+ raise AttributeError(name)
baton_auth/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Run the operator CLI with ``python -m baton_auth``."""
2
+
3
+ from .cli import main
4
+
5
+
6
+ if __name__ == "__main__":
7
+ raise SystemExit(main())
baton_auth/broker.py ADDED
@@ -0,0 +1,342 @@
1
+ """Brokered HTTP: the agent asks for a URL, the broker attaches the credential.
2
+
3
+ This is the second half of the never-hand-over-the-secret rule. Egress for APIs
4
+ goes through here so the token is used by the tool layer, not pasted into a
5
+ prompt. Signing schemes live with the credential, not with the caller.
6
+
7
+ SigV4 uses SHA-256 because the AWS specification fixes it; everything else in
8
+ baton uses BLAKE3.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import base64
14
+ import hashlib
15
+ import hmac
16
+ import ipaddress
17
+ import socket
18
+ from dataclasses import dataclass, field
19
+ from datetime import datetime, timezone
20
+ from typing import Any
21
+ from urllib.parse import quote, urlparse
22
+
23
+ import httpx
24
+
25
+ from .models import BatonError, Credential, LeaseError, UnknownCredential
26
+ from .redact import Redactor
27
+ from .vault import Vault
28
+
29
+ MAX_BODY = 200_000
30
+
31
+ _SAFE_HEADERS = {
32
+ "content-type",
33
+ "content-length",
34
+ "date",
35
+ "etag",
36
+ "last-modified",
37
+ "location",
38
+ "retry-after",
39
+ "x-ratelimit-limit",
40
+ "x-ratelimit-remaining",
41
+ "x-ratelimit-reset",
42
+ "x-request-id",
43
+ }
44
+
45
+ # Cloud metadata and internal link-local endpoints blocked by default
46
+ _METADATA_HOSTS = frozenset({
47
+ "169.254.169.254",
48
+ "metadata.google.internal",
49
+ "metadata.local",
50
+ "fd00:ec2::254",
51
+ })
52
+
53
+
54
+ def validate_target_url(
55
+ url: str,
56
+ *,
57
+ allow_private: bool = False,
58
+ allow_loopback: bool = True,
59
+ ) -> None:
60
+ """SSRF guard: reject non-http schemes, metadata services, and internal/private IPs."""
61
+ parsed = urlparse(url)
62
+ scheme = parsed.scheme.lower()
63
+ if scheme not in ("http", "https"):
64
+ raise BrokerError(f"unsupported URL scheme {scheme!r} (must be http or https)")
65
+
66
+ host = parsed.hostname
67
+ if not host:
68
+ raise BrokerError("missing host in URL")
69
+
70
+ host_lower = host.lower()
71
+ if host_lower in _METADATA_HOSTS:
72
+ raise BrokerError(f"SSRF protection: access to metadata endpoint {host!r} is blocked")
73
+
74
+ try:
75
+ addr_infos = socket.getaddrinfo(host, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
76
+ except socket.gaierror as exc:
77
+ raise BrokerError(f"could not resolve host {host!r}: {exc}") from exc
78
+
79
+ for _, _, _, _, sockaddr in addr_infos:
80
+ ip_str = sockaddr[0]
81
+ try:
82
+ ip = ipaddress.ip_address(ip_str)
83
+ except ValueError:
84
+ continue
85
+
86
+ if not allow_loopback and ip.is_loopback:
87
+ raise BrokerError(f"SSRF protection: loopback address {ip_str} is blocked")
88
+
89
+ if ip.is_link_local:
90
+ raise BrokerError(f"SSRF protection: link-local address {ip_str} is blocked")
91
+
92
+ if not allow_private and ip.is_private and not ip.is_loopback:
93
+ raise BrokerError(f"SSRF protection: private network address {ip_str} is blocked")
94
+
95
+
96
+
97
+ class BrokerError(BatonError):
98
+ """Request could not be attributed to a credential."""
99
+
100
+
101
+ @dataclass(slots=True)
102
+ class HttpResponse:
103
+ url: str
104
+ status: int
105
+ headers: dict[str, str] = field(default_factory=dict)
106
+ body: str = ""
107
+ truncated: bool = False
108
+ service: str = ""
109
+ credential: str = ""
110
+ redactions: int = 0
111
+
112
+ def as_dict(self) -> dict[str, Any]:
113
+ return {
114
+ "url": self.url,
115
+ "status": self.status,
116
+ "headers": self.headers,
117
+ "body": self.body,
118
+ "truncated": self.truncated,
119
+ "service": self.service,
120
+ "credential": self.credential,
121
+ "redactions": self.redactions,
122
+ }
123
+
124
+
125
+ def pick_credential(creds: list[Credential], service: str | None) -> Credential:
126
+ if service:
127
+ for c in creds:
128
+ if c.name == service or c.provider == service:
129
+ return c
130
+ raise UnknownCredential(f"no credential for service {service!r} in context")
131
+ if len(creds) == 1:
132
+ return creds[0]
133
+ raise BrokerError(
134
+ "context has several credentials; name one with service=" + str([c.name for c in creds])
135
+ )
136
+
137
+
138
+ def _pick_credential_name(vault: Vault, names: list[str], service: str | None) -> str:
139
+ """Select using public metadata before decrypting or refreshing a credential."""
140
+ if service:
141
+ for name in names:
142
+ provider = vault.get_credential_provider(name)
143
+ if provider is None:
144
+ raise UnknownCredential(f"unknown credential {name!r} (see `baton cred list`)")
145
+ if name == service or provider == service:
146
+ return name
147
+ raise UnknownCredential(f"no credential for service {service!r} in context")
148
+ if len(names) == 1:
149
+ return names[0]
150
+ raise BrokerError(
151
+ "context has several credentials; name one with service=" + str(names)
152
+ )
153
+
154
+
155
+ def _sigv4(
156
+ cred: Credential, method: str, url: str, headers: dict[str, str], body: bytes
157
+ ) -> dict[str, str]:
158
+ """AWS Signature Version 4 for a single request."""
159
+ akid = cred.secret.get("access_key_id", "")
160
+ secret = cred.secret.get("secret_access_key", "")
161
+ if not akid or not secret:
162
+ raise BrokerError(f"credential {cred.name} lacks AWS key material")
163
+ region = cred.secret.get("region") or "us-east-1"
164
+ service = str(cred.auth.get("service") or "execute-api")
165
+
166
+ parsed = urlparse(url)
167
+ host = parsed.netloc
168
+ now = datetime.now(timezone.utc)
169
+ amzdate = now.strftime("%Y%m%dT%H%M%SZ")
170
+ datestamp = now.strftime("%Y%m%d")
171
+
172
+ signed = {k.lower(): v for k, v in headers.items()}
173
+ signed["host"] = host
174
+ signed["x-amz-date"] = amzdate
175
+ if cred.secret.get("session_token"):
176
+ signed["x-amz-security-token"] = cred.secret["session_token"]
177
+
178
+ canonical_uri = quote(parsed.path or "/", safe="/-_.~")
179
+ canonical_query = "&".join(
180
+ sorted(
181
+ quote(k, safe="-_.~") + "=" + quote(v, safe="-_.~")
182
+ for k, v in (httpx.QueryParams(parsed.query).multi_items() if parsed.query else [])
183
+ )
184
+ )
185
+ keys = sorted(signed)
186
+ canonical_headers = "".join(f"{k}:{signed[k].strip()}\n" for k in keys)
187
+ signed_headers = ";".join(keys)
188
+ payload_hash = hashlib.sha256(body).hexdigest()
189
+
190
+ canonical_request = "\n".join(
191
+ [method, canonical_uri, canonical_query, canonical_headers, signed_headers, payload_hash]
192
+ )
193
+ scope = f"{datestamp}/{region}/{service}/aws4_request"
194
+ string_to_sign = "\n".join(
195
+ [
196
+ "AWS4-HMAC-SHA256",
197
+ amzdate,
198
+ scope,
199
+ hashlib.sha256(canonical_request.encode()).hexdigest(),
200
+ ]
201
+ )
202
+
203
+ def _hmac(key: bytes, msg: str) -> bytes:
204
+ return hmac.new(key, msg.encode(), hashlib.sha256).digest()
205
+
206
+ k_date = _hmac(b"AWS4" + secret.encode(), datestamp)
207
+ k_region = _hmac(k_date, region)
208
+ k_service = _hmac(k_region, service)
209
+ k_signing = _hmac(k_service, "aws4_request")
210
+ signature = hmac.new(k_signing, string_to_sign.encode(), hashlib.sha256).hexdigest()
211
+
212
+ out = dict(headers)
213
+ for k in ("host", "x-amz-date", "x-amz-security-token"):
214
+ if k in signed and k not in {h.lower() for h in out}:
215
+ out[k] = signed[k]
216
+ out["Authorization"] = (
217
+ f"AWS4-HMAC-SHA256 Credential={akid}/{scope}, "
218
+ f"SignedHeaders={signed_headers}, Signature={signature}"
219
+ )
220
+ return out
221
+
222
+
223
+ def attach_auth(
224
+ cred: Credential, method: str, url: str, headers: dict[str, str], body: bytes
225
+ ) -> tuple[dict[str, str], dict[str, str]]:
226
+ """Return (headers, query overrides) with the credential applied."""
227
+ scheme = str(cred.auth.get("scheme", "bearer"))
228
+ field_name = str(cred.auth.get("field", "token"))
229
+ secret = cred.secret.get(field_name, "")
230
+ out = dict(headers)
231
+ query: dict[str, str] = {}
232
+
233
+ if scheme == "sigv4":
234
+ return _sigv4(cred, method, url, out, body), query
235
+ if scheme == "bearer":
236
+ if not secret:
237
+ raise BrokerError(f"credential {cred.name} has no {field_name}")
238
+ out["Authorization"] = f"Bearer {secret}"
239
+ elif scheme == "header":
240
+ header_name = str(cred.auth.get("header", "x-api-key"))
241
+ if not secret:
242
+ raise BrokerError(f"credential {cred.name} has no {field_name}")
243
+ out[header_name] = secret
244
+ elif scheme == "basic":
245
+ user = cred.secret.get(str(cred.auth.get("user", "username")), "")
246
+ password = cred.secret.get(str(cred.auth.get("password", "password")), "")
247
+ out["Authorization"] = "Basic " + base64.b64encode(f"{user}:{password}".encode()).decode()
248
+ elif scheme == "query":
249
+ if not secret:
250
+ raise BrokerError(f"credential {cred.name} has no {field_name}")
251
+ query[str(cred.auth.get("param", "key"))] = secret
252
+ elif scheme == "none":
253
+ pass
254
+ else:
255
+ raise BrokerError(f"unsupported auth scheme {scheme!r}")
256
+ return out, query
257
+
258
+
259
+ def request(
260
+ vault: Vault,
261
+ target: str,
262
+ method: str,
263
+ url: str,
264
+ *,
265
+ service: str | None = None,
266
+ headers: dict[str, str] | None = None,
267
+ body: bytes | None = None,
268
+ json_body: Any | None = None,
269
+ timeout: float = 30.0,
270
+ allow_private: bool = False,
271
+ allow_loopback: bool = True,
272
+ ) -> HttpResponse:
273
+ validate_target_url(url, allow_private=allow_private, allow_loopback=allow_loopback)
274
+ lease = None
275
+ if target.startswith("btn_"):
276
+ lease = vault.resolve_lease(target)
277
+ if not lease.allows(service):
278
+ raise LeaseError(f"lease does not grant {service!r}")
279
+ context_name = lease.context
280
+ else:
281
+ context_name = target
282
+
283
+ ctx = vault.get_context(context_name)
284
+ if not ctx.credentials:
285
+ raise BrokerError(f"context {context_name} has no credentials")
286
+ cred_name = _pick_credential_name(vault, ctx.credentials, service)
287
+ cred = vault.get_credential(cred_name, auto_refresh=True)
288
+
289
+ payload = body if body is not None else b""
290
+ if json_body is not None:
291
+ import json as _json
292
+
293
+ payload = _json.dumps(json_body).encode()
294
+ headers = {**(headers or {}), "content-type": "application/json"}
295
+
296
+ public_url = url
297
+ final_headers, query = attach_auth(cred, method.upper(), url, dict(headers or {}), payload)
298
+ if query:
299
+ sep = "&" if "?" in url else "?"
300
+ url = url + sep + "&".join(f"{k}={quote(v, safe='')}" for k, v in query.items())
301
+
302
+ # A redirect destination has not passed the SSRF guard. Return the 3xx
303
+ # response so the caller can explicitly make a separately validated request.
304
+ with httpx.Client(timeout=timeout, follow_redirects=False) as client:
305
+ resp = client.request(method.upper(), url, headers=final_headers, content=payload or None)
306
+
307
+ # Query auth percent-encodes values. Echoes in bodies or Location headers
308
+ # need to be scrubbed in both their original and wire representations.
309
+ secret_values = list(cred.secret.values())
310
+ redactor = Redactor([*secret_values, *(quote(value, safe="") for value in secret_values)])
311
+ raw = resp.text
312
+ truncated = len(raw) > MAX_BODY
313
+ clean, hits = redactor.redact_counted(raw[:MAX_BODY])
314
+ safe_headers = {
315
+ k: v for k, v in resp.headers.items() if k.lower() in _SAFE_HEADERS
316
+ }
317
+ clean_headers = {}
318
+ for k, v in safe_headers.items():
319
+ clean_headers[k], n = redactor.redact_counted(v)
320
+ hits += n
321
+ # Never return the URL after query authentication has added a secret.
322
+ safe_url, n = redactor.redact_counted(public_url)
323
+ hits += n
324
+
325
+ vault.audit(
326
+ lease.id if lease else None,
327
+ None,
328
+ "http",
329
+ f"{method.upper()} {urlparse(url).netloc}{urlparse(url).path}",
330
+ resp.is_success,
331
+ {"status": resp.status_code, "credential": cred.name},
332
+ )
333
+ return HttpResponse(
334
+ url=safe_url,
335
+ status=resp.status_code,
336
+ headers=clean_headers,
337
+ body=clean,
338
+ truncated=truncated,
339
+ service=service or cred.provider,
340
+ credential=cred.name,
341
+ redactions=hits,
342
+ )
baton_auth/catalog.py ADDED
@@ -0,0 +1,268 @@
1
+ """Provider presets: how a secret becomes a working CLI auth.
2
+
3
+ A preset is the whole domain knowledge of this tool. For each provider it fixes
4
+ (a) the secret fields, (b) the materialization steps that make a CLI accept
5
+ those fields in an isolated HOME, and (c) how the brokered HTTP layer attaches
6
+ the credential.
7
+
8
+ Templating: `{field}` is substituted from the secret payload, `{HOME}` from the
9
+ sandbox home. Anything else left unresolved is a hard error at plan time.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field
15
+ from typing import Any
16
+
17
+ from .models import BatonError, Credential
18
+
19
+
20
+ class PresetError(BatonError):
21
+ """Invalid provider or missing required fields."""
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class Preset:
26
+ provider: str
27
+ fields: tuple[str, ...] = ()
28
+ optional: tuple[str, ...] = ()
29
+ materialize: tuple[dict[str, Any], ...] = ()
30
+ auth: dict[str, Any] = field(default_factory=dict)
31
+ hint: str = ""
32
+
33
+ @property
34
+ def all_fields(self) -> tuple[str, ...]:
35
+ return self.fields + self.optional
36
+
37
+
38
+ _GH_HOSTS = "github.com:\n oauth_token: {token}\n git_protocol: https\n"
39
+ _GH_HOSTS_USER = " user: {user}\n"
40
+
41
+ PRESETS: dict[str, Preset] = {
42
+ "aws": Preset(
43
+ provider="aws",
44
+ fields=("access_key_id", "secret_access_key"),
45
+ optional=("session_token", "region"),
46
+ materialize=(
47
+ {
48
+ "kind": "env",
49
+ "map": {
50
+ "AWS_ACCESS_KEY_ID": "{access_key_id}",
51
+ "AWS_SECRET_ACCESS_KEY": "{secret_access_key}",
52
+ "AWS_SESSION_TOKEN": "{session_token}",
53
+ "AWS_REGION": "{region}",
54
+ "AWS_DEFAULT_REGION": "{region}",
55
+ },
56
+ },
57
+ {
58
+ "kind": "ini",
59
+ "path": ".aws/credentials",
60
+ "section": "default",
61
+ "map": {
62
+ "aws_access_key_id": "{access_key_id}",
63
+ "aws_secret_access_key": "{secret_access_key}",
64
+ "aws_session_token": "{session_token}",
65
+ },
66
+ },
67
+ ),
68
+ auth={"scheme": "sigv4", "service": "execute-api"},
69
+ hint="AWS access key (or STS session credentials)",
70
+ ),
71
+ "gcp": Preset(
72
+ provider="gcp",
73
+ fields=("service_account_json",),
74
+ materialize=(
75
+ {
76
+ "kind": "file",
77
+ "path": ".config/gcloud/baton-sa.json",
78
+ "mode": "0600",
79
+ "template": "{service_account_json}",
80
+ },
81
+ {
82
+ "kind": "env",
83
+ "map": {
84
+ "GOOGLE_APPLICATION_CREDENTIALS": "{HOME}/.config/gcloud/baton-sa.json",
85
+ "CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE": "{HOME}/.config/gcloud/baton-sa.json",
86
+ },
87
+ },
88
+ ),
89
+ auth={"scheme": "bearer", "field": "access_token"},
90
+ hint="GCP service-account JSON blob",
91
+ ),
92
+ "github": Preset(
93
+ provider="github",
94
+ fields=("token",),
95
+ optional=("user",),
96
+ materialize=(
97
+ {
98
+ "kind": "env",
99
+ "map": {"GH_TOKEN": "{token}", "GITHUB_TOKEN": "{token}"},
100
+ },
101
+ {"kind": "file", "path": ".config/gh/hosts.yml", "template": _GH_HOSTS},
102
+ {
103
+ "kind": "file",
104
+ "path": ".config/gh/hosts.yml",
105
+ "optional": True,
106
+ "template": _GH_HOSTS_USER,
107
+ },
108
+ ),
109
+ auth={"scheme": "bearer", "field": "token"},
110
+ hint="GitHub PAT (ghp_/github_pat_) or fine-grained token",
111
+ ),
112
+ "gitlab": Preset(
113
+ provider="gitlab",
114
+ fields=("token",),
115
+ materialize=(
116
+ {"kind": "env", "map": {"GITLAB_TOKEN": "{token}", "GLAB_TOKEN": "{token}"}},
117
+ ),
118
+ auth={"scheme": "header", "header": "PRIVATE-TOKEN", "field": "token"},
119
+ hint="GitLab PAT (glpat-)",
120
+ ),
121
+ "npm": Preset(
122
+ provider="npm",
123
+ fields=("token",),
124
+ materialize=(
125
+ {"kind": "env", "map": {"NPM_TOKEN": "{token}"}},
126
+ {
127
+ "kind": "file",
128
+ "path": ".npmrc",
129
+ "template": "//registry.npmjs.org/:_authToken={token}\n",
130
+ },
131
+ ),
132
+ auth={"scheme": "bearer", "field": "token"},
133
+ hint="npm registry token",
134
+ ),
135
+ "docker": Preset(
136
+ provider="docker",
137
+ fields=("username", "password"),
138
+ optional=("registry",),
139
+ materialize=(
140
+ {
141
+ "kind": "docker_auth",
142
+ "path": ".docker/config.json",
143
+ "registry": "{registry}",
144
+ "username": "{username}",
145
+ "password": "{password}",
146
+ },
147
+ ),
148
+ auth={"scheme": "basic", "user": "username", "password": "password"},
149
+ hint="Container registry credentials",
150
+ ),
151
+ "kube": Preset(
152
+ provider="kube",
153
+ fields=("kubeconfig",),
154
+ materialize=(
155
+ {
156
+ "kind": "file",
157
+ "path": ".kube/config",
158
+ "mode": "0600",
159
+ "template": "{kubeconfig}",
160
+ },
161
+ {"kind": "env", "map": {"KUBECONFIG": "{HOME}/.kube/config"}},
162
+ ),
163
+ auth={"scheme": "bearer", "field": "token"},
164
+ hint="Full kubeconfig YAML",
165
+ ),
166
+ "ssh": Preset(
167
+ provider="ssh",
168
+ fields=("private_key",),
169
+ optional=("public_key", "known_hosts", "key_name"),
170
+ materialize=(
171
+ {
172
+ "kind": "file",
173
+ "path": ".ssh/{key_name}",
174
+ "mode": "0600",
175
+ "template": "{private_key}\n",
176
+ },
177
+ {
178
+ "kind": "file",
179
+ "path": ".ssh/{key_name}.pub",
180
+ "mode": "0644",
181
+ "optional": True,
182
+ "template": "{public_key}\n",
183
+ },
184
+ {
185
+ "kind": "file",
186
+ "path": ".ssh/known_hosts",
187
+ "mode": "0644",
188
+ "optional": True,
189
+ "template": "{known_hosts}",
190
+ },
191
+ ),
192
+ hint="SSH private key material",
193
+ ),
194
+ "generic": Preset(
195
+ provider="generic",
196
+ fields=("token",),
197
+ materialize=(),
198
+ auth={"scheme": "bearer", "field": "token"},
199
+ hint="Bare token; add env mapping with --env NAME={token}",
200
+ ),
201
+ }
202
+
203
+
204
+ def preset(provider: str) -> Preset:
205
+ if not provider.strip():
206
+ raise PresetError("provider name cannot be empty")
207
+ return PRESETS.get(provider) or Preset(
208
+ provider=provider,
209
+ auth={"scheme": "bearer", "field": "token"},
210
+ hint="Custom provider; bearer token by default",
211
+ )
212
+
213
+
214
+ def build_credential(
215
+ name: str,
216
+ provider: str,
217
+ values: dict[str, str],
218
+ *,
219
+ expires_at: float | None = None,
220
+ notes: str = "",
221
+ env_map: dict[str, str] | None = None,
222
+ refresh: dict[str, Any] | None = None,
223
+ auth: dict[str, Any] | None = None,
224
+ ) -> Credential:
225
+ """Validate field coverage and assemble a Credential from a preset."""
226
+ p = preset(provider)
227
+ if provider == "ssh" and "key_name" not in values:
228
+ values = {**values, "key_name": "id_baton"}
229
+ auth_spec = dict(auth) if auth is not None else dict(p.auth)
230
+ if provider in PRESETS and provider != "generic":
231
+ required = list(p.fields)
232
+ elif auth_spec.get("scheme") == "basic":
233
+ required = ["username", "password"]
234
+ elif auth_spec.get("scheme") == "none":
235
+ required = []
236
+ else:
237
+ required = [str(auth_spec.get("field", "token"))]
238
+ if auth is not None:
239
+ if auth_spec.get("scheme") == "basic":
240
+ required.extend(("username", "password"))
241
+ elif auth_spec.get("scheme") in {"bearer", "header", "query"}:
242
+ required.append(str(auth_spec.get("field", "token")))
243
+ missing = [f for f in dict.fromkeys(required) if not values.get(f)]
244
+ if missing:
245
+ raise PresetError(f"{provider}: missing required field(s): {', '.join(missing)}")
246
+ if not any(values.values()):
247
+ raise PresetError(f"{provider}: provide at least one secret field")
248
+ allowed = set(p.all_fields) | {"refresh_token", "client_id", "client_secret"}
249
+ unknown = [k for k in values if k not in allowed]
250
+ if unknown and provider in PRESETS and provider != "generic":
251
+ raise PresetError(f"{provider}: unknown field(s): {', '.join(unknown)}")
252
+
253
+ steps: list[dict[str, Any]] = [dict(s) for s in p.materialize]
254
+ if env_map:
255
+ steps.append({"kind": "env", "map": dict(env_map)})
256
+
257
+ # Incomplete optional steps are dropped at plan time; prune empty templates here.
258
+ return Credential(
259
+ name=name,
260
+ provider=provider,
261
+ secret={k: v for k, v in values.items() if v},
262
+ materialize=steps,
263
+ auth=auth_spec,
264
+ refresh=dict(refresh or {}),
265
+ expires_at=expires_at,
266
+ notes=notes,
267
+ meta={"preset": provider},
268
+ )