@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.
@@ -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
- if (config.grant_type === "client_credentials" && !config.client_secret?.trim() && config.token_auth !== "private_key_jwt") {
821
- throw new Error("oauth2 secret needs client_secret for client_credentials (or token_auth private_key_jwt with a key)");
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: request.headers,
945
+ headers,
934
946
  body: request.body,
935
947
  signal: AbortSignal.timeout(15_000),
936
- }, { sameOrigin: true });
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(await res.text().catch(() => ""), res.headers.get("content-type") ?? "");
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
- const fetcher = opts.fetcher ?? ((url, init) => fetchUntrusted(url, init, { sameOrigin: true, noRedirects: true }));
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>;
@@ -20,7 +20,8 @@
20
20
  // tier holding it all in memory is the platform's problem, not the tenant's.
21
21
  // Past the limit the body errors — a caller never gets the first megabyte
22
22
  // passed off as the whole answer.
23
- import { platform } from "./platform.js";
23
+ import crypto from "node:crypto";
24
+ import { platform, guardsUntrustedUrls } from "./platform.js";
24
25
  /** How many redirects one request may follow. fetch's own limit is 20;
25
26
  * nothing a tenant points the platform at needs more than a couple. */
26
27
  export const MAX_REDIRECTS = 5;
@@ -80,6 +81,57 @@ export function capBody(res, maxBytes, url) {
80
81
  Object.defineProperty(out, "url", { value: res.url });
81
82
  return out;
82
83
  }
84
+ /** One request with a client certificate, through node's https — a laptop's
85
+ * fetch has no way to present one. Redirects are the caller's. */
86
+ async function fetchWithClientCert(url, init, cert, maxBytes) {
87
+ const https = await import("node:https");
88
+ const body = init.body === undefined || init.body === null ? undefined : typeof init.body === "string" ? init.body : Buffer.from(await new Response(init.body).arrayBuffer());
89
+ const headers = {};
90
+ new Headers(init.headers).forEach((v, k) => (headers[k] = v));
91
+ // fetch decompresses; node's https does not. Ask for none, and decode one
92
+ // that comes anyway (Codex review).
93
+ headers["accept-encoding"] ??= "identity";
94
+ const zlib = await import("node:zlib");
95
+ const { pipeline } = await import("node:stream");
96
+ return new Promise((resolve, reject) => {
97
+ const r = https.request(url, { method: init.method ?? "GET", headers, cert: cert.cert, key: cert.key, ...(cert.passphrase ? { passphrase: cert.passphrase } : {}), ...(init.signal ? { signal: init.signal } : {}) }, (raw) => {
98
+ const enc = String(raw.headers["content-encoding"] ?? "").toLowerCase();
99
+ const decoder = enc === "gzip" || enc === "x-gzip" ? zlib.createGunzip()
100
+ : enc === "deflate" ? zlib.createInflate()
101
+ : enc === "br" ? zlib.createBrotliDecompress()
102
+ : null;
103
+ // pipeline: a reset mid-answer, or a bad compressed stream, rejects;
104
+ // and one side ending destroys the other (Codex review).
105
+ const res = decoder ? pipeline(raw, decoder, (err) => { if (err)
106
+ reject(err); }) : raw;
107
+ const chunks = [];
108
+ let size = 0;
109
+ res.on("data", (c) => {
110
+ // The read limit holds while reading, not after (Codex review).
111
+ size += c.length;
112
+ if (size > maxBytes) {
113
+ res.destroy();
114
+ raw.destroy();
115
+ reject(new Error(`${url}: the answer is larger than ${maxBytes} bytes — not read`));
116
+ return;
117
+ }
118
+ chunks.push(c);
119
+ });
120
+ res.on("end", () => {
121
+ const h = new Headers();
122
+ for (const [k, v] of Object.entries(raw.headers))
123
+ if (v !== undefined && k !== "content-encoding" && k !== "content-length")
124
+ h.set(k, Array.isArray(v) ? v.join(", ") : String(v));
125
+ resolve(new Response(chunks.length ? Buffer.concat(chunks) : null, { status: raw.statusCode ?? 502, headers: h }));
126
+ });
127
+ res.on("error", reject);
128
+ });
129
+ r.on("error", reject);
130
+ if (body !== undefined)
131
+ r.write(body);
132
+ r.end();
133
+ });
134
+ }
83
135
  export class RedirectRefused extends Error {
84
136
  constructor(message) {
85
137
  super(message);
@@ -91,7 +143,15 @@ export async function fetchUntrusted(url, init = {}, opts = {}) {
91
143
  let current = url;
92
144
  let req = { ...init };
93
145
  for (let hop = 0;; hop++) {
94
- const res = await platform.fetchUntrusted(current, { ...req, redirect: "manual" }, { maxBytes });
146
+ const res = opts.clientCert && !guardsUntrustedUrls()
147
+ // A laptop's plain fetch cannot present a certificate: node's https does.
148
+ ? await fetchWithClientCert(current, { ...req, redirect: "manual" }, opts.clientCert, maxBytes)
149
+ : await platform.fetchUntrusted(current, { ...req, redirect: "manual" }, {
150
+ maxBytes,
151
+ // A pool per certificate: a connection that presented one client's
152
+ // certificate is never reused for another's (Codex review).
153
+ ...(opts.clientCert ? (() => { const fp = crypto.createHash("sha256").update(`${opts.clientCert.cert}\n${opts.clientCert.key}`).digest("hex"); return { clientCert: { pool: `oauth-mtls:${fp}`, identity: fp, ...opts.clientCert } }; })() : {}),
154
+ });
95
155
  const location = REDIRECT.has(res.status) ? res.headers.get("location") : null;
96
156
  if (!location || opts.noRedirects)
97
157
  return capBody(res, maxBytes, current);
@@ -111,7 +171,8 @@ export async function fetchUntrusted(url, init = {}, opts = {}) {
111
171
  }
112
172
  const from = new URL(current);
113
173
  const crossOrigin = next.origin !== from.origin;
114
- if (crossOrigin && opts.sameOrigin) {
174
+ // A client certificate is a credential too: never carried to another origin.
175
+ if (crossOrigin && (opts.sameOrigin || opts.clientCert)) {
115
176
  throw new RedirectRefused(`${from.origin} redirected to ${next.origin} — not followed: this request carries a credential, and it goes only where it was sent`);
116
177
  }
117
178
  // fetch's own rewrite: a 303, and a 301/302 answering a POST, is
package/index.ts CHANGED
@@ -45,6 +45,8 @@ export * from "./src/oauth-presets.ts";
45
45
  export * from "./src/oauth-token.ts";
46
46
  export * from "./src/oauth-discovery.ts";
47
47
  export * from "./src/oauth-assertion.ts";
48
+ export * from "./src/oauth-device.ts";
49
+ export * from "./src/oauth-dpop.ts";
48
50
  export * from "./src/secret-files.ts";
49
51
  export * from "./src/notify.ts";
50
52
  export * from "./src/webhook.ts";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldrun/core",
3
- "version": "10.0.0",
3
+ "version": "11.0.0",
4
4
  "description": "Agents are just folders. The format, the runner, the checks.",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "type": "module",
@@ -114,19 +114,23 @@ export const OAUTH_CAPABILITIES: OAuthCapability[] = [
114
114
  },
115
115
  {
116
116
  id: "client.device", role: "client", title: "Device authorization", spec: "RFC 8628",
117
- status: "planned", note: "Part F: `foldrun connect --device`.",
117
+ status: "supported", note: "Connect with a code on any device: the Connections form, or `foldrun connect --device`. PKCE is sent with the device grant.",
118
+ proof: [core("tests/oauth-device.test.ts", "pending, then slow_down"), platform("tests/oauth-connect.test.ts", "device authorization (RFC 8628): started for a saved app"), platform(KC, "device authorization (RFC 8628): started for a saved app, approved by the person at Keycloak"), web("tests/oauth-device-route.test.ts", "start → pending → approved → stored"), cli("tests/cli-connect.test.ts", "connect --device (RFC 8628)")],
118
119
  },
119
120
  {
120
121
  id: "client.token-exchange", role: "client", title: "Token exchange", spec: "RFC 8693",
121
- status: "planned", note: "Part I: a `token-exchange` secret kind. Cloud STS is covered today by the keyless identity kind.",
122
+ status: "supported", note: "A `token-exchange` secret: another secret's live token traded for a narrower one, cached. Cloud STS is covered by the keyless identity kind; a workload identity as the subject is not yet.",
123
+ proof: [core("tests/oauth-token-exchange.test.ts", "the subject's live token is traded for a narrower one"), platform(KC, "token exchange (RFC 8693)"), cli("tests/cli-connect.test.ts", "--kind token-exchange")],
122
124
  },
123
125
  {
124
126
  id: "client.dpop", role: "client", title: "DPoP sender-constrained tokens", spec: "RFC 9449",
125
- status: "planned", note: "Part H: Bluesky/atproto, Okta DPoP.",
127
+ status: "supported", note: "`dpop: true` on the app (`--dpop`): each connection's own key; the egress proxy signs a proof for every call through a site binding; nonces honoured.",
128
+ proof: [core("tests/oauth-dpop-mtls.test.ts", "refresh with DPoP"), platform("tests/egress-dpop.test.ts", "a used DPoP connection gives its key"), platform(KC, "DPoP (RFC 9449): a connection made with DPoP gets a bound token"), cli("tests/cli-connect.test.ts", "connect --dpop")],
126
129
  },
127
130
  {
128
131
  id: "client.mtls", role: "client", title: "Mutual-TLS client auth and bound tokens", spec: "RFC 8705",
129
- status: "planned", note: "Part H: the client-cert secret at the token endpoint.",
132
+ status: "supported", note: "`token_auth: tls_client_auth` with the certificate and key; presented to the token URL and, through the egress proxy, on the calls the token is bound to.",
133
+ proof: [core("tests/oauth-dpop-mtls.test.ts", "tls_client_auth (RFC 8705)"), platform("tests/egress-dpop.test.ts", "a certificate-authenticated connection gives its certificate"), platform(KC, "mutual TLS (RFC 8705)"), cli("tests/cli-connect.test.ts", "--token-auth mtls stores the certificate")],
130
134
  },
131
135
  {
132
136
  id: "client.ciba", role: "client", title: "Client-initiated backchannel authentication", spec: "OpenID CIBA",
@@ -175,7 +179,8 @@ export const OAUTH_CAPABILITIES: OAuthCapability[] = [
175
179
  },
176
180
  {
177
181
  id: "server.client-credentials", role: "server", title: "Client credentials for machine clients", spec: "RFC 6749 §4.4",
178
- status: "planned", note: "Part G: machine MCP clients an admin registers.",
182
+ status: "supported", note: "An admin registers a machine client (Settings, or `foldrun mcp clients add`) with a role, workspaces and access; no refresh token; disconnecting it makes it `invalid_client`.",
183
+ proof: [web("tests/mcp-oauth.test.ts", "machine clients (client credentials, part G)"), web("tests/mcp-oauth.test.ts", "a machine client can sign in with private_key_jwt"), cli("tests/cli-mcp.test.ts", "mcp clients add")],
179
184
  },
180
185
  {
181
186
  id: "server.private-key-jwt", role: "server", title: "Signed client assertion (private_key_jwt)", spec: "RFC 7523 §2.2",
@@ -0,0 +1,149 @@
1
+ // Device authorization (RFC 8628): connect an account from somewhere with no
2
+ // browser of its own — a terminal over ssh, a server. foldrun asks the
3
+ // provider for a device code, a person opens the provider's page on any
4
+ // device and types the short user code, and foldrun polls the token endpoint
5
+ // until they have said yes (or no, or the code ran out). The grant that
6
+ // comes back is stored like any other: an oauth2 secret.
7
+ //
8
+ // One request each, so a caller can poll its own way: the CLI in a loop,
9
+ // the platform once per dashboard poll.
10
+
11
+ import crypto from "node:crypto";
12
+ import { oauthTokenRequest, readTokenResponse, type TokenClient, type TokenResponse } from "./oauth-token.ts";
13
+
14
+ export const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
15
+
16
+ export interface DeviceStart {
17
+ device_code: string;
18
+ user_code: string;
19
+ verification_uri: string;
20
+ /** The page with the code already in it, when the provider gives one. */
21
+ verification_uri_complete?: string;
22
+ /** Seconds the codes live. */
23
+ expires_in: number;
24
+ /** Seconds to wait between polls (default 5, RFC 8628 §3.2). */
25
+ interval: number;
26
+ /** PKCE for the device grant: sent with every poll. Keycloak requires it
27
+ * of an S256 client; a server that does not know it ignores both halves
28
+ * (found by the conformance suite). Secret: never shown to the person. */
29
+ code_verifier: string;
30
+ }
31
+
32
+ type Fetcher = (url: string, init: RequestInit) => Promise<Response>;
33
+
34
+ /** RFC 8628 §3.1–3.2: ask for a device code and a user code. */
35
+ export async function startDeviceAuthorization(
36
+ client: TokenClient & { device_url: string; token_url: string; issuer?: string },
37
+ scopes: string,
38
+ fetcher: Fetcher,
39
+ extra: Record<string, string> = {},
40
+ ): Promise<DeviceStart> {
41
+ // The client authenticates as at the token endpoint (§3.1); a signed
42
+ // assertion's aud is the issuer when known, else the token URL.
43
+ const verifier = crypto.randomBytes(32).toString("base64url");
44
+ const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
45
+ const request = oauthTokenRequest(client, { ...extra, ...(scopes.trim() ? { scope: scopes.trim() } : {}), code_challenge: challenge, code_challenge_method: "S256" }, { clientIdInBody: true, audience: client.issuer ?? client.token_url });
46
+ const res = await fetcher(client.device_url, { method: "POST", headers: request.headers, body: request.body, redirect: "error", signal: AbortSignal.timeout(15_000) });
47
+ const text = await res.text().catch(() => "");
48
+ let body: Record<string, unknown> = {};
49
+ try {
50
+ body = JSON.parse(text) as Record<string, unknown>;
51
+ } catch {
52
+ body = Object.fromEntries(new URLSearchParams(text));
53
+ }
54
+ if (!res.ok || typeof body.device_code !== "string" || typeof body.user_code !== "string") {
55
+ const why = (body.error_description ?? body.error ?? "no device code in the reply") as string;
56
+ throw new Error(`the provider refused the device authorization (${res.status}): ${why}`);
57
+ }
58
+ // Some providers (Google's older endpoint) say verification_url.
59
+ const uri = (body.verification_uri ?? body.verification_url) as unknown;
60
+ if (typeof uri !== "string" || !/^https?:\/\//.test(uri)) throw new Error("the provider gave no verification_uri to send the person to");
61
+ const num = (v: unknown, d: number) => {
62
+ const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : NaN;
63
+ return Number.isFinite(n) && n > 0 ? n : d;
64
+ };
65
+ return {
66
+ device_code: body.device_code,
67
+ user_code: body.user_code,
68
+ verification_uri: uri,
69
+ ...(typeof body.verification_uri_complete === "string" ? { verification_uri_complete: body.verification_uri_complete } : {}),
70
+ expires_in: num(body.expires_in, 600),
71
+ interval: num(body.interval, 5),
72
+ code_verifier: verifier,
73
+ };
74
+ }
75
+
76
+ /** What one poll came to (RFC 8628 §3.5). */
77
+ export type DevicePoll =
78
+ | { status: "approved"; token: TokenResponse }
79
+ | { status: "pending" }
80
+ /** The provider asked for a longer wait: add 5 seconds to the interval. */
81
+ | { status: "slow_down" }
82
+ | { status: "denied"; reason: string }
83
+ | { status: "expired"; reason: string };
84
+
85
+ /** One token request with the device code. */
86
+ export async function pollDeviceToken(
87
+ client: TokenClient & { token_url: string },
88
+ deviceCode: string,
89
+ fetcher: Fetcher,
90
+ codeVerifier?: string,
91
+ ): Promise<DevicePoll> {
92
+ const request = oauthTokenRequest(client, { grant_type: DEVICE_GRANT, device_code: deviceCode, ...(codeVerifier ? { code_verifier: codeVerifier } : {}) }, { audience: client.token_url });
93
+ const res = await fetcher(client.token_url, { method: "POST", headers: request.headers, body: request.body, redirect: "error", signal: AbortSignal.timeout(15_000) });
94
+ // A body cut off mid-read throws here, so the caller retries it as the
95
+ // transport failure it is — never read as an empty refusal (Codex review).
96
+ const token = readTokenResponse(await res.text(), res.headers.get("content-type") ?? "");
97
+ if (res.ok && token.access_token) return { status: "approved", token };
98
+ switch (token.error) {
99
+ case "authorization_pending": return { status: "pending" };
100
+ case "slow_down": return { status: "slow_down" };
101
+ case "access_denied": return { status: "denied", reason: token.error_description ?? "the person said no" };
102
+ case "expired_token": return { status: "expired", reason: token.error_description ?? "the code ran out before anyone approved it" };
103
+ default:
104
+ throw new Error(`the provider refused the device token request (${res.status}): ${token.error_description ?? token.error ?? "no token in the reply"}`);
105
+ }
106
+ }
107
+
108
+ /** A failure of the wire, not of the provider: fetch's TypeError, an abort
109
+ * or a timeout. */
110
+ function isTransient(err: unknown): boolean {
111
+ if (err instanceof TypeError) return true;
112
+ const name = (err as { name?: unknown })?.name;
113
+ return name === "AbortError" || name === "TimeoutError";
114
+ }
115
+
116
+ /** Poll until approved, denied or expired, honouring interval and slow_down. */
117
+ export async function waitForDeviceToken(
118
+ client: TokenClient & { token_url: string },
119
+ start: DeviceStart,
120
+ fetcher: Fetcher,
121
+ opts: { sleep?: (ms: number) => Promise<void>; now?: () => number } = {},
122
+ ): Promise<TokenResponse> {
123
+ const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
124
+ const now = opts.now ?? Date.now;
125
+ const deadline = now() + start.expires_in * 1000;
126
+ let interval = start.interval;
127
+ // A client that cannot make the request (no key, a bad alg) says so now,
128
+ // not after ten minutes of retries.
129
+ oauthTokenRequest(client, { grant_type: DEVICE_GRANT, device_code: start.device_code }, { audience: client.token_url });
130
+ for (;;) {
131
+ await sleep(interval * 1000);
132
+ if (now() > deadline) throw new Error("the code ran out before anyone approved it — run it again");
133
+ let r: DevicePoll;
134
+ try {
135
+ r = await pollDeviceToken(client, start.device_code, fetcher, start.code_verifier);
136
+ } catch (err) {
137
+ // Only a transport failure — a timeout, a dropped connection — is
138
+ // retried, less often (RFC 8628 §3.5); anything else is final. The
139
+ // backoff never polls faster than the interval the provider set.
140
+ if (!isTransient(err)) throw err;
141
+ interval = interval >= 60 ? interval : Math.min(interval * 2, 60);
142
+ continue;
143
+ }
144
+ if (r.status === "approved") return r.token;
145
+ if (r.status === "slow_down") interval += 5;
146
+ else if (r.status === "denied") throw new Error(`the provider says no: ${r.reason}`);
147
+ else if (r.status === "expired") throw new Error(`${r.reason} — run it again`);
148
+ }
149
+ }
@@ -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
+ }