@foldrun/core 10.1.0 → 11.1.0

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.
@@ -20,7 +20,8 @@
20
20
  // tier holding it all in memory is the platform's problem, not the tenant's.
21
21
  // Past the limit the body errors — a caller never gets the first megabyte
22
22
  // passed off as the whole answer.
23
- import { platform } from "./platform.js";
23
+ import crypto from "node:crypto";
24
+ import { platform, guardsUntrustedUrls } from "./platform.js";
24
25
  /** How many redirects one request may follow. fetch's own limit is 20;
25
26
  * nothing a tenant points the platform at needs more than a couple. */
26
27
  export const MAX_REDIRECTS = 5;
@@ -80,6 +81,57 @@ export function capBody(res, maxBytes, url) {
80
81
  Object.defineProperty(out, "url", { value: res.url });
81
82
  return out;
82
83
  }
84
+ /** One request with a client certificate, through node's https — a laptop's
85
+ * fetch has no way to present one. Redirects are the caller's. */
86
+ async function fetchWithClientCert(url, init, cert, maxBytes) {
87
+ const https = await import("node:https");
88
+ const body = init.body === undefined || init.body === null ? undefined : typeof init.body === "string" ? init.body : Buffer.from(await new Response(init.body).arrayBuffer());
89
+ const headers = {};
90
+ new Headers(init.headers).forEach((v, k) => (headers[k] = v));
91
+ // fetch decompresses; node's https does not. Ask for none, and decode one
92
+ // that comes anyway (Codex review).
93
+ headers["accept-encoding"] ??= "identity";
94
+ const zlib = await import("node:zlib");
95
+ const { pipeline } = await import("node:stream");
96
+ return new Promise((resolve, reject) => {
97
+ const r = https.request(url, { method: init.method ?? "GET", headers, cert: cert.cert, key: cert.key, ...(cert.passphrase ? { passphrase: cert.passphrase } : {}), ...(init.signal ? { signal: init.signal } : {}) }, (raw) => {
98
+ const enc = String(raw.headers["content-encoding"] ?? "").toLowerCase();
99
+ const decoder = enc === "gzip" || enc === "x-gzip" ? zlib.createGunzip()
100
+ : enc === "deflate" ? zlib.createInflate()
101
+ : enc === "br" ? zlib.createBrotliDecompress()
102
+ : null;
103
+ // pipeline: a reset mid-answer, or a bad compressed stream, rejects;
104
+ // and one side ending destroys the other (Codex review).
105
+ const res = decoder ? pipeline(raw, decoder, (err) => { if (err)
106
+ reject(err); }) : raw;
107
+ const chunks = [];
108
+ let size = 0;
109
+ res.on("data", (c) => {
110
+ // The read limit holds while reading, not after (Codex review).
111
+ size += c.length;
112
+ if (size > maxBytes) {
113
+ res.destroy();
114
+ raw.destroy();
115
+ reject(new Error(`${url}: the answer is larger than ${maxBytes} bytes — not read`));
116
+ return;
117
+ }
118
+ chunks.push(c);
119
+ });
120
+ res.on("end", () => {
121
+ const h = new Headers();
122
+ for (const [k, v] of Object.entries(raw.headers))
123
+ if (v !== undefined && k !== "content-encoding" && k !== "content-length")
124
+ h.set(k, Array.isArray(v) ? v.join(", ") : String(v));
125
+ resolve(new Response(chunks.length ? Buffer.concat(chunks) : null, { status: raw.statusCode ?? 502, headers: h }));
126
+ });
127
+ res.on("error", reject);
128
+ });
129
+ r.on("error", reject);
130
+ if (body !== undefined)
131
+ r.write(body);
132
+ r.end();
133
+ });
134
+ }
83
135
  export class RedirectRefused extends Error {
84
136
  constructor(message) {
85
137
  super(message);
@@ -91,7 +143,15 @@ export async function fetchUntrusted(url, init = {}, opts = {}) {
91
143
  let current = url;
92
144
  let req = { ...init };
93
145
  for (let hop = 0;; hop++) {
94
- const res = await platform.fetchUntrusted(current, { ...req, redirect: "manual" }, { maxBytes });
146
+ const res = opts.clientCert && !guardsUntrustedUrls()
147
+ // A laptop's plain fetch cannot present a certificate: node's https does.
148
+ ? await fetchWithClientCert(current, { ...req, redirect: "manual" }, opts.clientCert, maxBytes)
149
+ : await platform.fetchUntrusted(current, { ...req, redirect: "manual" }, {
150
+ maxBytes,
151
+ // A pool per certificate: a connection that presented one client's
152
+ // certificate is never reused for another's (Codex review).
153
+ ...(opts.clientCert ? (() => { const fp = crypto.createHash("sha256").update(`${opts.clientCert.cert}\n${opts.clientCert.key}`).digest("hex"); return { clientCert: { pool: `oauth-mtls:${fp}`, identity: fp, ...opts.clientCert } }; })() : {}),
154
+ });
95
155
  const location = REDIRECT.has(res.status) ? res.headers.get("location") : null;
96
156
  if (!location || opts.noRedirects)
97
157
  return capBody(res, maxBytes, current);
@@ -111,7 +171,8 @@ export async function fetchUntrusted(url, init = {}, opts = {}) {
111
171
  }
112
172
  const from = new URL(current);
113
173
  const crossOrigin = next.origin !== from.origin;
114
- if (crossOrigin && opts.sameOrigin) {
174
+ // A client certificate is a credential too: never carried to another origin.
175
+ if (crossOrigin && (opts.sameOrigin || opts.clientCert)) {
115
176
  throw new RedirectRefused(`${from.origin} redirected to ${next.origin} — not followed: this request carries a credential, and it goes only where it was sent`);
116
177
  }
117
178
  // fetch's own rewrite: a 303, and a 301/302 answering a POST, is
package/index.ts CHANGED
@@ -46,6 +46,7 @@ export * from "./src/oauth-token.ts";
46
46
  export * from "./src/oauth-discovery.ts";
47
47
  export * from "./src/oauth-assertion.ts";
48
48
  export * from "./src/oauth-device.ts";
49
+ export * from "./src/oauth-dpop.ts";
49
50
  export * from "./src/secret-files.ts";
50
51
  export * from "./src/notify.ts";
51
52
  export * from "./src/webhook.ts";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldrun/core",
3
- "version": "10.1.0",
3
+ "version": "11.1.0",
4
4
  "description": "Agents are just folders. The format, the runner, the checks.",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "type": "module",
package/src/a2a-client.ts CHANGED
@@ -200,6 +200,28 @@ export function routeFor(conn: Pick<A2AConnection, "token" | "egress">, target:
200
200
  return target;
201
201
  }
202
202
 
203
+ /** Why the egress proxy refused a request, or null for an answer that is
204
+ * the remote's. The diagnostic is fixed text of ours; the body's own words
205
+ * go back as `said`, for the untrusted fence — the proxy passes a remote's
206
+ * 403 through as it came, so a remote can send this exact shape (Codex
207
+ * review, 2 Nov 2026). */
208
+ export function egressRefusal(status: number, text: string): { reason: string; said: string | null } | null {
209
+ if (status !== 403) return null;
210
+ let body: unknown;
211
+ try {
212
+ body = JSON.parse(text);
213
+ } catch {
214
+ return null;
215
+ }
216
+ const err = (body as { error?: { type?: unknown; message?: unknown } } | null)?.error;
217
+ if (!err || err.type !== "egress") return null;
218
+ const said = typeof err.message === "string" ? short(err.message.replace(/^egress:\s*/, ""), 200) : "";
219
+ const reason = /private|internal/i.test(said)
220
+ ? "its address is not allowed — this platform does not call private or internal addresses"
221
+ : "the platform's egress refused it";
222
+ return { reason, said: said || null };
223
+ }
224
+
203
225
  /**
204
226
  * One HTTP exchange, read as JSON, to a limit, with no redirect followed.
205
227
  *
@@ -251,6 +273,10 @@ async function exchange(
251
273
  }
252
274
  throw new A2AError(`${conn.remote}: the answer could not be read: ${short((err as Error)?.message ?? err, 200)}`);
253
275
  }
276
+ // The egress proxy's own refusal ({error: {type: "egress"}}) is ours, not
277
+ // the remote's: the address is not allowed, and no secret would change it.
278
+ const egress = egressRefusal(res.status, text);
279
+ if (egress) throw new A2AError(`${conn.remote} cannot be called from here: ${egress.reason}`, null, true, egress.said);
254
280
  if (res.status === 401 || res.status === 403) {
255
281
  throw new A2AError(`${conn.remote} refused the credential (HTTP ${res.status})${conn.token ? "" : " — this remote has no secret: set one"}`, null, true);
256
282
  }
@@ -119,15 +119,18 @@ export const OAUTH_CAPABILITIES: OAuthCapability[] = [
119
119
  },
120
120
  {
121
121
  id: "client.token-exchange", role: "client", title: "Token exchange", spec: "RFC 8693",
122
- status: "planned", note: "Part I: a `token-exchange` secret kind. Cloud STS is covered today by the keyless identity kind.",
122
+ status: "supported", note: "A `token-exchange` secret: another secret's live token traded for a narrower one, cached. Cloud STS is covered by the keyless identity kind; a workload identity as the subject is not yet.",
123
+ proof: [core("tests/oauth-token-exchange.test.ts", "the subject's live token is traded for a narrower one"), platform(KC, "token exchange (RFC 8693)"), cli("tests/cli-connect.test.ts", "--kind token-exchange")],
123
124
  },
124
125
  {
125
126
  id: "client.dpop", role: "client", title: "DPoP sender-constrained tokens", spec: "RFC 9449",
126
- status: "planned", note: "Part H: Bluesky/atproto, Okta DPoP.",
127
+ status: "supported", note: "`dpop: true` on the app (`--dpop`): each connection's own key; the egress proxy signs a proof for every call through a site binding; nonces honoured.",
128
+ proof: [core("tests/oauth-dpop-mtls.test.ts", "refresh with DPoP"), platform("tests/egress-dpop.test.ts", "a used DPoP connection gives its key"), platform(KC, "DPoP (RFC 9449): a connection made with DPoP gets a bound token"), cli("tests/cli-connect.test.ts", "connect --dpop")],
127
129
  },
128
130
  {
129
131
  id: "client.mtls", role: "client", title: "Mutual-TLS client auth and bound tokens", spec: "RFC 8705",
130
- status: "planned", note: "Part H: the client-cert secret at the token endpoint.",
132
+ status: "supported", note: "`token_auth: tls_client_auth` with the certificate and key; presented to the token URL and, through the egress proxy, on the calls the token is bound to.",
133
+ proof: [core("tests/oauth-dpop-mtls.test.ts", "tls_client_auth (RFC 8705)"), platform("tests/egress-dpop.test.ts", "a certificate-authenticated connection gives its certificate"), platform(KC, "mutual TLS (RFC 8705)"), cli("tests/cli-connect.test.ts", "--token-auth mtls stores the certificate")],
131
134
  },
132
135
  {
133
136
  id: "client.ciba", role: "client", title: "Client-initiated backchannel authentication", spec: "OpenID CIBA",
@@ -0,0 +1,93 @@
1
+ // DPoP (RFC 9449): sender-constrained tokens. A connection made with DPoP
2
+ // holds a key of its own; every request to the token endpoint, and every call
3
+ // that uses the access token, carries a fresh proof signed with it — so a
4
+ // token copied off the wire is useless without the key. Bluesky/atproto and
5
+ // Okta's DPoP mode require it.
6
+ //
7
+ // The proof: header { typ: "dpop+jwt", alg, jwk: the public key }, claims
8
+ // { jti, htm, htu (no query or fragment), iat, ath? (SHA-256 of the access
9
+ // token, on resource calls), nonce? (when the server asked for one) }.
10
+ //
11
+ // Where the proofs are made: core's refresh and the platform's code exchange
12
+ // (token requests), and the egress proxy (resource calls) — a DPoP-bound
13
+ // connection is used through a site binding, where the proxy can sign each
14
+ // request; the sandbox never holds the key.
15
+
16
+ import crypto from "node:crypto";
17
+ import { algForKey, readPrivateKey, ASSERTION_ALGS, type AssertionAlg, type Jwk } from "./oauth-assertion.ts";
18
+
19
+ /** A new DPoP key for a connection (EC P-256, ES256): the private PEM. */
20
+ export function generateDpopKey(): string {
21
+ return crypto.generateKeyPairSync("ec", { namedCurve: "P-256" }).privateKey.export({ format: "pem", type: "pkcs8" }).toString();
22
+ }
23
+
24
+ /** The htu of a request: its URL with no query or fragment (RFC 9449 §4.2). */
25
+ export function dpopHtu(url: string): string {
26
+ const u = new URL(url);
27
+ return `${u.origin}${u.pathname}`;
28
+ }
29
+
30
+ /** The ath of an access token: base64url of its SHA-256 (RFC 9449 §4.2). */
31
+ export function dpopAth(accessToken: string): string {
32
+ return crypto.createHash("sha256").update(accessToken).digest("base64url");
33
+ }
34
+
35
+ const b64url = (s: string | Buffer) => Buffer.from(s).toString("base64url");
36
+
37
+ /** A DPoP proof for one request. */
38
+ export function dpopProof(key: string, req: { htm: string; htu: string; ath?: string; nonce?: string }, nowMs = Date.now()): string {
39
+ const k = readPrivateKey(key);
40
+ const alg: AssertionAlg = algForKey(k);
41
+ if (!(ASSERTION_ALGS as readonly string[]).includes(alg) || alg === "PS256") throw new Error("a DPoP key must be EC P-256/P-384, RSA or Ed25519");
42
+ const pub = crypto.createPublicKey(k).export({ format: "jwk" }) as Jwk;
43
+ const header = { typ: "dpop+jwt", alg, jwk: pub };
44
+ const claims = {
45
+ jti: b64url(crypto.randomBytes(18)),
46
+ htm: req.htm.toUpperCase(),
47
+ htu: dpopHtu(req.htu),
48
+ iat: Math.floor(nowMs / 1000),
49
+ ...(req.ath ? { ath: req.ath } : {}),
50
+ ...(req.nonce ? { nonce: req.nonce } : {}),
51
+ };
52
+ const input = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(claims))}`;
53
+ const data = Buffer.from(input);
54
+ const sig =
55
+ alg === "ES256" ? crypto.sign("sha256", data, { key: k, dsaEncoding: "ieee-p1363" })
56
+ : alg === "ES384" ? crypto.sign("sha384", data, { key: k, dsaEncoding: "ieee-p1363" })
57
+ : alg === "EdDSA" ? crypto.sign(null, data, k)
58
+ : crypto.sign("sha256", data, k);
59
+ return `${input}.${b64url(sig)}`;
60
+ }
61
+
62
+ /** The JWK thumbprint the server binds a DPoP token to (cnf.jkt). */
63
+ export function dpopJkt(key: string): string {
64
+ const pub = crypto.createPublicKey(readPrivateKey(key)).export({ format: "jwk" }) as Jwk;
65
+ const members = pub.kty === "EC" ? { crv: pub.crv, kty: pub.kty, x: pub.x, y: pub.y } : pub.kty === "RSA" ? { e: pub.e, kty: pub.kty, n: pub.n } : { crv: pub.crv, kty: pub.kty, x: pub.x };
66
+ return crypto.createHash("sha256").update(JSON.stringify(members)).digest("base64url");
67
+ }
68
+
69
+ // Server-provided nonces (RFC 9449 §8, §9), kept per origin and per DPoP
70
+ // key: the last one a server sent to this connection is the one its next
71
+ // proof carries — two connections to one server never overwrite each other's
72
+ // (a server may issue nonces per client; Codex review).
73
+ const nonces = new Map<string, string>();
74
+ const nonceKey = (url: string, key: string) => `${new URL(url).origin} ${dpopJkt(key)}`;
75
+
76
+ /** Remember a DPoP-Nonce a response to this connection carried. */
77
+ export function noteDpopNonce(url: string, res: { headers: { get(name: string): string | null } }, key: string): void {
78
+ const n = res.headers.get("dpop-nonce");
79
+ if (n) nonces.set(nonceKey(url, key), n);
80
+ }
81
+
82
+ /** The nonce for this connection's next proof to this URL's server, if any. */
83
+ export function dpopNonceFor(url: string, key: string): string | undefined {
84
+ return nonces.get(nonceKey(url, key));
85
+ }
86
+
87
+ /** Whether an answer asks for a (new) nonce: a token endpoint's 400
88
+ * use_dpop_nonce, or a resource's 401 with it in WWW-Authenticate. */
89
+ export function wantsDpopNonce(res: { status: number; headers: { get(name: string): string | null } }, body?: { error?: string }): boolean {
90
+ if (!res.headers.get("dpop-nonce")) return false;
91
+ if (res.status === 400 && body?.error === "use_dpop_nonce") return true;
92
+ return res.status === 401 && /use_dpop_nonce/.test(res.headers.get("www-authenticate") ?? "");
93
+ }
@@ -18,9 +18,10 @@
18
18
  //
19
19
  // Absent means the old behaviour: the body, with the secret if there is one.
20
20
 
21
+ import crypto from "node:crypto";
21
22
  import { ASSERTION_TYPE, assertionKeyProblem, signClientAssertion, type AssertionAlg } from "./oauth-assertion.ts";
22
23
 
23
- export const TOKEN_AUTH_METHODS = ["client_secret_post", "client_secret_basic", "none", "private_key_jwt"] as const;
24
+ export const TOKEN_AUTH_METHODS = ["client_secret_post", "client_secret_basic", "none", "private_key_jwt", "tls_client_auth", "self_signed_tls_client_auth"] as const;
24
25
  export type TokenAuth = (typeof TOKEN_AUTH_METHODS)[number];
25
26
 
26
27
  /** The short spellings a person types (`--token-auth basic`), to the RFC names. */
@@ -30,6 +31,8 @@ export function parseTokenAuth(value: string): TokenAuth | null {
30
31
  if (v === "post" || v === "body") return "client_secret_post";
31
32
  if (v === "basic") return "client_secret_basic";
32
33
  if (v === "jwt" || v === "private-key-jwt" || v === "key") return "private_key_jwt";
34
+ if (v === "mtls" || v === "tls" || v === "cert") return "tls_client_auth";
35
+ if (v === "self-signed-mtls" || v === "self-signed") return "self_signed_tls_client_auth";
33
36
  return null;
34
37
  }
35
38
 
@@ -43,10 +46,15 @@ export interface TokenClient {
43
46
  client_assertion_kid?: string;
44
47
  client_assertion_alg?: AssertionAlg;
45
48
  client_assertion_cert?: string;
49
+ /** tls_client_auth / self_signed_tls_client_auth (RFC 8705): the client
50
+ * certificate and key presented on the connection to the token URL. */
51
+ tls_client_cert?: string;
52
+ tls_client_key?: string;
53
+ tls_client_passphrase?: string;
46
54
  }
47
55
 
48
56
  /** Why a client's token_auth cannot work, or null. */
49
- export function tokenAuthProblem(client: { client_secret?: string; token_auth?: unknown; client_assertion_key?: string; client_assertion_alg?: unknown; client_assertion_cert?: string }): string | null {
57
+ export function tokenAuthProblem(client: { client_secret?: string; token_auth?: unknown; client_assertion_key?: string; client_assertion_alg?: unknown; client_assertion_cert?: string; tls_client_cert?: string; tls_client_key?: string; tls_client_passphrase?: string }): string | null {
50
58
  const auth = client.token_auth;
51
59
  if (auth === undefined) return null;
52
60
  if (typeof auth !== "string" || !(TOKEN_AUTH_METHODS as readonly string[]).includes(auth)) {
@@ -61,6 +69,16 @@ export function tokenAuthProblem(client: { client_secret?: string; token_auth?:
61
69
  if (hasSecret) return "token_auth private_key_jwt signs with a key and sends no client_secret — leave the secret empty";
62
70
  return assertionKeyProblem(client);
63
71
  }
72
+ if (auth === "tls_client_auth" || auth === "self_signed_tls_client_auth") {
73
+ if (hasSecret) return `token_auth ${auth} proves the client with its certificate and sends no client_secret — leave the secret empty`;
74
+ if (!client.tls_client_cert?.trim() || !client.tls_client_key?.trim()) return `token_auth ${auth} needs tls_client_cert and tls_client_key (PEM)`;
75
+ try {
76
+ const key = crypto.createPrivateKey(client.tls_client_passphrase ? { key: client.tls_client_key, passphrase: client.tls_client_passphrase } : client.tls_client_key);
77
+ if (!new crypto.X509Certificate(client.tls_client_cert).checkPrivateKey(key)) return "tls_client_cert is not the certificate of tls_client_key";
78
+ } catch {
79
+ return "tls_client_cert and tls_client_key must be a PEM certificate and its private key";
80
+ }
81
+ }
64
82
  return null;
65
83
  }
66
84
 
@@ -102,6 +120,10 @@ export function oauthTokenRequest(
102
120
  const pair = `${formEncode(client.client_id)}:${formEncode(client.client_secret!)}`;
103
121
  headers.authorization = `Basic ${Buffer.from(pair, "utf8").toString("base64")}`;
104
122
  if (opts.clientIdInBody) form.set("client_id", client.client_id);
123
+ } else if (auth === "tls_client_auth" || auth === "self_signed_tls_client_auth") {
124
+ // The certificate on the connection is the proof (RFC 8705 §2): the
125
+ // caller presents tls_client_cert; the body names the client only.
126
+ form.set("client_id", client.client_id);
105
127
  } else if (auth === "private_key_jwt") {
106
128
  if (!opts.audience) throw new Error("a private_key_jwt request needs the endpoint it goes to (its assertion's aud)");
107
129
  form.set("client_id", client.client_id);
package/src/platform.ts CHANGED
@@ -185,7 +185,7 @@ export interface PlatformHooks {
185
185
  * `maxBytes` is the most the answer may be, read (untrusted-fetch.ts
186
186
  * caps it again, so a laptop's plain fetch may ignore it).
187
187
  */
188
- fetchUntrusted(url: string, init?: RequestInit, opts?: { maxBytes?: number }): Promise<Response>;
188
+ fetchUntrusted(url: string, init?: RequestInit, opts?: { maxBytes?: number; clientCert?: { pool: string; identity: string; cert: string; key: string; passphrase?: string } }): Promise<Response>;
189
189
  /**
190
190
  * May the host call this URL a tenant chose — a provider's base_url,
191
191
  * before an eval's judge or a dashboard draft talks to it? A reason it may
package/src/runner.ts CHANGED
@@ -2407,7 +2407,10 @@ async function runStep(
2407
2407
  const cardProblems = new Map<string, string>();
2408
2408
  for (const r of remotes) {
2409
2409
  const why = await platform.checkUntrustedUrl(r.card);
2410
- if (why) cardProblems.set(r.name, why);
2410
+ if (why) {
2411
+ cardProblems.set(r.name, why);
2412
+ console.warn(`[a2a] remotes: ${r.name} refused for ${tenant}/${path.basename(workspaceRootOf(agentDir))} (${step.agent}): ${why}`);
2413
+ }
2411
2414
  }
2412
2415
  const stepRemotes: RemoteSpec[] = remotes.map((r) => {
2413
2416
  const refused = cardProblems.get(r.name);
package/src/secrets.ts CHANGED
@@ -27,6 +27,7 @@ import { fetchUntrusted } from "./untrusted-fetch.ts";
27
27
  import { oauthTokenRequest, readTokenResponse, tokenAuthProblem, type TokenAuth } from "./oauth-token.ts";
28
28
  import { isSecureUrl } from "./oauth-discovery.ts";
29
29
  import type { AssertionAlg } from "./oauth-assertion.ts";
30
+ import { dpopProof, dpopNonceFor, noteDpopNonce } from "./oauth-dpop.ts";
30
31
 
31
32
  /**
32
33
  * GCM's auth tag, pinned to its full 16 bytes at every cipher call. Without
@@ -45,7 +46,7 @@ const tenantKey = (tenant: string) => platform.tenantKey(tenant);
45
46
  import crypto from "node:crypto";
46
47
  import { assertSafeName } from "./store.ts";
47
48
  import { trimSlashes } from "./paths.ts";
48
- import { SITE } from "./network.ts";
49
+ import { SITE, siteCovers } from "./network.ts";
49
50
 
50
51
  const keyFile = () => path.join(dataRoot(), ".secret-key");
51
52
 
@@ -97,7 +98,7 @@ function secretsFile(tenant: string, workspace?: string) {
97
98
  /** How the stored value is used at run time — "oauth2"/"service-account"
98
99
  * are refreshed into live tokens, "file"/"ssh"/"api" are materialised into
99
100
  * the run's scratch. Plain values have no kind. */
100
- export type SecretKind = "oauth2" | "service-account" | "file" | "ssh" | "api" | "client-cert" | "identity";
101
+ export type SecretKind = "oauth2" | "service-account" | "file" | "ssh" | "api" | "client-cert" | "identity" | "token-exchange";
101
102
 
102
103
  interface StoredSecret {
103
104
  iv: string;
@@ -953,6 +954,15 @@ export interface OAuth2Config {
953
954
  client_assertion_kid?: string;
954
955
  client_assertion_alg?: AssertionAlg;
955
956
  client_assertion_cert?: string;
957
+ /** DPoP (RFC 9449): this connection's own key; token requests and the
958
+ * calls that use its token carry proofs signed with it (oauth-dpop.ts). */
959
+ dpop_key?: string;
960
+ /** tls_client_auth (RFC 8705): the client certificate and key presented
961
+ * to the token URL — and, through the egress proxy, on the calls its
962
+ * certificate-bound token is used for. */
963
+ tls_client_cert?: string;
964
+ tls_client_key?: string;
965
+ tls_client_passphrase?: string;
956
966
  }
957
967
 
958
968
  const OAUTH2_PREFIX = "@oauth2 ";
@@ -978,8 +988,9 @@ export function setOAuth2Secret(
978
988
  }
979
989
  // A public client (PKCE, no secret) refreshes with its client id alone;
980
990
  // client credentials are nothing without the secret.
981
- if (config.grant_type === "client_credentials" && !config.client_secret?.trim() && config.token_auth !== "private_key_jwt") {
982
- throw new Error("oauth2 secret needs client_secret for client_credentials (or token_auth private_key_jwt with a key)");
991
+ // Client credentials need a proof of the client: a secret, a signing key or a certificate.
992
+ if (config.grant_type === "client_credentials" && !config.client_secret?.trim() && !["private_key_jwt", "tls_client_auth", "self_signed_tls_client_auth"].includes(config.token_auth ?? "")) {
993
+ throw new Error("oauth2 secret needs client_secret for client_credentials (or token_auth private_key_jwt with a key, or tls_client_auth with a certificate)");
983
994
  }
984
995
  if (config.grant_type !== "client_credentials" && !config.refresh_token?.trim()) {
985
996
  throw new Error("oauth2 secret needs refresh_token (or grant_type: client_credentials)");
@@ -1016,7 +1027,7 @@ const inFlight = new Map<string, Promise<string>>();
1016
1027
  * second account with the same names be handed the first one's token
1017
1028
  * (Codex debate, 22 Oct 2026).
1018
1029
  */
1019
- export function tokenCacheKey(kind: "oauth2" | "sa", storedValue: string, tenant?: string): string {
1030
+ export function tokenCacheKey(kind: "oauth2" | "sa" | "tx", storedValue: string, tenant?: string): string {
1020
1031
  return crypto.createHash("sha256").update(JSON.stringify([kind, tenant ?? "", storedValue])).digest("hex");
1021
1032
  }
1022
1033
 
@@ -1091,27 +1102,51 @@ async function exchange(config: OAuth2Config, cacheKey: string): Promise<OAuthLi
1091
1102
  // and the reconnect mail; a 5xx or 429 is the provider busy, retried.
1092
1103
  let res: Response | undefined;
1093
1104
  let lastNetwork: unknown;
1105
+ let text = "";
1106
+ // A certificate-authenticated client (RFC 8705) presents it on the connection.
1107
+ const clientCert = config.tls_client_cert && config.tls_client_key
1108
+ ? { cert: config.tls_client_cert, key: config.tls_client_key, ...(config.tls_client_passphrase ? { passphrase: config.tls_client_passphrase } : {}) }
1109
+ : undefined;
1110
+ let nonceRetried = false;
1094
1111
  for (let attempt = 0; attempt < OAUTH_REFRESH_TRIES; attempt++) {
1095
1112
  if (attempt) await new Promise((r) => setTimeout(r, oauthRetryDelayMs(attempt)));
1096
1113
  try {
1097
1114
  const request = build();
1115
+ // DPoP: a fresh proof per try, with the server's nonce when it gave one.
1116
+ const headers = config.dpop_key
1117
+ ? { ...request.headers, dpop: dpopProof(config.dpop_key, { htm: "POST", htu: config.token_url, ...(dpopNonceFor(config.token_url, config.dpop_key) ? { nonce: dpopNonceFor(config.token_url, config.dpop_key) } : {}) }) }
1118
+ : request.headers;
1098
1119
  res = await fetchUntrusted(config.token_url, {
1099
1120
  method: "POST",
1100
- headers: request.headers,
1121
+ headers,
1101
1122
  body: request.body,
1102
1123
  signal: AbortSignal.timeout(15_000),
1103
- }, { sameOrigin: true });
1124
+ // A DPoP proof names this URL and method: a redirect would carry it
1125
+ // somewhere it is not for, so none is followed (Codex review).
1126
+ }, { sameOrigin: true, ...(clientCert ? { clientCert } : {}), ...(config.dpop_key ? { noRedirects: true } : {}) });
1104
1127
  } catch (err) {
1105
1128
  lastNetwork = err;
1106
1129
  res = undefined;
1107
1130
  continue;
1108
1131
  }
1132
+ // Read before deciding to retry: the last answer's own words are the
1133
+ // error a person sees when every try is busy (Codex review).
1134
+ text = await res.text().catch(() => "");
1109
1135
  if (res.status === 429 || res.status >= 500) continue;
1136
+ if (config.dpop_key) {
1137
+ noteDpopNonce(config.token_url, res, config.dpop_key);
1138
+ // The server wants a nonce in the proof (RFC 9449 §8): once more, at once.
1139
+ if (!nonceRetried && res.status === 400 && res.headers.get("dpop-nonce") && readTokenResponse(text, res.headers.get("content-type") ?? "").error === "use_dpop_nonce") {
1140
+ nonceRetried = true;
1141
+ attempt--;
1142
+ continue;
1143
+ }
1144
+ }
1110
1145
  break;
1111
1146
  }
1112
1147
  if (!res) throw lastNetwork instanceof Error ? lastNetwork : new Error(String(lastNetwork));
1113
1148
  // JSON or form-encoded, Slack's nested user token, expires_in as a string.
1114
- const payload = readTokenResponse(await res.text().catch(() => ""), res.headers.get("content-type") ?? "");
1149
+ const payload = readTokenResponse(text, res.headers.get("content-type") ?? "");
1115
1150
  if (!res.ok || !payload.access_token) {
1116
1151
  throw new Error(
1117
1152
  `token refresh failed (${res.status}): ${payload.error_description ?? payload.error ?? "no access_token in the reply"}`,
@@ -1234,7 +1269,9 @@ export async function revokeOAuth2Value(
1234
1269
  }
1235
1270
  // No redirect is followed: a 3xx is not ok, so a revocation endpoint that
1236
1271
  // sends us to a login page is reported, never counted as revoked.
1237
- const fetcher: RevokeFetcher = opts.fetcher ?? ((url, init) => fetchUntrusted(url, init, { sameOrigin: true, noRedirects: true }));
1272
+ // A certificate-authenticated client (RFC 8705) presents it here too.
1273
+ const clientCert = config.tls_client_cert && config.tls_client_key ? { cert: config.tls_client_cert, key: config.tls_client_key, ...(config.tls_client_passphrase ? { passphrase: config.tls_client_passphrase } : {}) } : undefined;
1274
+ const fetcher: RevokeFetcher = opts.fetcher ?? ((url, init) => fetchUntrusted(url, init, { sameOrigin: true, noRedirects: true, ...(clientCert ? { clientCert } : {}) }));
1238
1275
  const key = tokenCacheKey("oauth2", value, opts.tenant);
1239
1276
  const access = oauthCache.get(key)?.token;
1240
1277
  const tokens: ["refresh_token" | "access_token", string][] = [];
@@ -1319,6 +1356,8 @@ export async function materializeSecrets(
1319
1356
  throw new Error(`secret ${name}: ${message}${link ? ` — reconnect it in one click: ${link}` : ""}`);
1320
1357
  }
1321
1358
  if (ctx) await platform.noteOAuthRefresh(ctx, name, null).catch(() => undefined);
1359
+ } else if (value.startsWith(TOKEN_EXCHANGE_PREFIX)) {
1360
+ out[name] = await materializeTokenExchange(name, value, env, ctx);
1322
1361
  } else if (value.startsWith(SERVICE_ACCOUNT_PREFIX)) {
1323
1362
  let config: ServiceAccountConfig;
1324
1363
  try {
@@ -1624,6 +1663,136 @@ export interface IdentityConfig {
1624
1663
  audience: string;
1625
1664
  }
1626
1665
 
1666
+ // ---------------------------------------------------------------- token exchange
1667
+
1668
+ // Token exchange (RFC 8693): a secret that is not a credential of its own but
1669
+ // a recipe for one — present another secret's token (the subject) at a token
1670
+ // endpoint and get back a narrower one: another audience, fewer scopes, a
1671
+ // token a downstream service accepts. Cached like an oauth2 token; the
1672
+ // subject is made live first, the same way it would be on its own.
1673
+
1674
+ export const TOKEN_EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange";
1675
+ const ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token";
1676
+ const TOKEN_EXCHANGE_PREFIX = "@token-exchange ";
1677
+
1678
+ export interface TokenExchangeConfig {
1679
+ token_url: string;
1680
+ client_id: string;
1681
+ client_secret?: string;
1682
+ token_auth?: TokenAuth;
1683
+ client_assertion_key?: string;
1684
+ client_assertion_kid?: string;
1685
+ client_assertion_alg?: AssertionAlg;
1686
+ client_assertion_cert?: string;
1687
+ tls_client_cert?: string;
1688
+ tls_client_key?: string;
1689
+ tls_client_passphrase?: string;
1690
+ /** The secret whose live token is the subject: an oauth2 or service-account
1691
+ * secret, or a static token. Declared beside this one, or found in the
1692
+ * vault at the same scope. */
1693
+ subject_secret: string;
1694
+ /** Default urn:ietf:params:oauth:token-type:access_token. */
1695
+ subject_token_type?: string;
1696
+ requested_token_type?: string;
1697
+ audience?: string;
1698
+ resource?: string;
1699
+ scope?: string;
1700
+ }
1701
+
1702
+ export function setTokenExchangeSecret(tenant: string, name: string, config: TokenExchangeConfig, workspace?: string, limit: SecretLimit = {}) {
1703
+ for (const field of ["token_url", "client_id", "subject_secret"] as const) {
1704
+ if (typeof config[field] !== "string" || !config[field].trim()) throw new Error(`a token-exchange secret needs ${field}`);
1705
+ }
1706
+ if (!SECRET_NAME.test(config.subject_secret)) throw new Error("subject_secret is a secret's name, UPPER_SNAKE_CASE");
1707
+ if (config.subject_secret === name) throw new Error("a token exchange cannot be its own subject");
1708
+ if (!isSecureUrl(config.token_url)) throw new Error("token_url must be https");
1709
+ for (const k of ["subject_token_type", "requested_token_type", "audience", "resource", "scope"] as const) {
1710
+ if (config[k] !== undefined && typeof config[k] !== "string") throw new Error(`${k} is a string`);
1711
+ }
1712
+ const authProblem = tokenAuthProblem(config);
1713
+ if (authProblem) throw new Error(`token-exchange secret: ${authProblem}`);
1714
+ setSecret(tenant, name, TOKEN_EXCHANGE_PREFIX + JSON.stringify(config), workspace, "token-exchange", limit);
1715
+ }
1716
+
1717
+ export function isTokenExchangeValue(value: string): boolean {
1718
+ return value.startsWith(TOKEN_EXCHANGE_PREFIX);
1719
+ }
1720
+
1721
+ const txCache = new Map<string, { token: string; expiresAt: number }>();
1722
+ const txFlight = new Map<string, Promise<string>>();
1723
+
1724
+ async function materializeTokenExchange(
1725
+ name: string,
1726
+ value: string,
1727
+ env: Record<string, string>,
1728
+ ctx?: { tenant: string; workspace?: string },
1729
+ ): Promise<string> {
1730
+ let config: TokenExchangeConfig;
1731
+ try {
1732
+ config = JSON.parse(value.slice(TOKEN_EXCHANGE_PREFIX.length));
1733
+ } catch {
1734
+ throw new Error(`secret ${name}: stored token-exchange config is unreadable`);
1735
+ }
1736
+ // The subject: declared beside this secret, else the vault at this scope.
1737
+ const subjectName = config.subject_secret;
1738
+ let subjectValue: string | undefined = env[subjectName];
1739
+ if (subjectValue === undefined && ctx) subjectValue = getSecret(ctx.tenant, subjectName, ctx.workspace)?.value;
1740
+ if (subjectValue === undefined) throw new Error(`secret ${name}: its subject ${subjectName} is not set — declare it in secrets: beside ${name}`);
1741
+ if (isTokenExchangeValue(subjectValue)) throw new Error(`secret ${name}: its subject ${subjectName} is itself a token exchange — the subject must be a token`);
1742
+ // Only a token may be sent as the subject: a file, ssh key, header set,
1743
+ // certificate or identity recipe would hand its contents to the token
1744
+ // endpoint (Codex review).
1745
+ for (const p of [FILE_PREFIX, SSH_PREFIX, API_PREFIX, CLIENT_CERT_PREFIX, IDENTITY_PREFIX]) {
1746
+ if (subjectValue.startsWith(p)) throw new Error(`secret ${name}: its subject ${subjectName} is a ${p.slice(1).trim()} secret — the subject must be a token (an oauth2, service-account or plain token secret)`);
1747
+ }
1748
+ // A subject bound to sites goes to those sites alone: an exchange whose
1749
+ // token URL is not one of them would carry it elsewhere (Codex review).
1750
+ if (ctx) {
1751
+ const bound = secretSites(ctx.tenant, [subjectName], ctx.workspace)[subjectName] ?? [];
1752
+ const host = new URL(config.token_url).host;
1753
+ if (bound.length && !bound.some((site) => siteCovers(site, host))) {
1754
+ throw new Error(`secret ${name}: its subject ${subjectName} is bound to ${bound.join(", ")} — the exchange's token URL (${host}) is not one of them; bind the subject to it too`);
1755
+ }
1756
+ }
1757
+ const subjectToken = (await materializeSecrets({ [subjectName]: subjectValue }, ctx))[subjectName];
1758
+ // Cached by the recipe and the subject token: a new subject is a new exchange.
1759
+ const key = tokenCacheKey("tx", `${value}\n${crypto.createHash("sha256").update(subjectToken).digest("hex")}`, ctx?.tenant);
1760
+ const hit = txCache.get(key);
1761
+ if (hit && hit.expiresAt > Date.now()) return hit.token;
1762
+ const pending = txFlight.get(key);
1763
+ if (pending) return pending;
1764
+ const flight = (async () => {
1765
+ const clientCert = config.tls_client_cert && config.tls_client_key ? { cert: config.tls_client_cert, key: config.tls_client_key, ...(config.tls_client_passphrase ? { passphrase: config.tls_client_passphrase } : {}) } : undefined;
1766
+ const request = oauthTokenRequest(config, {
1767
+ grant_type: TOKEN_EXCHANGE_GRANT,
1768
+ subject_token: subjectToken,
1769
+ subject_token_type: config.subject_token_type ?? ACCESS_TOKEN_TYPE,
1770
+ ...(config.requested_token_type ? { requested_token_type: config.requested_token_type } : {}),
1771
+ ...(config.audience ? { audience: config.audience } : {}),
1772
+ ...(config.resource ? { resource: config.resource } : {}),
1773
+ ...(config.scope ? { scope: config.scope } : {}),
1774
+ }, { audience: config.token_url });
1775
+ const res = await fetchUntrusted(config.token_url, { method: "POST", headers: request.headers, body: request.body, signal: AbortSignal.timeout(15_000) }, { sameOrigin: true, ...(clientCert ? { clientCert } : {}) });
1776
+ const payload = readTokenResponse(await res.text().catch(() => ""), res.headers.get("content-type") ?? "");
1777
+ if (!res.ok || !payload.access_token) {
1778
+ throw new Error(`secret ${name}: the token exchange was refused (${res.status}): ${payload.error_description ?? payload.error ?? "no access_token in the reply"}`);
1779
+ }
1780
+ // Never cached past its life: a minute short of it, or half a short life.
1781
+ const life = payload.expires_in ?? 3600;
1782
+ const ttl = Math.max(0, life - Math.min(60, life / 2)) * 1000;
1783
+ const now = Date.now();
1784
+ for (const [k, v] of txCache) if (v.expiresAt <= now) txCache.delete(k);
1785
+ txCache.set(key, { token: payload.access_token, expiresAt: now + ttl });
1786
+ return payload.access_token;
1787
+ })();
1788
+ txFlight.set(key, flight);
1789
+ try {
1790
+ return await flight;
1791
+ } finally {
1792
+ txFlight.delete(key);
1793
+ }
1794
+ }
1795
+
1627
1796
  export function setIdentitySecret(tenant: string, name: string, config: IdentityConfig, workspace?: string, limit: SecretLimit = {}) {
1628
1797
  const audience = typeof config.audience === "string" ? config.audience.trim() : "";
1629
1798
  if (!audience) throw new Error("an identity needs an audience — the value your cloud's trust expects in aud");