@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.
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/src/oauth-capabilities.js +10 -5
- package/dist/src/oauth-device.d.ts +53 -0
- package/dist/src/oauth-device.js +112 -0
- package/dist/src/oauth-dpop.d.ts +33 -0
- package/dist/src/oauth-dpop.js +85 -0
- package/dist/src/oauth-token.d.ts +9 -1
- package/dist/src/oauth-token.js +25 -1
- package/dist/src/platform.d.ts +7 -0
- package/dist/src/secrets.d.ts +37 -2
- package/dist/src/secrets.js +148 -7
- package/dist/src/untrusted-fetch.d.ts +6 -0
- package/dist/src/untrusted-fetch.js +64 -3
- package/index.ts +2 -0
- package/package.json +1 -1
- package/src/oauth-capabilities.ts +10 -5
- package/src/oauth-device.ts +149 -0
- package/src/oauth-dpop.ts +93 -0
- package/src/oauth-token.ts +24 -2
- package/src/platform.ts +1 -1
- package/src/secrets.ts +178 -9
- package/src/untrusted-fetch.ts +63 -3
package/src/oauth-token.ts
CHANGED
|
@@ -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
|
-
|
|
982
|
-
|
|
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
|
|
1121
|
+
headers,
|
|
1101
1122
|
body: request.body,
|
|
1102
1123
|
signal: AbortSignal.timeout(15_000),
|
|
1103
|
-
|
|
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(
|
|
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
|
-
|
|
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");
|
package/src/untrusted-fetch.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
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
|
);
|