@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
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
// tier holding it all in memory is the platform's problem, not the tenant's.
|
|
21
21
|
// Past the limit the body errors — a caller never gets the first megabyte
|
|
22
22
|
// passed off as the whole answer.
|
|
23
|
-
import
|
|
23
|
+
import crypto from "node:crypto";
|
|
24
|
+
import { platform, guardsUntrustedUrls } from "./platform.js";
|
|
24
25
|
/** How many redirects one request may follow. fetch's own limit is 20;
|
|
25
26
|
* nothing a tenant points the platform at needs more than a couple. */
|
|
26
27
|
export const MAX_REDIRECTS = 5;
|
|
@@ -80,6 +81,57 @@ export function capBody(res, maxBytes, url) {
|
|
|
80
81
|
Object.defineProperty(out, "url", { value: res.url });
|
|
81
82
|
return out;
|
|
82
83
|
}
|
|
84
|
+
/** One request with a client certificate, through node's https — a laptop's
|
|
85
|
+
* fetch has no way to present one. Redirects are the caller's. */
|
|
86
|
+
async function fetchWithClientCert(url, init, cert, maxBytes) {
|
|
87
|
+
const https = await import("node:https");
|
|
88
|
+
const body = init.body === undefined || init.body === null ? undefined : typeof init.body === "string" ? init.body : Buffer.from(await new Response(init.body).arrayBuffer());
|
|
89
|
+
const headers = {};
|
|
90
|
+
new Headers(init.headers).forEach((v, k) => (headers[k] = v));
|
|
91
|
+
// fetch decompresses; node's https does not. Ask for none, and decode one
|
|
92
|
+
// that comes anyway (Codex review).
|
|
93
|
+
headers["accept-encoding"] ??= "identity";
|
|
94
|
+
const zlib = await import("node:zlib");
|
|
95
|
+
const { pipeline } = await import("node:stream");
|
|
96
|
+
return new Promise((resolve, reject) => {
|
|
97
|
+
const r = https.request(url, { method: init.method ?? "GET", headers, cert: cert.cert, key: cert.key, ...(cert.passphrase ? { passphrase: cert.passphrase } : {}), ...(init.signal ? { signal: init.signal } : {}) }, (raw) => {
|
|
98
|
+
const enc = String(raw.headers["content-encoding"] ?? "").toLowerCase();
|
|
99
|
+
const decoder = enc === "gzip" || enc === "x-gzip" ? zlib.createGunzip()
|
|
100
|
+
: enc === "deflate" ? zlib.createInflate()
|
|
101
|
+
: enc === "br" ? zlib.createBrotliDecompress()
|
|
102
|
+
: null;
|
|
103
|
+
// pipeline: a reset mid-answer, or a bad compressed stream, rejects;
|
|
104
|
+
// and one side ending destroys the other (Codex review).
|
|
105
|
+
const res = decoder ? pipeline(raw, decoder, (err) => { if (err)
|
|
106
|
+
reject(err); }) : raw;
|
|
107
|
+
const chunks = [];
|
|
108
|
+
let size = 0;
|
|
109
|
+
res.on("data", (c) => {
|
|
110
|
+
// The read limit holds while reading, not after (Codex review).
|
|
111
|
+
size += c.length;
|
|
112
|
+
if (size > maxBytes) {
|
|
113
|
+
res.destroy();
|
|
114
|
+
raw.destroy();
|
|
115
|
+
reject(new Error(`${url}: the answer is larger than ${maxBytes} bytes — not read`));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
chunks.push(c);
|
|
119
|
+
});
|
|
120
|
+
res.on("end", () => {
|
|
121
|
+
const h = new Headers();
|
|
122
|
+
for (const [k, v] of Object.entries(raw.headers))
|
|
123
|
+
if (v !== undefined && k !== "content-encoding" && k !== "content-length")
|
|
124
|
+
h.set(k, Array.isArray(v) ? v.join(", ") : String(v));
|
|
125
|
+
resolve(new Response(chunks.length ? Buffer.concat(chunks) : null, { status: raw.statusCode ?? 502, headers: h }));
|
|
126
|
+
});
|
|
127
|
+
res.on("error", reject);
|
|
128
|
+
});
|
|
129
|
+
r.on("error", reject);
|
|
130
|
+
if (body !== undefined)
|
|
131
|
+
r.write(body);
|
|
132
|
+
r.end();
|
|
133
|
+
});
|
|
134
|
+
}
|
|
83
135
|
export class RedirectRefused extends Error {
|
|
84
136
|
constructor(message) {
|
|
85
137
|
super(message);
|
|
@@ -91,7 +143,15 @@ export async function fetchUntrusted(url, init = {}, opts = {}) {
|
|
|
91
143
|
let current = url;
|
|
92
144
|
let req = { ...init };
|
|
93
145
|
for (let hop = 0;; hop++) {
|
|
94
|
-
const res =
|
|
146
|
+
const res = opts.clientCert && !guardsUntrustedUrls()
|
|
147
|
+
// A laptop's plain fetch cannot present a certificate: node's https does.
|
|
148
|
+
? await fetchWithClientCert(current, { ...req, redirect: "manual" }, opts.clientCert, maxBytes)
|
|
149
|
+
: await platform.fetchUntrusted(current, { ...req, redirect: "manual" }, {
|
|
150
|
+
maxBytes,
|
|
151
|
+
// A pool per certificate: a connection that presented one client's
|
|
152
|
+
// certificate is never reused for another's (Codex review).
|
|
153
|
+
...(opts.clientCert ? (() => { const fp = crypto.createHash("sha256").update(`${opts.clientCert.cert}\n${opts.clientCert.key}`).digest("hex"); return { clientCert: { pool: `oauth-mtls:${fp}`, identity: fp, ...opts.clientCert } }; })() : {}),
|
|
154
|
+
});
|
|
95
155
|
const location = REDIRECT.has(res.status) ? res.headers.get("location") : null;
|
|
96
156
|
if (!location || opts.noRedirects)
|
|
97
157
|
return capBody(res, maxBytes, current);
|
|
@@ -111,7 +171,8 @@ export async function fetchUntrusted(url, init = {}, opts = {}) {
|
|
|
111
171
|
}
|
|
112
172
|
const from = new URL(current);
|
|
113
173
|
const crossOrigin = next.origin !== from.origin;
|
|
114
|
-
|
|
174
|
+
// A client certificate is a credential too: never carried to another origin.
|
|
175
|
+
if (crossOrigin && (opts.sameOrigin || opts.clientCert)) {
|
|
115
176
|
throw new RedirectRefused(`${from.origin} redirected to ${next.origin} — not followed: this request carries a credential, and it goes only where it was sent`);
|
|
116
177
|
}
|
|
117
178
|
// fetch's own rewrite: a 303, and a 301/302 answering a POST, is
|
package/index.ts
CHANGED
|
@@ -46,6 +46,7 @@ export * from "./src/oauth-token.ts";
|
|
|
46
46
|
export * from "./src/oauth-discovery.ts";
|
|
47
47
|
export * from "./src/oauth-assertion.ts";
|
|
48
48
|
export * from "./src/oauth-device.ts";
|
|
49
|
+
export * from "./src/oauth-dpop.ts";
|
|
49
50
|
export * from "./src/secret-files.ts";
|
|
50
51
|
export * from "./src/notify.ts";
|
|
51
52
|
export * from "./src/webhook.ts";
|
package/package.json
CHANGED
package/src/a2a-client.ts
CHANGED
|
@@ -200,6 +200,28 @@ export function routeFor(conn: Pick<A2AConnection, "token" | "egress">, target:
|
|
|
200
200
|
return target;
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
/** Why the egress proxy refused a request, or null for an answer that is
|
|
204
|
+
* the remote's. The diagnostic is fixed text of ours; the body's own words
|
|
205
|
+
* go back as `said`, for the untrusted fence — the proxy passes a remote's
|
|
206
|
+
* 403 through as it came, so a remote can send this exact shape (Codex
|
|
207
|
+
* review, 2 Nov 2026). */
|
|
208
|
+
export function egressRefusal(status: number, text: string): { reason: string; said: string | null } | null {
|
|
209
|
+
if (status !== 403) return null;
|
|
210
|
+
let body: unknown;
|
|
211
|
+
try {
|
|
212
|
+
body = JSON.parse(text);
|
|
213
|
+
} catch {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
const err = (body as { error?: { type?: unknown; message?: unknown } } | null)?.error;
|
|
217
|
+
if (!err || err.type !== "egress") return null;
|
|
218
|
+
const said = typeof err.message === "string" ? short(err.message.replace(/^egress:\s*/, ""), 200) : "";
|
|
219
|
+
const reason = /private|internal/i.test(said)
|
|
220
|
+
? "its address is not allowed — this platform does not call private or internal addresses"
|
|
221
|
+
: "the platform's egress refused it";
|
|
222
|
+
return { reason, said: said || null };
|
|
223
|
+
}
|
|
224
|
+
|
|
203
225
|
/**
|
|
204
226
|
* One HTTP exchange, read as JSON, to a limit, with no redirect followed.
|
|
205
227
|
*
|
|
@@ -251,6 +273,10 @@ async function exchange(
|
|
|
251
273
|
}
|
|
252
274
|
throw new A2AError(`${conn.remote}: the answer could not be read: ${short((err as Error)?.message ?? err, 200)}`);
|
|
253
275
|
}
|
|
276
|
+
// The egress proxy's own refusal ({error: {type: "egress"}}) is ours, not
|
|
277
|
+
// the remote's: the address is not allowed, and no secret would change it.
|
|
278
|
+
const egress = egressRefusal(res.status, text);
|
|
279
|
+
if (egress) throw new A2AError(`${conn.remote} cannot be called from here: ${egress.reason}`, null, true, egress.said);
|
|
254
280
|
if (res.status === 401 || res.status === 403) {
|
|
255
281
|
throw new A2AError(`${conn.remote} refused the credential (HTTP ${res.status})${conn.token ? "" : " — this remote has no secret: set one"}`, null, true);
|
|
256
282
|
}
|
|
@@ -119,15 +119,18 @@ export const OAUTH_CAPABILITIES: OAuthCapability[] = [
|
|
|
119
119
|
},
|
|
120
120
|
{
|
|
121
121
|
id: "client.token-exchange", role: "client", title: "Token exchange", spec: "RFC 8693",
|
|
122
|
-
status: "
|
|
122
|
+
status: "supported", note: "A `token-exchange` secret: another secret's live token traded for a narrower one, cached. Cloud STS is covered by the keyless identity kind; a workload identity as the subject is not yet.",
|
|
123
|
+
proof: [core("tests/oauth-token-exchange.test.ts", "the subject's live token is traded for a narrower one"), platform(KC, "token exchange (RFC 8693)"), cli("tests/cli-connect.test.ts", "--kind token-exchange")],
|
|
123
124
|
},
|
|
124
125
|
{
|
|
125
126
|
id: "client.dpop", role: "client", title: "DPoP sender-constrained tokens", spec: "RFC 9449",
|
|
126
|
-
status: "
|
|
127
|
+
status: "supported", note: "`dpop: true` on the app (`--dpop`): each connection's own key; the egress proxy signs a proof for every call through a site binding; nonces honoured.",
|
|
128
|
+
proof: [core("tests/oauth-dpop-mtls.test.ts", "refresh with DPoP"), platform("tests/egress-dpop.test.ts", "a used DPoP connection gives its key"), platform(KC, "DPoP (RFC 9449): a connection made with DPoP gets a bound token"), cli("tests/cli-connect.test.ts", "connect --dpop")],
|
|
127
129
|
},
|
|
128
130
|
{
|
|
129
131
|
id: "client.mtls", role: "client", title: "Mutual-TLS client auth and bound tokens", spec: "RFC 8705",
|
|
130
|
-
status: "
|
|
132
|
+
status: "supported", note: "`token_auth: tls_client_auth` with the certificate and key; presented to the token URL and, through the egress proxy, on the calls the token is bound to.",
|
|
133
|
+
proof: [core("tests/oauth-dpop-mtls.test.ts", "tls_client_auth (RFC 8705)"), platform("tests/egress-dpop.test.ts", "a certificate-authenticated connection gives its certificate"), platform(KC, "mutual TLS (RFC 8705)"), cli("tests/cli-connect.test.ts", "--token-auth mtls stores the certificate")],
|
|
131
134
|
},
|
|
132
135
|
{
|
|
133
136
|
id: "client.ciba", role: "client", title: "Client-initiated backchannel authentication", spec: "OpenID CIBA",
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// DPoP (RFC 9449): sender-constrained tokens. A connection made with DPoP
|
|
2
|
+
// holds a key of its own; every request to the token endpoint, and every call
|
|
3
|
+
// that uses the access token, carries a fresh proof signed with it — so a
|
|
4
|
+
// token copied off the wire is useless without the key. Bluesky/atproto and
|
|
5
|
+
// Okta's DPoP mode require it.
|
|
6
|
+
//
|
|
7
|
+
// The proof: header { typ: "dpop+jwt", alg, jwk: the public key }, claims
|
|
8
|
+
// { jti, htm, htu (no query or fragment), iat, ath? (SHA-256 of the access
|
|
9
|
+
// token, on resource calls), nonce? (when the server asked for one) }.
|
|
10
|
+
//
|
|
11
|
+
// Where the proofs are made: core's refresh and the platform's code exchange
|
|
12
|
+
// (token requests), and the egress proxy (resource calls) — a DPoP-bound
|
|
13
|
+
// connection is used through a site binding, where the proxy can sign each
|
|
14
|
+
// request; the sandbox never holds the key.
|
|
15
|
+
|
|
16
|
+
import crypto from "node:crypto";
|
|
17
|
+
import { algForKey, readPrivateKey, ASSERTION_ALGS, type AssertionAlg, type Jwk } from "./oauth-assertion.ts";
|
|
18
|
+
|
|
19
|
+
/** A new DPoP key for a connection (EC P-256, ES256): the private PEM. */
|
|
20
|
+
export function generateDpopKey(): string {
|
|
21
|
+
return crypto.generateKeyPairSync("ec", { namedCurve: "P-256" }).privateKey.export({ format: "pem", type: "pkcs8" }).toString();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The htu of a request: its URL with no query or fragment (RFC 9449 §4.2). */
|
|
25
|
+
export function dpopHtu(url: string): string {
|
|
26
|
+
const u = new URL(url);
|
|
27
|
+
return `${u.origin}${u.pathname}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The ath of an access token: base64url of its SHA-256 (RFC 9449 §4.2). */
|
|
31
|
+
export function dpopAth(accessToken: string): string {
|
|
32
|
+
return crypto.createHash("sha256").update(accessToken).digest("base64url");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const b64url = (s: string | Buffer) => Buffer.from(s).toString("base64url");
|
|
36
|
+
|
|
37
|
+
/** A DPoP proof for one request. */
|
|
38
|
+
export function dpopProof(key: string, req: { htm: string; htu: string; ath?: string; nonce?: string }, nowMs = Date.now()): string {
|
|
39
|
+
const k = readPrivateKey(key);
|
|
40
|
+
const alg: AssertionAlg = algForKey(k);
|
|
41
|
+
if (!(ASSERTION_ALGS as readonly string[]).includes(alg) || alg === "PS256") throw new Error("a DPoP key must be EC P-256/P-384, RSA or Ed25519");
|
|
42
|
+
const pub = crypto.createPublicKey(k).export({ format: "jwk" }) as Jwk;
|
|
43
|
+
const header = { typ: "dpop+jwt", alg, jwk: pub };
|
|
44
|
+
const claims = {
|
|
45
|
+
jti: b64url(crypto.randomBytes(18)),
|
|
46
|
+
htm: req.htm.toUpperCase(),
|
|
47
|
+
htu: dpopHtu(req.htu),
|
|
48
|
+
iat: Math.floor(nowMs / 1000),
|
|
49
|
+
...(req.ath ? { ath: req.ath } : {}),
|
|
50
|
+
...(req.nonce ? { nonce: req.nonce } : {}),
|
|
51
|
+
};
|
|
52
|
+
const input = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(claims))}`;
|
|
53
|
+
const data = Buffer.from(input);
|
|
54
|
+
const sig =
|
|
55
|
+
alg === "ES256" ? crypto.sign("sha256", data, { key: k, dsaEncoding: "ieee-p1363" })
|
|
56
|
+
: alg === "ES384" ? crypto.sign("sha384", data, { key: k, dsaEncoding: "ieee-p1363" })
|
|
57
|
+
: alg === "EdDSA" ? crypto.sign(null, data, k)
|
|
58
|
+
: crypto.sign("sha256", data, k);
|
|
59
|
+
return `${input}.${b64url(sig)}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The JWK thumbprint the server binds a DPoP token to (cnf.jkt). */
|
|
63
|
+
export function dpopJkt(key: string): string {
|
|
64
|
+
const pub = crypto.createPublicKey(readPrivateKey(key)).export({ format: "jwk" }) as Jwk;
|
|
65
|
+
const members = pub.kty === "EC" ? { crv: pub.crv, kty: pub.kty, x: pub.x, y: pub.y } : pub.kty === "RSA" ? { e: pub.e, kty: pub.kty, n: pub.n } : { crv: pub.crv, kty: pub.kty, x: pub.x };
|
|
66
|
+
return crypto.createHash("sha256").update(JSON.stringify(members)).digest("base64url");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Server-provided nonces (RFC 9449 §8, §9), kept per origin and per DPoP
|
|
70
|
+
// key: the last one a server sent to this connection is the one its next
|
|
71
|
+
// proof carries — two connections to one server never overwrite each other's
|
|
72
|
+
// (a server may issue nonces per client; Codex review).
|
|
73
|
+
const nonces = new Map<string, string>();
|
|
74
|
+
const nonceKey = (url: string, key: string) => `${new URL(url).origin} ${dpopJkt(key)}`;
|
|
75
|
+
|
|
76
|
+
/** Remember a DPoP-Nonce a response to this connection carried. */
|
|
77
|
+
export function noteDpopNonce(url: string, res: { headers: { get(name: string): string | null } }, key: string): void {
|
|
78
|
+
const n = res.headers.get("dpop-nonce");
|
|
79
|
+
if (n) nonces.set(nonceKey(url, key), n);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The nonce for this connection's next proof to this URL's server, if any. */
|
|
83
|
+
export function dpopNonceFor(url: string, key: string): string | undefined {
|
|
84
|
+
return nonces.get(nonceKey(url, key));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Whether an answer asks for a (new) nonce: a token endpoint's 400
|
|
88
|
+
* use_dpop_nonce, or a resource's 401 with it in WWW-Authenticate. */
|
|
89
|
+
export function wantsDpopNonce(res: { status: number; headers: { get(name: string): string | null } }, body?: { error?: string }): boolean {
|
|
90
|
+
if (!res.headers.get("dpop-nonce")) return false;
|
|
91
|
+
if (res.status === 400 && body?.error === "use_dpop_nonce") return true;
|
|
92
|
+
return res.status === 401 && /use_dpop_nonce/.test(res.headers.get("www-authenticate") ?? "");
|
|
93
|
+
}
|
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/runner.ts
CHANGED
|
@@ -2407,7 +2407,10 @@ async function runStep(
|
|
|
2407
2407
|
const cardProblems = new Map<string, string>();
|
|
2408
2408
|
for (const r of remotes) {
|
|
2409
2409
|
const why = await platform.checkUntrustedUrl(r.card);
|
|
2410
|
-
if (why)
|
|
2410
|
+
if (why) {
|
|
2411
|
+
cardProblems.set(r.name, why);
|
|
2412
|
+
console.warn(`[a2a] remotes: ${r.name} refused for ${tenant}/${path.basename(workspaceRootOf(agentDir))} (${step.agent}): ${why}`);
|
|
2413
|
+
}
|
|
2411
2414
|
}
|
|
2412
2415
|
const stepRemotes: RemoteSpec[] = remotes.map((r) => {
|
|
2413
2416
|
const refused = cardProblems.get(r.name);
|
package/src/secrets.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { fetchUntrusted } from "./untrusted-fetch.ts";
|
|
|
27
27
|
import { oauthTokenRequest, readTokenResponse, tokenAuthProblem, type TokenAuth } from "./oauth-token.ts";
|
|
28
28
|
import { isSecureUrl } from "./oauth-discovery.ts";
|
|
29
29
|
import type { AssertionAlg } from "./oauth-assertion.ts";
|
|
30
|
+
import { dpopProof, dpopNonceFor, noteDpopNonce } from "./oauth-dpop.ts";
|
|
30
31
|
|
|
31
32
|
/**
|
|
32
33
|
* GCM's auth tag, pinned to its full 16 bytes at every cipher call. Without
|
|
@@ -45,7 +46,7 @@ const tenantKey = (tenant: string) => platform.tenantKey(tenant);
|
|
|
45
46
|
import crypto from "node:crypto";
|
|
46
47
|
import { assertSafeName } from "./store.ts";
|
|
47
48
|
import { trimSlashes } from "./paths.ts";
|
|
48
|
-
import { SITE } from "./network.ts";
|
|
49
|
+
import { SITE, siteCovers } from "./network.ts";
|
|
49
50
|
|
|
50
51
|
const keyFile = () => path.join(dataRoot(), ".secret-key");
|
|
51
52
|
|
|
@@ -97,7 +98,7 @@ function secretsFile(tenant: string, workspace?: string) {
|
|
|
97
98
|
/** How the stored value is used at run time — "oauth2"/"service-account"
|
|
98
99
|
* are refreshed into live tokens, "file"/"ssh"/"api" are materialised into
|
|
99
100
|
* the run's scratch. Plain values have no kind. */
|
|
100
|
-
export type SecretKind = "oauth2" | "service-account" | "file" | "ssh" | "api" | "client-cert" | "identity";
|
|
101
|
+
export type SecretKind = "oauth2" | "service-account" | "file" | "ssh" | "api" | "client-cert" | "identity" | "token-exchange";
|
|
101
102
|
|
|
102
103
|
interface StoredSecret {
|
|
103
104
|
iv: string;
|
|
@@ -953,6 +954,15 @@ export interface OAuth2Config {
|
|
|
953
954
|
client_assertion_kid?: string;
|
|
954
955
|
client_assertion_alg?: AssertionAlg;
|
|
955
956
|
client_assertion_cert?: string;
|
|
957
|
+
/** DPoP (RFC 9449): this connection's own key; token requests and the
|
|
958
|
+
* calls that use its token carry proofs signed with it (oauth-dpop.ts). */
|
|
959
|
+
dpop_key?: string;
|
|
960
|
+
/** tls_client_auth (RFC 8705): the client certificate and key presented
|
|
961
|
+
* to the token URL — and, through the egress proxy, on the calls its
|
|
962
|
+
* certificate-bound token is used for. */
|
|
963
|
+
tls_client_cert?: string;
|
|
964
|
+
tls_client_key?: string;
|
|
965
|
+
tls_client_passphrase?: string;
|
|
956
966
|
}
|
|
957
967
|
|
|
958
968
|
const OAUTH2_PREFIX = "@oauth2 ";
|
|
@@ -978,8 +988,9 @@ export function setOAuth2Secret(
|
|
|
978
988
|
}
|
|
979
989
|
// A public client (PKCE, no secret) refreshes with its client id alone;
|
|
980
990
|
// client credentials are nothing without the secret.
|
|
981
|
-
|
|
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");
|