@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.
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/src/a2a-client.d.ts +9 -0
- package/dist/src/a2a-client.js +29 -0
- package/dist/src/oauth-capabilities.js +6 -3
- 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/runner.js +3 -1
- 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 +1 -0
- package/package.json +1 -1
- package/src/a2a-client.ts +26 -0
- package/src/oauth-capabilities.ts +6 -3
- package/src/oauth-dpop.ts +93 -0
- package/src/oauth-token.ts +24 -2
- package/src/platform.ts +1 -1
- package/src/runner.ts +4 -1
- package/src/secrets.ts +178 -9
- package/src/untrusted-fetch.ts +63 -3
package/dist/index.d.ts
CHANGED
|
@@ -34,6 +34,7 @@ export * from "./src/oauth-token.ts";
|
|
|
34
34
|
export * from "./src/oauth-discovery.ts";
|
|
35
35
|
export * from "./src/oauth-assertion.ts";
|
|
36
36
|
export * from "./src/oauth-device.ts";
|
|
37
|
+
export * from "./src/oauth-dpop.ts";
|
|
37
38
|
export * from "./src/secret-files.ts";
|
|
38
39
|
export * from "./src/notify.ts";
|
|
39
40
|
export * from "./src/webhook.ts";
|
package/dist/index.js
CHANGED
|
@@ -45,6 +45,7 @@ export * from "./src/oauth-token.js";
|
|
|
45
45
|
export * from "./src/oauth-discovery.js";
|
|
46
46
|
export * from "./src/oauth-assertion.js";
|
|
47
47
|
export * from "./src/oauth-device.js";
|
|
48
|
+
export * from "./src/oauth-dpop.js";
|
|
48
49
|
export * from "./src/secret-files.js";
|
|
49
50
|
export * from "./src/notify.js";
|
|
50
51
|
export * from "./src/webhook.js";
|
package/dist/src/a2a-client.d.ts
CHANGED
|
@@ -112,6 +112,15 @@ export declare function stripHidden(text: string): {
|
|
|
112
112
|
/** The address a request to `target` goes to: the egress door when the
|
|
113
113
|
* token is a placeholder only the proxy can fill, else the target. */
|
|
114
114
|
export declare function routeFor(conn: Pick<A2AConnection, "token" | "egress">, target: string): string;
|
|
115
|
+
/** Why the egress proxy refused a request, or null for an answer that is
|
|
116
|
+
* the remote's. The diagnostic is fixed text of ours; the body's own words
|
|
117
|
+
* go back as `said`, for the untrusted fence — the proxy passes a remote's
|
|
118
|
+
* 403 through as it came, so a remote can send this exact shape (Codex
|
|
119
|
+
* review, 2 Nov 2026). */
|
|
120
|
+
export declare function egressRefusal(status: number, text: string): {
|
|
121
|
+
reason: string;
|
|
122
|
+
said: string | null;
|
|
123
|
+
} | null;
|
|
115
124
|
/**
|
|
116
125
|
* The endpoint a card names, if it names one we may call: a JSON-RPC
|
|
117
126
|
* interface of protocol 1.x at the card's own origin. Pure — the card in,
|
package/dist/src/a2a-client.js
CHANGED
|
@@ -129,6 +129,30 @@ export function routeFor(conn, target) {
|
|
|
129
129
|
}
|
|
130
130
|
return target;
|
|
131
131
|
}
|
|
132
|
+
/** Why the egress proxy refused a request, or null for an answer that is
|
|
133
|
+
* the remote's. The diagnostic is fixed text of ours; the body's own words
|
|
134
|
+
* go back as `said`, for the untrusted fence — the proxy passes a remote's
|
|
135
|
+
* 403 through as it came, so a remote can send this exact shape (Codex
|
|
136
|
+
* review, 2 Nov 2026). */
|
|
137
|
+
export function egressRefusal(status, text) {
|
|
138
|
+
if (status !== 403)
|
|
139
|
+
return null;
|
|
140
|
+
let body;
|
|
141
|
+
try {
|
|
142
|
+
body = JSON.parse(text);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
const err = body?.error;
|
|
148
|
+
if (!err || err.type !== "egress")
|
|
149
|
+
return null;
|
|
150
|
+
const said = typeof err.message === "string" ? short(err.message.replace(/^egress:\s*/, ""), 200) : "";
|
|
151
|
+
const reason = /private|internal/i.test(said)
|
|
152
|
+
? "its address is not allowed — this platform does not call private or internal addresses"
|
|
153
|
+
: "the platform's egress refused it";
|
|
154
|
+
return { reason, said: said || null };
|
|
155
|
+
}
|
|
132
156
|
/**
|
|
133
157
|
* One HTTP exchange, read as JSON, to a limit, with no redirect followed.
|
|
134
158
|
*
|
|
@@ -178,6 +202,11 @@ async function exchange(conn, target, init, maxBytes) {
|
|
|
178
202
|
}
|
|
179
203
|
throw new A2AError(`${conn.remote}: the answer could not be read: ${short(err?.message ?? err, 200)}`);
|
|
180
204
|
}
|
|
205
|
+
// The egress proxy's own refusal ({error: {type: "egress"}}) is ours, not
|
|
206
|
+
// the remote's: the address is not allowed, and no secret would change it.
|
|
207
|
+
const egress = egressRefusal(res.status, text);
|
|
208
|
+
if (egress)
|
|
209
|
+
throw new A2AError(`${conn.remote} cannot be called from here: ${egress.reason}`, null, true, egress.said);
|
|
181
210
|
if (res.status === 401 || res.status === 403) {
|
|
182
211
|
throw new A2AError(`${conn.remote} refused the credential (HTTP ${res.status})${conn.token ? "" : " — this remote has no secret: set one"}`, null, true);
|
|
183
212
|
}
|
|
@@ -93,15 +93,18 @@ export const OAUTH_CAPABILITIES = [
|
|
|
93
93
|
},
|
|
94
94
|
{
|
|
95
95
|
id: "client.token-exchange", role: "client", title: "Token exchange", spec: "RFC 8693",
|
|
96
|
-
status: "
|
|
96
|
+
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.",
|
|
97
|
+
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")],
|
|
97
98
|
},
|
|
98
99
|
{
|
|
99
100
|
id: "client.dpop", role: "client", title: "DPoP sender-constrained tokens", spec: "RFC 9449",
|
|
100
|
-
status: "
|
|
101
|
+
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.",
|
|
102
|
+
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")],
|
|
101
103
|
},
|
|
102
104
|
{
|
|
103
105
|
id: "client.mtls", role: "client", title: "Mutual-TLS client auth and bound tokens", spec: "RFC 8705",
|
|
104
|
-
status: "
|
|
106
|
+
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.",
|
|
107
|
+
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")],
|
|
105
108
|
},
|
|
106
109
|
{
|
|
107
110
|
id: "client.ciba", role: "client", title: "Client-initiated backchannel authentication", spec: "OpenID CIBA",
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** A new DPoP key for a connection (EC P-256, ES256): the private PEM. */
|
|
2
|
+
export declare function generateDpopKey(): string;
|
|
3
|
+
/** The htu of a request: its URL with no query or fragment (RFC 9449 §4.2). */
|
|
4
|
+
export declare function dpopHtu(url: string): string;
|
|
5
|
+
/** The ath of an access token: base64url of its SHA-256 (RFC 9449 §4.2). */
|
|
6
|
+
export declare function dpopAth(accessToken: string): string;
|
|
7
|
+
/** A DPoP proof for one request. */
|
|
8
|
+
export declare function dpopProof(key: string, req: {
|
|
9
|
+
htm: string;
|
|
10
|
+
htu: string;
|
|
11
|
+
ath?: string;
|
|
12
|
+
nonce?: string;
|
|
13
|
+
}, nowMs?: number): string;
|
|
14
|
+
/** The JWK thumbprint the server binds a DPoP token to (cnf.jkt). */
|
|
15
|
+
export declare function dpopJkt(key: string): string;
|
|
16
|
+
/** Remember a DPoP-Nonce a response to this connection carried. */
|
|
17
|
+
export declare function noteDpopNonce(url: string, res: {
|
|
18
|
+
headers: {
|
|
19
|
+
get(name: string): string | null;
|
|
20
|
+
};
|
|
21
|
+
}, key: string): void;
|
|
22
|
+
/** The nonce for this connection's next proof to this URL's server, if any. */
|
|
23
|
+
export declare function dpopNonceFor(url: string, key: string): string | undefined;
|
|
24
|
+
/** Whether an answer asks for a (new) nonce: a token endpoint's 400
|
|
25
|
+
* use_dpop_nonce, or a resource's 401 with it in WWW-Authenticate. */
|
|
26
|
+
export declare function wantsDpopNonce(res: {
|
|
27
|
+
status: number;
|
|
28
|
+
headers: {
|
|
29
|
+
get(name: string): string | null;
|
|
30
|
+
};
|
|
31
|
+
}, body?: {
|
|
32
|
+
error?: string;
|
|
33
|
+
}): boolean;
|
|
@@ -0,0 +1,85 @@
|
|
|
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
|
+
import crypto from "node:crypto";
|
|
16
|
+
import { algForKey, readPrivateKey, ASSERTION_ALGS } from "./oauth-assertion.js";
|
|
17
|
+
/** A new DPoP key for a connection (EC P-256, ES256): the private PEM. */
|
|
18
|
+
export function generateDpopKey() {
|
|
19
|
+
return crypto.generateKeyPairSync("ec", { namedCurve: "P-256" }).privateKey.export({ format: "pem", type: "pkcs8" }).toString();
|
|
20
|
+
}
|
|
21
|
+
/** The htu of a request: its URL with no query or fragment (RFC 9449 §4.2). */
|
|
22
|
+
export function dpopHtu(url) {
|
|
23
|
+
const u = new URL(url);
|
|
24
|
+
return `${u.origin}${u.pathname}`;
|
|
25
|
+
}
|
|
26
|
+
/** The ath of an access token: base64url of its SHA-256 (RFC 9449 §4.2). */
|
|
27
|
+
export function dpopAth(accessToken) {
|
|
28
|
+
return crypto.createHash("sha256").update(accessToken).digest("base64url");
|
|
29
|
+
}
|
|
30
|
+
const b64url = (s) => Buffer.from(s).toString("base64url");
|
|
31
|
+
/** A DPoP proof for one request. */
|
|
32
|
+
export function dpopProof(key, req, nowMs = Date.now()) {
|
|
33
|
+
const k = readPrivateKey(key);
|
|
34
|
+
const alg = algForKey(k);
|
|
35
|
+
if (!ASSERTION_ALGS.includes(alg) || alg === "PS256")
|
|
36
|
+
throw new Error("a DPoP key must be EC P-256/P-384, RSA or Ed25519");
|
|
37
|
+
const pub = crypto.createPublicKey(k).export({ format: "jwk" });
|
|
38
|
+
const header = { typ: "dpop+jwt", alg, jwk: pub };
|
|
39
|
+
const claims = {
|
|
40
|
+
jti: b64url(crypto.randomBytes(18)),
|
|
41
|
+
htm: req.htm.toUpperCase(),
|
|
42
|
+
htu: dpopHtu(req.htu),
|
|
43
|
+
iat: Math.floor(nowMs / 1000),
|
|
44
|
+
...(req.ath ? { ath: req.ath } : {}),
|
|
45
|
+
...(req.nonce ? { nonce: req.nonce } : {}),
|
|
46
|
+
};
|
|
47
|
+
const input = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(claims))}`;
|
|
48
|
+
const data = Buffer.from(input);
|
|
49
|
+
const sig = alg === "ES256" ? crypto.sign("sha256", data, { key: k, dsaEncoding: "ieee-p1363" })
|
|
50
|
+
: alg === "ES384" ? crypto.sign("sha384", data, { key: k, dsaEncoding: "ieee-p1363" })
|
|
51
|
+
: alg === "EdDSA" ? crypto.sign(null, data, k)
|
|
52
|
+
: crypto.sign("sha256", data, k);
|
|
53
|
+
return `${input}.${b64url(sig)}`;
|
|
54
|
+
}
|
|
55
|
+
/** The JWK thumbprint the server binds a DPoP token to (cnf.jkt). */
|
|
56
|
+
export function dpopJkt(key) {
|
|
57
|
+
const pub = crypto.createPublicKey(readPrivateKey(key)).export({ format: "jwk" });
|
|
58
|
+
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 };
|
|
59
|
+
return crypto.createHash("sha256").update(JSON.stringify(members)).digest("base64url");
|
|
60
|
+
}
|
|
61
|
+
// Server-provided nonces (RFC 9449 §8, §9), kept per origin and per DPoP
|
|
62
|
+
// key: the last one a server sent to this connection is the one its next
|
|
63
|
+
// proof carries — two connections to one server never overwrite each other's
|
|
64
|
+
// (a server may issue nonces per client; Codex review).
|
|
65
|
+
const nonces = new Map();
|
|
66
|
+
const nonceKey = (url, key) => `${new URL(url).origin} ${dpopJkt(key)}`;
|
|
67
|
+
/** Remember a DPoP-Nonce a response to this connection carried. */
|
|
68
|
+
export function noteDpopNonce(url, res, key) {
|
|
69
|
+
const n = res.headers.get("dpop-nonce");
|
|
70
|
+
if (n)
|
|
71
|
+
nonces.set(nonceKey(url, key), n);
|
|
72
|
+
}
|
|
73
|
+
/** The nonce for this connection's next proof to this URL's server, if any. */
|
|
74
|
+
export function dpopNonceFor(url, key) {
|
|
75
|
+
return nonces.get(nonceKey(url, key));
|
|
76
|
+
}
|
|
77
|
+
/** Whether an answer asks for a (new) nonce: a token endpoint's 400
|
|
78
|
+
* use_dpop_nonce, or a resource's 401 with it in WWW-Authenticate. */
|
|
79
|
+
export function wantsDpopNonce(res, body) {
|
|
80
|
+
if (!res.headers.get("dpop-nonce"))
|
|
81
|
+
return false;
|
|
82
|
+
if (res.status === 400 && body?.error === "use_dpop_nonce")
|
|
83
|
+
return true;
|
|
84
|
+
return res.status === 401 && /use_dpop_nonce/.test(res.headers.get("www-authenticate") ?? "");
|
|
85
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type AssertionAlg } from "./oauth-assertion.ts";
|
|
2
|
-
export declare const TOKEN_AUTH_METHODS: readonly ["client_secret_post", "client_secret_basic", "none", "private_key_jwt"];
|
|
2
|
+
export declare const TOKEN_AUTH_METHODS: readonly ["client_secret_post", "client_secret_basic", "none", "private_key_jwt", "tls_client_auth", "self_signed_tls_client_auth"];
|
|
3
3
|
export type TokenAuth = (typeof TOKEN_AUTH_METHODS)[number];
|
|
4
4
|
/** The short spellings a person types (`--token-auth basic`), to the RFC names. */
|
|
5
5
|
export declare function parseTokenAuth(value: string): TokenAuth | null;
|
|
@@ -13,6 +13,11 @@ export interface TokenClient {
|
|
|
13
13
|
client_assertion_kid?: string;
|
|
14
14
|
client_assertion_alg?: AssertionAlg;
|
|
15
15
|
client_assertion_cert?: string;
|
|
16
|
+
/** tls_client_auth / self_signed_tls_client_auth (RFC 8705): the client
|
|
17
|
+
* certificate and key presented on the connection to the token URL. */
|
|
18
|
+
tls_client_cert?: string;
|
|
19
|
+
tls_client_key?: string;
|
|
20
|
+
tls_client_passphrase?: string;
|
|
16
21
|
}
|
|
17
22
|
/** Why a client's token_auth cannot work, or null. */
|
|
18
23
|
export declare function tokenAuthProblem(client: {
|
|
@@ -21,6 +26,9 @@ export declare function tokenAuthProblem(client: {
|
|
|
21
26
|
client_assertion_key?: string;
|
|
22
27
|
client_assertion_alg?: unknown;
|
|
23
28
|
client_assertion_cert?: string;
|
|
29
|
+
tls_client_cert?: string;
|
|
30
|
+
tls_client_key?: string;
|
|
31
|
+
tls_client_passphrase?: string;
|
|
24
32
|
}): string | null;
|
|
25
33
|
/**
|
|
26
34
|
* The headers and body of one token request. `params` is the grant's own
|
package/dist/src/oauth-token.js
CHANGED
|
@@ -17,8 +17,9 @@
|
|
|
17
17
|
// secret at all (RFC 7523 §2.2, oauth-assertion.ts)
|
|
18
18
|
//
|
|
19
19
|
// Absent means the old behaviour: the body, with the secret if there is one.
|
|
20
|
+
import crypto from "node:crypto";
|
|
20
21
|
import { ASSERTION_TYPE, assertionKeyProblem, signClientAssertion } from "./oauth-assertion.js";
|
|
21
|
-
export const TOKEN_AUTH_METHODS = ["client_secret_post", "client_secret_basic", "none", "private_key_jwt"];
|
|
22
|
+
export const TOKEN_AUTH_METHODS = ["client_secret_post", "client_secret_basic", "none", "private_key_jwt", "tls_client_auth", "self_signed_tls_client_auth"];
|
|
22
23
|
/** The short spellings a person types (`--token-auth basic`), to the RFC names. */
|
|
23
24
|
export function parseTokenAuth(value) {
|
|
24
25
|
const v = value.trim().toLowerCase();
|
|
@@ -30,6 +31,10 @@ export function parseTokenAuth(value) {
|
|
|
30
31
|
return "client_secret_basic";
|
|
31
32
|
if (v === "jwt" || v === "private-key-jwt" || v === "key")
|
|
32
33
|
return "private_key_jwt";
|
|
34
|
+
if (v === "mtls" || v === "tls" || v === "cert")
|
|
35
|
+
return "tls_client_auth";
|
|
36
|
+
if (v === "self-signed-mtls" || v === "self-signed")
|
|
37
|
+
return "self_signed_tls_client_auth";
|
|
33
38
|
return null;
|
|
34
39
|
}
|
|
35
40
|
/** Why a client's token_auth cannot work, or null. */
|
|
@@ -53,6 +58,20 @@ export function tokenAuthProblem(client) {
|
|
|
53
58
|
return "token_auth private_key_jwt signs with a key and sends no client_secret — leave the secret empty";
|
|
54
59
|
return assertionKeyProblem(client);
|
|
55
60
|
}
|
|
61
|
+
if (auth === "tls_client_auth" || auth === "self_signed_tls_client_auth") {
|
|
62
|
+
if (hasSecret)
|
|
63
|
+
return `token_auth ${auth} proves the client with its certificate and sends no client_secret — leave the secret empty`;
|
|
64
|
+
if (!client.tls_client_cert?.trim() || !client.tls_client_key?.trim())
|
|
65
|
+
return `token_auth ${auth} needs tls_client_cert and tls_client_key (PEM)`;
|
|
66
|
+
try {
|
|
67
|
+
const key = crypto.createPrivateKey(client.tls_client_passphrase ? { key: client.tls_client_key, passphrase: client.tls_client_passphrase } : client.tls_client_key);
|
|
68
|
+
if (!new crypto.X509Certificate(client.tls_client_cert).checkPrivateKey(key))
|
|
69
|
+
return "tls_client_cert is not the certificate of tls_client_key";
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return "tls_client_cert and tls_client_key must be a PEM certificate and its private key";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
56
75
|
return null;
|
|
57
76
|
}
|
|
58
77
|
/** The form-urlencoding RFC 6749 Appendix B asks for: space as '+'. */
|
|
@@ -93,6 +112,11 @@ opts = {}) {
|
|
|
93
112
|
if (opts.clientIdInBody)
|
|
94
113
|
form.set("client_id", client.client_id);
|
|
95
114
|
}
|
|
115
|
+
else if (auth === "tls_client_auth" || auth === "self_signed_tls_client_auth") {
|
|
116
|
+
// The certificate on the connection is the proof (RFC 8705 §2): the
|
|
117
|
+
// caller presents tls_client_cert; the body names the client only.
|
|
118
|
+
form.set("client_id", client.client_id);
|
|
119
|
+
}
|
|
96
120
|
else if (auth === "private_key_jwt") {
|
|
97
121
|
if (!opts.audience)
|
|
98
122
|
throw new Error("a private_key_jwt request needs the endpoint it goes to (its assertion's aud)");
|
package/dist/src/platform.d.ts
CHANGED
|
@@ -183,6 +183,13 @@ export interface PlatformHooks {
|
|
|
183
183
|
*/
|
|
184
184
|
fetchUntrusted(url: string, init?: RequestInit, opts?: {
|
|
185
185
|
maxBytes?: number;
|
|
186
|
+
clientCert?: {
|
|
187
|
+
pool: string;
|
|
188
|
+
identity: string;
|
|
189
|
+
cert: string;
|
|
190
|
+
key: string;
|
|
191
|
+
passphrase?: string;
|
|
192
|
+
};
|
|
186
193
|
}): Promise<Response>;
|
|
187
194
|
/**
|
|
188
195
|
* May the host call this URL a tenant chose — a provider's base_url,
|
package/dist/src/runner.js
CHANGED
|
@@ -2211,8 +2211,10 @@ mayDefer) {
|
|
|
2211
2211
|
const cardProblems = new Map();
|
|
2212
2212
|
for (const r of remotes) {
|
|
2213
2213
|
const why = await platform.checkUntrustedUrl(r.card);
|
|
2214
|
-
if (why)
|
|
2214
|
+
if (why) {
|
|
2215
2215
|
cardProblems.set(r.name, why);
|
|
2216
|
+
console.warn(`[a2a] remotes: ${r.name} refused for ${tenant}/${path.basename(workspaceRootOf(agentDir))} (${step.agent}): ${why}`);
|
|
2217
|
+
}
|
|
2216
2218
|
}
|
|
2217
2219
|
const stepRemotes = remotes.map((r) => {
|
|
2218
2220
|
const refused = cardProblems.get(r.name);
|
package/dist/src/secrets.d.ts
CHANGED
|
@@ -17,7 +17,7 @@ export declare function installKeyMaterial(): string;
|
|
|
17
17
|
/** How the stored value is used at run time — "oauth2"/"service-account"
|
|
18
18
|
* are refreshed into live tokens, "file"/"ssh"/"api" are materialised into
|
|
19
19
|
* the run's scratch. Plain values have no kind. */
|
|
20
|
-
export type SecretKind = "oauth2" | "service-account" | "file" | "ssh" | "api" | "client-cert" | "identity";
|
|
20
|
+
export type SecretKind = "oauth2" | "service-account" | "file" | "ssh" | "api" | "client-cert" | "identity" | "token-exchange";
|
|
21
21
|
interface StoredSecret {
|
|
22
22
|
iv: string;
|
|
23
23
|
tag: string;
|
|
@@ -256,6 +256,15 @@ export interface OAuth2Config {
|
|
|
256
256
|
client_assertion_kid?: string;
|
|
257
257
|
client_assertion_alg?: AssertionAlg;
|
|
258
258
|
client_assertion_cert?: string;
|
|
259
|
+
/** DPoP (RFC 9449): this connection's own key; token requests and the
|
|
260
|
+
* calls that use its token carry proofs signed with it (oauth-dpop.ts). */
|
|
261
|
+
dpop_key?: string;
|
|
262
|
+
/** tls_client_auth (RFC 8705): the client certificate and key presented
|
|
263
|
+
* to the token URL — and, through the egress proxy, on the calls its
|
|
264
|
+
* certificate-bound token is used for. */
|
|
265
|
+
tls_client_cert?: string;
|
|
266
|
+
tls_client_key?: string;
|
|
267
|
+
tls_client_passphrase?: string;
|
|
259
268
|
}
|
|
260
269
|
/** Tries at a token endpoint before a refresh is called failed: the first,
|
|
261
270
|
* and two more after a short wait, for a network error, a 5xx or a 429. */
|
|
@@ -273,7 +282,7 @@ export declare function isOAuth2Value(value: string): boolean;
|
|
|
273
282
|
* second account with the same names be handed the first one's token
|
|
274
283
|
* (Codex debate, 22 Oct 2026).
|
|
275
284
|
*/
|
|
276
|
-
export declare function tokenCacheKey(kind: "oauth2" | "sa", storedValue: string, tenant?: string): string;
|
|
285
|
+
export declare function tokenCacheKey(kind: "oauth2" | "sa" | "tx", storedValue: string, tenant?: string): string;
|
|
277
286
|
/**
|
|
278
287
|
* A provider that rotates refresh tokens (RFC 9700 §2.2.2) answered a refresh
|
|
279
288
|
* with a new one: write it back where the old one is stored — the workspace
|
|
@@ -417,6 +426,32 @@ export declare function setClientCertSecret(tenant: string, name: string, config
|
|
|
417
426
|
export interface IdentityConfig {
|
|
418
427
|
audience: string;
|
|
419
428
|
}
|
|
429
|
+
export declare const TOKEN_EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange";
|
|
430
|
+
export interface TokenExchangeConfig {
|
|
431
|
+
token_url: string;
|
|
432
|
+
client_id: string;
|
|
433
|
+
client_secret?: string;
|
|
434
|
+
token_auth?: TokenAuth;
|
|
435
|
+
client_assertion_key?: string;
|
|
436
|
+
client_assertion_kid?: string;
|
|
437
|
+
client_assertion_alg?: AssertionAlg;
|
|
438
|
+
client_assertion_cert?: string;
|
|
439
|
+
tls_client_cert?: string;
|
|
440
|
+
tls_client_key?: string;
|
|
441
|
+
tls_client_passphrase?: string;
|
|
442
|
+
/** The secret whose live token is the subject: an oauth2 or service-account
|
|
443
|
+
* secret, or a static token. Declared beside this one, or found in the
|
|
444
|
+
* vault at the same scope. */
|
|
445
|
+
subject_secret: string;
|
|
446
|
+
/** Default urn:ietf:params:oauth:token-type:access_token. */
|
|
447
|
+
subject_token_type?: string;
|
|
448
|
+
requested_token_type?: string;
|
|
449
|
+
audience?: string;
|
|
450
|
+
resource?: string;
|
|
451
|
+
scope?: string;
|
|
452
|
+
}
|
|
453
|
+
export declare function setTokenExchangeSecret(tenant: string, name: string, config: TokenExchangeConfig, workspace?: string, limit?: SecretLimit): void;
|
|
454
|
+
export declare function isTokenExchangeValue(value: string): boolean;
|
|
420
455
|
export declare function setIdentitySecret(tenant: string, name: string, config: IdentityConfig, workspace?: string, limit?: SecretLimit): void;
|
|
421
456
|
export declare function isIdentityValue(value: string): boolean;
|
|
422
457
|
export declare function identityOf(value: string): IdentityConfig;
|
package/dist/src/secrets.js
CHANGED
|
@@ -25,6 +25,7 @@ import { platform } from "./platform.js";
|
|
|
25
25
|
import { fetchUntrusted } from "./untrusted-fetch.js";
|
|
26
26
|
import { oauthTokenRequest, readTokenResponse, tokenAuthProblem } from "./oauth-token.js";
|
|
27
27
|
import { isSecureUrl } from "./oauth-discovery.js";
|
|
28
|
+
import { dpopProof, dpopNonceFor, noteDpopNonce } from "./oauth-dpop.js";
|
|
28
29
|
/**
|
|
29
30
|
* GCM's auth tag, pinned to its full 16 bytes at every cipher call. Without
|
|
30
31
|
* `authTagLength`, Node accepts whatever length `setAuthTag` is handed — 4, 8,
|
|
@@ -41,7 +42,7 @@ const tenantKey = (tenant) => platform.tenantKey(tenant);
|
|
|
41
42
|
import crypto from "node:crypto";
|
|
42
43
|
import { assertSafeName } from "./store.js";
|
|
43
44
|
import { trimSlashes } from "./paths.js";
|
|
44
|
-
import { SITE } from "./network.js";
|
|
45
|
+
import { SITE, siteCovers } from "./network.js";
|
|
45
46
|
const keyFile = () => path.join(dataRoot(), ".secret-key");
|
|
46
47
|
/**
|
|
47
48
|
* The install's root secret, as text: FOLDRUN_SECRET_KEY when set, else the
|
|
@@ -817,8 +818,9 @@ export function setOAuth2Secret(tenant, name, config, workspace, limit = {}) {
|
|
|
817
818
|
}
|
|
818
819
|
// A public client (PKCE, no secret) refreshes with its client id alone;
|
|
819
820
|
// client credentials are nothing without the secret.
|
|
820
|
-
|
|
821
|
-
|
|
821
|
+
// Client credentials need a proof of the client: a secret, a signing key or a certificate.
|
|
822
|
+
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 ?? "")) {
|
|
823
|
+
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)");
|
|
822
824
|
}
|
|
823
825
|
if (config.grant_type !== "client_credentials" && !config.refresh_token?.trim()) {
|
|
824
826
|
throw new Error("oauth2 secret needs refresh_token (or grant_type: client_credentials)");
|
|
@@ -923,31 +925,55 @@ async function exchange(config, cacheKey) {
|
|
|
923
925
|
// and the reconnect mail; a 5xx or 429 is the provider busy, retried.
|
|
924
926
|
let res;
|
|
925
927
|
let lastNetwork;
|
|
928
|
+
let text = "";
|
|
929
|
+
// A certificate-authenticated client (RFC 8705) presents it on the connection.
|
|
930
|
+
const clientCert = config.tls_client_cert && config.tls_client_key
|
|
931
|
+
? { cert: config.tls_client_cert, key: config.tls_client_key, ...(config.tls_client_passphrase ? { passphrase: config.tls_client_passphrase } : {}) }
|
|
932
|
+
: undefined;
|
|
933
|
+
let nonceRetried = false;
|
|
926
934
|
for (let attempt = 0; attempt < OAUTH_REFRESH_TRIES; attempt++) {
|
|
927
935
|
if (attempt)
|
|
928
936
|
await new Promise((r) => setTimeout(r, oauthRetryDelayMs(attempt)));
|
|
929
937
|
try {
|
|
930
938
|
const request = build();
|
|
939
|
+
// DPoP: a fresh proof per try, with the server's nonce when it gave one.
|
|
940
|
+
const headers = config.dpop_key
|
|
941
|
+
? { ...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) } : {}) }) }
|
|
942
|
+
: request.headers;
|
|
931
943
|
res = await fetchUntrusted(config.token_url, {
|
|
932
944
|
method: "POST",
|
|
933
|
-
headers
|
|
945
|
+
headers,
|
|
934
946
|
body: request.body,
|
|
935
947
|
signal: AbortSignal.timeout(15_000),
|
|
936
|
-
|
|
948
|
+
// A DPoP proof names this URL and method: a redirect would carry it
|
|
949
|
+
// somewhere it is not for, so none is followed (Codex review).
|
|
950
|
+
}, { sameOrigin: true, ...(clientCert ? { clientCert } : {}), ...(config.dpop_key ? { noRedirects: true } : {}) });
|
|
937
951
|
}
|
|
938
952
|
catch (err) {
|
|
939
953
|
lastNetwork = err;
|
|
940
954
|
res = undefined;
|
|
941
955
|
continue;
|
|
942
956
|
}
|
|
957
|
+
// Read before deciding to retry: the last answer's own words are the
|
|
958
|
+
// error a person sees when every try is busy (Codex review).
|
|
959
|
+
text = await res.text().catch(() => "");
|
|
943
960
|
if (res.status === 429 || res.status >= 500)
|
|
944
961
|
continue;
|
|
962
|
+
if (config.dpop_key) {
|
|
963
|
+
noteDpopNonce(config.token_url, res, config.dpop_key);
|
|
964
|
+
// The server wants a nonce in the proof (RFC 9449 §8): once more, at once.
|
|
965
|
+
if (!nonceRetried && res.status === 400 && res.headers.get("dpop-nonce") && readTokenResponse(text, res.headers.get("content-type") ?? "").error === "use_dpop_nonce") {
|
|
966
|
+
nonceRetried = true;
|
|
967
|
+
attempt--;
|
|
968
|
+
continue;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
945
971
|
break;
|
|
946
972
|
}
|
|
947
973
|
if (!res)
|
|
948
974
|
throw lastNetwork instanceof Error ? lastNetwork : new Error(String(lastNetwork));
|
|
949
975
|
// JSON or form-encoded, Slack's nested user token, expires_in as a string.
|
|
950
|
-
const payload = readTokenResponse(
|
|
976
|
+
const payload = readTokenResponse(text, res.headers.get("content-type") ?? "");
|
|
951
977
|
if (!res.ok || !payload.access_token) {
|
|
952
978
|
throw new Error(`token refresh failed (${res.status}): ${payload.error_description ?? payload.error ?? "no access_token in the reply"}`);
|
|
953
979
|
}
|
|
@@ -1055,7 +1081,9 @@ export async function revokeOAuth2Value(value, opts = {}) {
|
|
|
1055
1081
|
}
|
|
1056
1082
|
// No redirect is followed: a 3xx is not ok, so a revocation endpoint that
|
|
1057
1083
|
// sends us to a login page is reported, never counted as revoked.
|
|
1058
|
-
|
|
1084
|
+
// A certificate-authenticated client (RFC 8705) presents it here too.
|
|
1085
|
+
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;
|
|
1086
|
+
const fetcher = opts.fetcher ?? ((url, init) => fetchUntrusted(url, init, { sameOrigin: true, noRedirects: true, ...(clientCert ? { clientCert } : {}) }));
|
|
1059
1087
|
const key = tokenCacheKey("oauth2", value, opts.tenant);
|
|
1060
1088
|
const access = oauthCache.get(key)?.token;
|
|
1061
1089
|
const tokens = [];
|
|
@@ -1144,6 +1172,9 @@ ctx) {
|
|
|
1144
1172
|
if (ctx)
|
|
1145
1173
|
await platform.noteOAuthRefresh(ctx, name, null).catch(() => undefined);
|
|
1146
1174
|
}
|
|
1175
|
+
else if (value.startsWith(TOKEN_EXCHANGE_PREFIX)) {
|
|
1176
|
+
out[name] = await materializeTokenExchange(name, value, env, ctx);
|
|
1177
|
+
}
|
|
1147
1178
|
else if (value.startsWith(SERVICE_ACCOUNT_PREFIX)) {
|
|
1148
1179
|
let config;
|
|
1149
1180
|
try {
|
|
@@ -1383,6 +1414,116 @@ export function setClientCertSecret(tenant, name, config, workspace, limit = {})
|
|
|
1383
1414
|
});
|
|
1384
1415
|
}
|
|
1385
1416
|
const IDENTITY_PREFIX = "@identity ";
|
|
1417
|
+
// ---------------------------------------------------------------- token exchange
|
|
1418
|
+
// Token exchange (RFC 8693): a secret that is not a credential of its own but
|
|
1419
|
+
// a recipe for one — present another secret's token (the subject) at a token
|
|
1420
|
+
// endpoint and get back a narrower one: another audience, fewer scopes, a
|
|
1421
|
+
// token a downstream service accepts. Cached like an oauth2 token; the
|
|
1422
|
+
// subject is made live first, the same way it would be on its own.
|
|
1423
|
+
export const TOKEN_EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange";
|
|
1424
|
+
const ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token";
|
|
1425
|
+
const TOKEN_EXCHANGE_PREFIX = "@token-exchange ";
|
|
1426
|
+
export function setTokenExchangeSecret(tenant, name, config, workspace, limit = {}) {
|
|
1427
|
+
for (const field of ["token_url", "client_id", "subject_secret"]) {
|
|
1428
|
+
if (typeof config[field] !== "string" || !config[field].trim())
|
|
1429
|
+
throw new Error(`a token-exchange secret needs ${field}`);
|
|
1430
|
+
}
|
|
1431
|
+
if (!SECRET_NAME.test(config.subject_secret))
|
|
1432
|
+
throw new Error("subject_secret is a secret's name, UPPER_SNAKE_CASE");
|
|
1433
|
+
if (config.subject_secret === name)
|
|
1434
|
+
throw new Error("a token exchange cannot be its own subject");
|
|
1435
|
+
if (!isSecureUrl(config.token_url))
|
|
1436
|
+
throw new Error("token_url must be https");
|
|
1437
|
+
for (const k of ["subject_token_type", "requested_token_type", "audience", "resource", "scope"]) {
|
|
1438
|
+
if (config[k] !== undefined && typeof config[k] !== "string")
|
|
1439
|
+
throw new Error(`${k} is a string`);
|
|
1440
|
+
}
|
|
1441
|
+
const authProblem = tokenAuthProblem(config);
|
|
1442
|
+
if (authProblem)
|
|
1443
|
+
throw new Error(`token-exchange secret: ${authProblem}`);
|
|
1444
|
+
setSecret(tenant, name, TOKEN_EXCHANGE_PREFIX + JSON.stringify(config), workspace, "token-exchange", limit);
|
|
1445
|
+
}
|
|
1446
|
+
export function isTokenExchangeValue(value) {
|
|
1447
|
+
return value.startsWith(TOKEN_EXCHANGE_PREFIX);
|
|
1448
|
+
}
|
|
1449
|
+
const txCache = new Map();
|
|
1450
|
+
const txFlight = new Map();
|
|
1451
|
+
async function materializeTokenExchange(name, value, env, ctx) {
|
|
1452
|
+
let config;
|
|
1453
|
+
try {
|
|
1454
|
+
config = JSON.parse(value.slice(TOKEN_EXCHANGE_PREFIX.length));
|
|
1455
|
+
}
|
|
1456
|
+
catch {
|
|
1457
|
+
throw new Error(`secret ${name}: stored token-exchange config is unreadable`);
|
|
1458
|
+
}
|
|
1459
|
+
// The subject: declared beside this secret, else the vault at this scope.
|
|
1460
|
+
const subjectName = config.subject_secret;
|
|
1461
|
+
let subjectValue = env[subjectName];
|
|
1462
|
+
if (subjectValue === undefined && ctx)
|
|
1463
|
+
subjectValue = getSecret(ctx.tenant, subjectName, ctx.workspace)?.value;
|
|
1464
|
+
if (subjectValue === undefined)
|
|
1465
|
+
throw new Error(`secret ${name}: its subject ${subjectName} is not set — declare it in secrets: beside ${name}`);
|
|
1466
|
+
if (isTokenExchangeValue(subjectValue))
|
|
1467
|
+
throw new Error(`secret ${name}: its subject ${subjectName} is itself a token exchange — the subject must be a token`);
|
|
1468
|
+
// Only a token may be sent as the subject: a file, ssh key, header set,
|
|
1469
|
+
// certificate or identity recipe would hand its contents to the token
|
|
1470
|
+
// endpoint (Codex review).
|
|
1471
|
+
for (const p of [FILE_PREFIX, SSH_PREFIX, API_PREFIX, CLIENT_CERT_PREFIX, IDENTITY_PREFIX]) {
|
|
1472
|
+
if (subjectValue.startsWith(p))
|
|
1473
|
+
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)`);
|
|
1474
|
+
}
|
|
1475
|
+
// A subject bound to sites goes to those sites alone: an exchange whose
|
|
1476
|
+
// token URL is not one of them would carry it elsewhere (Codex review).
|
|
1477
|
+
if (ctx) {
|
|
1478
|
+
const bound = secretSites(ctx.tenant, [subjectName], ctx.workspace)[subjectName] ?? [];
|
|
1479
|
+
const host = new URL(config.token_url).host;
|
|
1480
|
+
if (bound.length && !bound.some((site) => siteCovers(site, host))) {
|
|
1481
|
+
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`);
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
const subjectToken = (await materializeSecrets({ [subjectName]: subjectValue }, ctx))[subjectName];
|
|
1485
|
+
// Cached by the recipe and the subject token: a new subject is a new exchange.
|
|
1486
|
+
const key = tokenCacheKey("tx", `${value}\n${crypto.createHash("sha256").update(subjectToken).digest("hex")}`, ctx?.tenant);
|
|
1487
|
+
const hit = txCache.get(key);
|
|
1488
|
+
if (hit && hit.expiresAt > Date.now())
|
|
1489
|
+
return hit.token;
|
|
1490
|
+
const pending = txFlight.get(key);
|
|
1491
|
+
if (pending)
|
|
1492
|
+
return pending;
|
|
1493
|
+
const flight = (async () => {
|
|
1494
|
+
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;
|
|
1495
|
+
const request = oauthTokenRequest(config, {
|
|
1496
|
+
grant_type: TOKEN_EXCHANGE_GRANT,
|
|
1497
|
+
subject_token: subjectToken,
|
|
1498
|
+
subject_token_type: config.subject_token_type ?? ACCESS_TOKEN_TYPE,
|
|
1499
|
+
...(config.requested_token_type ? { requested_token_type: config.requested_token_type } : {}),
|
|
1500
|
+
...(config.audience ? { audience: config.audience } : {}),
|
|
1501
|
+
...(config.resource ? { resource: config.resource } : {}),
|
|
1502
|
+
...(config.scope ? { scope: config.scope } : {}),
|
|
1503
|
+
}, { audience: config.token_url });
|
|
1504
|
+
const res = await fetchUntrusted(config.token_url, { method: "POST", headers: request.headers, body: request.body, signal: AbortSignal.timeout(15_000) }, { sameOrigin: true, ...(clientCert ? { clientCert } : {}) });
|
|
1505
|
+
const payload = readTokenResponse(await res.text().catch(() => ""), res.headers.get("content-type") ?? "");
|
|
1506
|
+
if (!res.ok || !payload.access_token) {
|
|
1507
|
+
throw new Error(`secret ${name}: the token exchange was refused (${res.status}): ${payload.error_description ?? payload.error ?? "no access_token in the reply"}`);
|
|
1508
|
+
}
|
|
1509
|
+
// Never cached past its life: a minute short of it, or half a short life.
|
|
1510
|
+
const life = payload.expires_in ?? 3600;
|
|
1511
|
+
const ttl = Math.max(0, life - Math.min(60, life / 2)) * 1000;
|
|
1512
|
+
const now = Date.now();
|
|
1513
|
+
for (const [k, v] of txCache)
|
|
1514
|
+
if (v.expiresAt <= now)
|
|
1515
|
+
txCache.delete(k);
|
|
1516
|
+
txCache.set(key, { token: payload.access_token, expiresAt: now + ttl });
|
|
1517
|
+
return payload.access_token;
|
|
1518
|
+
})();
|
|
1519
|
+
txFlight.set(key, flight);
|
|
1520
|
+
try {
|
|
1521
|
+
return await flight;
|
|
1522
|
+
}
|
|
1523
|
+
finally {
|
|
1524
|
+
txFlight.delete(key);
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1386
1527
|
export function setIdentitySecret(tenant, name, config, workspace, limit = {}) {
|
|
1387
1528
|
const audience = typeof config.audience === "string" ? config.audience.trim() : "";
|
|
1388
1529
|
if (!audience)
|
|
@@ -28,4 +28,10 @@ export declare function fetchUntrusted(url: string, init?: RequestInit, opts?: {
|
|
|
28
28
|
* caller to treat as the failure it is (a revocation that redirects to
|
|
29
29
|
* a login page did not revoke anything). */
|
|
30
30
|
noRedirects?: boolean;
|
|
31
|
+
/** Present this client certificate (mutual TLS, RFC 8705). */
|
|
32
|
+
clientCert?: {
|
|
33
|
+
cert: string;
|
|
34
|
+
key: string;
|
|
35
|
+
passphrase?: string;
|
|
36
|
+
};
|
|
31
37
|
}): Promise<Response>;
|