@foldrun/core 10.0.0 → 11.0.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.
@@ -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/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");
@@ -21,7 +21,8 @@
21
21
  // Past the limit the body errors — a caller never gets the first megabyte
22
22
  // passed off as the whole answer.
23
23
 
24
- import { platform } from "./platform.ts";
24
+ import crypto from "node:crypto";
25
+ import { platform, guardsUntrustedUrls } from "./platform.ts";
25
26
 
26
27
  /** How many redirects one request may follow. fetch's own limit is 20;
27
28
  * nothing a tenant points the platform at needs more than a couple. */
@@ -88,6 +89,54 @@ export function capBody(res: Response, maxBytes: number, url: string): Response
88
89
  return out;
89
90
  }
90
91
 
92
+ /** One request with a client certificate, through node's https — a laptop's
93
+ * fetch has no way to present one. Redirects are the caller's. */
94
+ async function fetchWithClientCert(url: string, init: RequestInit, cert: { cert: string; key: string; passphrase?: string }, maxBytes: number): Promise<Response> {
95
+ const https = await import("node:https");
96
+ const body = init.body === undefined || init.body === null ? undefined : typeof init.body === "string" ? init.body : Buffer.from(await new Response(init.body as BodyInit).arrayBuffer());
97
+ const headers: Record<string, string> = {};
98
+ new Headers(init.headers).forEach((v, k) => (headers[k] = v));
99
+ // fetch decompresses; node's https does not. Ask for none, and decode one
100
+ // that comes anyway (Codex review).
101
+ headers["accept-encoding"] ??= "identity";
102
+ const zlib = await import("node:zlib");
103
+ const { pipeline } = await import("node:stream");
104
+ return new Promise<Response>((resolve, reject) => {
105
+ 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) => {
106
+ const enc = String(raw.headers["content-encoding"] ?? "").toLowerCase();
107
+ const decoder = enc === "gzip" || enc === "x-gzip" ? zlib.createGunzip()
108
+ : enc === "deflate" ? zlib.createInflate()
109
+ : enc === "br" ? zlib.createBrotliDecompress()
110
+ : null;
111
+ // pipeline: a reset mid-answer, or a bad compressed stream, rejects;
112
+ // and one side ending destroys the other (Codex review).
113
+ const res = decoder ? pipeline(raw, decoder, (err) => { if (err) reject(err); }) : raw;
114
+ const chunks: Buffer[] = [];
115
+ let size = 0;
116
+ res.on("data", (c: Buffer) => {
117
+ // The read limit holds while reading, not after (Codex review).
118
+ size += c.length;
119
+ if (size > maxBytes) {
120
+ res.destroy();
121
+ raw.destroy();
122
+ reject(new Error(`${url}: the answer is larger than ${maxBytes} bytes — not read`));
123
+ return;
124
+ }
125
+ chunks.push(c);
126
+ });
127
+ res.on("end", () => {
128
+ const h = new Headers();
129
+ for (const [k, v] of Object.entries(raw.headers)) if (v !== undefined && k !== "content-encoding" && k !== "content-length") h.set(k, Array.isArray(v) ? v.join(", ") : String(v));
130
+ resolve(new Response(chunks.length ? Buffer.concat(chunks) : null, { status: raw.statusCode ?? 502, headers: h }));
131
+ });
132
+ res.on("error", reject);
133
+ });
134
+ r.on("error", reject);
135
+ if (body !== undefined) r.write(body);
136
+ r.end();
137
+ });
138
+ }
139
+
91
140
  export class RedirectRefused extends Error {
92
141
  constructor(message: string) {
93
142
  super(message);
@@ -108,13 +157,23 @@ export async function fetchUntrusted(
108
157
  * caller to treat as the failure it is (a revocation that redirects to
109
158
  * a login page did not revoke anything). */
110
159
  noRedirects?: boolean;
160
+ /** Present this client certificate (mutual TLS, RFC 8705). */
161
+ clientCert?: { cert: string; key: string; passphrase?: string };
111
162
  } = {},
112
163
  ): Promise<Response> {
113
164
  const maxBytes = opts.maxBytes ?? MAX_BYTES;
114
165
  let current = url;
115
166
  let req: RequestInit = { ...init };
116
167
  for (let hop = 0; ; hop++) {
117
- const res = await platform.fetchUntrusted(current, { ...req, redirect: "manual" }, { maxBytes });
168
+ const res = opts.clientCert && !guardsUntrustedUrls()
169
+ // A laptop's plain fetch cannot present a certificate: node's https does.
170
+ ? await fetchWithClientCert(current, { ...req, redirect: "manual" }, opts.clientCert, maxBytes)
171
+ : await platform.fetchUntrusted(current, { ...req, redirect: "manual" }, {
172
+ maxBytes,
173
+ // A pool per certificate: a connection that presented one client's
174
+ // certificate is never reused for another's (Codex review).
175
+ ...(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! } }; })() : {}),
176
+ });
118
177
  const location = REDIRECT.has(res.status) ? res.headers.get("location") : null;
119
178
  if (!location || opts.noRedirects) return capBody(res, maxBytes, current);
120
179
  // The body of a redirect is never read; free the socket.
@@ -131,7 +190,8 @@ export async function fetchUntrusted(
131
190
  }
132
191
  const from = new URL(current);
133
192
  const crossOrigin = next.origin !== from.origin;
134
- if (crossOrigin && opts.sameOrigin) {
193
+ // A client certificate is a credential too: never carried to another origin.
194
+ if (crossOrigin && (opts.sameOrigin || opts.clientCert)) {
135
195
  throw new RedirectRefused(
136
196
  `${from.origin} redirected to ${next.origin} — not followed: this request carries a credential, and it goes only where it was sent`,
137
197
  );