@foldrun/core 9.4.0 → 10.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.
@@ -817,8 +817,8 @@ export function setOAuth2Secret(tenant, name, config, workspace, limit = {}) {
817
817
  }
818
818
  // A public client (PKCE, no secret) refreshes with its client id alone;
819
819
  // client credentials are nothing without the secret.
820
- if (config.grant_type === "client_credentials" && !config.client_secret?.trim()) {
821
- throw new Error("oauth2 secret needs client_secret for client_credentials");
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)");
822
822
  }
823
823
  if (config.grant_type !== "client_credentials" && !config.refresh_token?.trim()) {
824
824
  throw new Error("oauth2 secret needs refresh_token (or grant_type: client_credentials)");
@@ -908,11 +908,14 @@ async function exchange(config, cacheKey) {
908
908
  const flight = (async () => {
909
909
  // One builder for every token request (oauth-token.ts): the client's
910
910
  // identity goes where its token_auth says, and an `extra` cannot set it.
911
- const request = oauthTokenRequest(config, {
911
+ // Built per try: a private_key_jwt assertion's jti is good once, so a
912
+ // retry after a lost answer must carry a new one (Codex review).
913
+ const build = () => oauthTokenRequest(config, {
912
914
  grant_type: config.grant_type ?? "refresh_token",
913
915
  ...(config.grant_type === "client_credentials" ? {} : { refresh_token: config.refresh_token }),
914
916
  ...(config.extra ?? {}),
915
- });
917
+ }, { audience: config.token_url });
918
+ build(); // a config that cannot make a request says so before any network
916
919
  // A network blip is retried; a provider's answer is not. "fetch failed"
917
920
  // (a reset, a DNS hiccup, a timeout) failed whole steps on the first try
918
921
  // — gbp-desk's Thursday posts, 7 Oct 2026. A 4xx (revoked, wrong client)
@@ -924,6 +927,7 @@ async function exchange(config, cacheKey) {
924
927
  if (attempt)
925
928
  await new Promise((r) => setTimeout(r, oauthRetryDelayMs(attempt)));
926
929
  try {
930
+ const request = build();
927
931
  res = await fetchUntrusted(config.token_url, {
928
932
  method: "POST",
929
933
  headers: request.headers,
@@ -1062,7 +1066,7 @@ export async function revokeOAuth2Value(value, opts = {}) {
1062
1066
  const out = { revoked: [] };
1063
1067
  for (const [hint, token] of tokens) {
1064
1068
  try {
1065
- const request = oauthTokenRequest(config, { token, token_type_hint: hint });
1069
+ const request = oauthTokenRequest(config, { token, token_type_hint: hint }, { audience: config.token_url });
1066
1070
  const res = await fetcher(config.revocation_url, { method: "POST", headers: request.headers, body: request.body, redirect: "error", signal: AbortSignal.timeout(10_000) });
1067
1071
  if (res.ok) {
1068
1072
  await res.body?.cancel().catch(() => { });
package/index.ts CHANGED
@@ -44,6 +44,7 @@ export * from "./src/secrets.ts";
44
44
  export * from "./src/oauth-presets.ts";
45
45
  export * from "./src/oauth-token.ts";
46
46
  export * from "./src/oauth-discovery.ts";
47
+ export * from "./src/oauth-assertion.ts";
47
48
  export * from "./src/secret-files.ts";
48
49
  export * from "./src/notify.ts";
49
50
  export * from "./src/webhook.ts";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldrun/core",
3
- "version": "9.4.0",
3
+ "version": "10.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",
@@ -0,0 +1,234 @@
1
+ // Signed client assertions (private_key_jwt, RFC 7523 §2.2 and §3; OIDC Core
2
+ // §9): a client proves itself at a token endpoint by signing a short JWT with
3
+ // its private key instead of sending a secret. What Microsoft Entra
4
+ // certificate credentials, Okta and FAPI providers ask for — and what the MCP
5
+ // server (foldrun-web server/mcp-oauth.ts) accepts from apps that publish a
6
+ // JWKS. Both halves are here so the client and the server read one rule.
7
+ //
8
+ // The assertion: header { alg, typ: "JWT", kid?, x5t?, x5t#S256? }, claims
9
+ // { iss = sub = client_id, aud = the endpoint, jti = random, iat, exp ≤ 5 min }.
10
+
11
+ import crypto from "node:crypto";
12
+
13
+ /** A JSON Web Key, as node's crypto reads and writes it. */
14
+ export interface Jwk { kty?: string; crv?: string; x?: string; y?: string; n?: string; e?: string; d?: string; kid?: string; alg?: string; use?: string; [k: string]: unknown }
15
+
16
+ export const ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
17
+ /** The algorithms foldrun signs and accepts. Never `none`, never HMAC. */
18
+ export const ASSERTION_ALGS = ["ES256", "ES384", "RS256", "PS256", "EdDSA"] as const;
19
+ export type AssertionAlg = (typeof ASSERTION_ALGS)[number];
20
+ /** The longest an assertion may live (proposal: ≤ 5 minutes). */
21
+ export const ASSERTION_MAX_SECONDS = 300;
22
+
23
+ const b64url = (b: Buffer | string) => Buffer.from(b).toString("base64url");
24
+
25
+ /** The alg a key signs with when none is said: from its type. */
26
+ export function algForKey(key: crypto.KeyObject): AssertionAlg {
27
+ const t = key.asymmetricKeyType;
28
+ if (t === "ec") {
29
+ const crv = key.asymmetricKeyDetails?.namedCurve;
30
+ if (crv === "prime256v1" || crv === "P-256") return "ES256";
31
+ if (crv === "secp384r1" || crv === "P-384") return "ES384";
32
+ throw new Error(`an EC key on ${crv} cannot sign a client assertion — use P-256 or P-384`);
33
+ }
34
+ if (t === "rsa") return "RS256";
35
+ // An RSA-PSS-only key cannot be exported as a JWK (no kid, no public JWK
36
+ // for the provider): refused here, not at the first token exchange. A
37
+ // plain RSA key signs PS256 when client_assertion_alg says so.
38
+ if (t === "rsa-pss") throw new Error("an RSA-PSS-only key is not supported — use a plain RSA key with client_assertion_alg PS256");
39
+ if (t === "ed25519") return "EdDSA";
40
+ throw new Error(`a ${t} key cannot sign a client assertion — use EC P-256, RSA or Ed25519`);
41
+ }
42
+
43
+ /** A private key from PEM (PKCS#8 or traditional) or a private JWK (JSON). */
44
+ export function readPrivateKey(material: string): crypto.KeyObject {
45
+ const text = material.trim();
46
+ try {
47
+ if (text.startsWith("{")) return crypto.createPrivateKey({ key: JSON.parse(text), format: "jwk" });
48
+ return crypto.createPrivateKey(text);
49
+ } catch (err) {
50
+ throw new Error(`the client assertion key is not a private key (PEM or JWK): ${err instanceof Error ? err.message : String(err)}`);
51
+ }
52
+ }
53
+
54
+ /** RFC 7638 thumbprint of a public JWK — the default `kid`. */
55
+ export function jwkThumbprint(jwk: Jwk): string {
56
+ const members: Record<string, unknown> =
57
+ jwk.kty === "EC" ? { crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y }
58
+ : jwk.kty === "RSA" ? { e: jwk.e, kty: jwk.kty, n: jwk.n }
59
+ : { crv: jwk.crv, kty: jwk.kty, x: jwk.x };
60
+ return b64url(crypto.createHash("sha256").update(JSON.stringify(members)).digest());
61
+ }
62
+
63
+ /** The public JWK a provider needs to register for a private key. */
64
+ export function publicJwkOf(privateKey: string, opts: { kid?: string; alg?: AssertionAlg } = {}): Jwk & { kid: string; alg: AssertionAlg; use: "sig" } {
65
+ const key = readPrivateKey(privateKey);
66
+ const pub = crypto.createPublicKey(key).export({ format: "jwk" }) as Jwk;
67
+ const alg = opts.alg ?? algForKey(key);
68
+ return { ...pub, kid: opts.kid ?? ownKid(privateKey) ?? jwkThumbprint(pub), alg, use: "sig" };
69
+ }
70
+
71
+ /** A new signing key for an app: the private PEM to keep sealed, and the
72
+ * public JWK to register with the provider. EC P-256 (ES256) unless asked. */
73
+ export function generateClientKey(alg: "ES256" | "RS256" | "EdDSA" = "ES256"): { privateKey: string; publicJwk: ReturnType<typeof publicJwkOf> } {
74
+ const pair =
75
+ alg === "RS256" ? crypto.generateKeyPairSync("rsa", { modulusLength: 2048 })
76
+ : alg === "EdDSA" ? crypto.generateKeyPairSync("ed25519")
77
+ : crypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
78
+ const privateKey = pair.privateKey.export({ format: "pem", type: "pkcs8" }).toString();
79
+ return { privateKey, publicJwk: publicJwkOf(privateKey, { alg }) };
80
+ }
81
+
82
+ /** Whether a key can sign (and its public half verify) an alg. */
83
+ function algFits(alg: AssertionAlg, key: crypto.KeyObject): boolean {
84
+ return algForKey(key) === alg || (alg === "PS256" && key.asymmetricKeyType === "rsa");
85
+ }
86
+
87
+ /** The kid a private JWK carries itself (the provider registered it by it). */
88
+ function ownKid(material: string): string | undefined {
89
+ const text = material.trim();
90
+ if (!text.startsWith("{")) return undefined;
91
+ try {
92
+ const kid = (JSON.parse(text) as { kid?: unknown }).kid;
93
+ return typeof kid === "string" && kid ? kid : undefined;
94
+ } catch {
95
+ return undefined;
96
+ }
97
+ }
98
+
99
+ function signBytes(alg: AssertionAlg, data: Buffer, key: crypto.KeyObject): Buffer {
100
+ switch (alg) {
101
+ case "ES256": return crypto.sign("sha256", data, { key, dsaEncoding: "ieee-p1363" });
102
+ case "ES384": return crypto.sign("sha384", data, { key, dsaEncoding: "ieee-p1363" });
103
+ case "RS256": return crypto.sign("sha256", data, key);
104
+ case "PS256": return crypto.sign("sha256", data, { key, padding: crypto.constants.RSA_PKCS1_PSS_PADDING, saltLength: 32 });
105
+ case "EdDSA": return crypto.sign(null, data, key);
106
+ }
107
+ }
108
+
109
+ function verifyBytes(alg: AssertionAlg, data: Buffer, sig: Buffer, key: crypto.KeyObject): boolean {
110
+ switch (alg) {
111
+ case "ES256": return crypto.verify("sha256", data, { key, dsaEncoding: "ieee-p1363" }, sig);
112
+ case "ES384": return crypto.verify("sha384", data, { key, dsaEncoding: "ieee-p1363" }, sig);
113
+ case "RS256": return crypto.verify("sha256", data, key, sig);
114
+ case "PS256": return crypto.verify("sha256", data, { key, padding: crypto.constants.RSA_PKCS1_PSS_PADDING, saltLength: 32 }, sig);
115
+ case "EdDSA": return crypto.verify(null, data, key, sig);
116
+ }
117
+ }
118
+
119
+ export interface AssertionSigner {
120
+ client_id: string;
121
+ /** The private key, PEM or JWK. */
122
+ client_assertion_key: string;
123
+ client_assertion_kid?: string;
124
+ client_assertion_alg?: AssertionAlg;
125
+ /** The certificate for the key (PEM), when the provider identifies keys by
126
+ * it (Microsoft Entra: x5t / x5t#S256 in the header). */
127
+ client_assertion_cert?: string;
128
+ }
129
+
130
+ /** Sign a client assertion for `audience` (the endpoint it is sent to). */
131
+ export function signClientAssertion(signer: AssertionSigner, audience: string, nowMs = Date.now()): string {
132
+ const key = readPrivateKey(signer.client_assertion_key);
133
+ const alg = signer.client_assertion_alg ?? algForKey(key);
134
+ if (!(ASSERTION_ALGS as readonly string[]).includes(alg)) throw new Error(`client_assertion_alg must be one of ${ASSERTION_ALGS.join(", ")}`);
135
+ if (!algFits(alg, key)) throw new Error(`client_assertion_alg ${alg} does not fit this key`);
136
+ const header: Record<string, string> = { alg, typ: "JWT" };
137
+ if (signer.client_assertion_cert) {
138
+ const der = new crypto.X509Certificate(signer.client_assertion_cert).raw;
139
+ header["x5t#S256"] = b64url(crypto.createHash("sha256").update(der).digest());
140
+ header.x5t = b64url(crypto.createHash("sha1").update(der).digest());
141
+ }
142
+ header.kid = signer.client_assertion_kid ?? ownKid(signer.client_assertion_key) ?? jwkThumbprint(crypto.createPublicKey(key).export({ format: "jwk" }) as Jwk);
143
+ const iat = Math.floor(nowMs / 1000);
144
+ const claims = { iss: signer.client_id, sub: signer.client_id, aud: audience, jti: b64url(crypto.randomBytes(18)), iat, nbf: iat, exp: iat + 120 };
145
+ const input = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(claims))}`;
146
+ return `${input}.${b64url(signBytes(alg, Buffer.from(input), key))}`;
147
+ }
148
+
149
+ /** Why a signer's key material cannot work, or null. */
150
+ export function assertionKeyProblem(signer: { client_assertion_key?: string; client_assertion_alg?: unknown; client_assertion_cert?: string }): string | null {
151
+ if (!signer.client_assertion_key?.trim()) return "token_auth private_key_jwt needs a client_assertion_key (a private key, PEM or JWK)";
152
+ let key: crypto.KeyObject;
153
+ try {
154
+ key = readPrivateKey(signer.client_assertion_key);
155
+ algForKey(key);
156
+ } catch (err) {
157
+ return err instanceof Error ? err.message : String(err);
158
+ }
159
+ if (signer.client_assertion_alg !== undefined) {
160
+ const alg = String(signer.client_assertion_alg);
161
+ if (!(ASSERTION_ALGS as readonly string[]).includes(alg)) return `client_assertion_alg must be one of ${ASSERTION_ALGS.join(", ")}`;
162
+ // The alg must be one this key signs: an RSA key also signs PS256.
163
+ if (!algFits(alg as AssertionAlg, key)) return `client_assertion_alg ${alg} does not fit this key (a ${key.asymmetricKeyType} key signs ${algForKey(key)})`;
164
+ }
165
+ if (signer.client_assertion_cert) {
166
+ try {
167
+ const cert = new crypto.X509Certificate(signer.client_assertion_cert);
168
+ if (!cert.checkPrivateKey(key)) return "client_assertion_cert is not the certificate of client_assertion_key";
169
+ } catch {
170
+ return "client_assertion_cert is not a PEM certificate";
171
+ }
172
+ }
173
+ return null;
174
+ }
175
+
176
+ // ------------------------------------------------------------ the server's half
177
+
178
+ export interface VerifiedAssertion {
179
+ jti: string;
180
+ exp: number;
181
+ }
182
+
183
+ /**
184
+ * Check a client assertion presented to `audiences` (the endpoint URL, and
185
+ * the issuer — RFC 7523 §3 and its successor accept either). `keys` is the
186
+ * client's public JWKS. Throws with why; returns the jti and exp for the
187
+ * caller's replay store, which must refuse a jti it has seen before.
188
+ */
189
+ export function verifyClientAssertion(
190
+ assertion: string,
191
+ opts: { clientId: string; audiences: string[]; keys: Jwk[]; nowMs?: number; skewSeconds?: number },
192
+ ): VerifiedAssertion {
193
+ const parts = assertion.split(".");
194
+ if (parts.length !== 3) throw new Error("the client assertion is not a JWT");
195
+ let header: { alg?: string; kid?: string; typ?: string };
196
+ let claims: { iss?: unknown; sub?: unknown; aud?: unknown; exp?: unknown; iat?: unknown; nbf?: unknown; jti?: unknown };
197
+ try {
198
+ header = JSON.parse(Buffer.from(parts[0], "base64url").toString("utf8"));
199
+ claims = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
200
+ } catch {
201
+ throw new Error("the client assertion is not a JWT");
202
+ }
203
+ const alg = header.alg as AssertionAlg;
204
+ if (!(ASSERTION_ALGS as readonly string[]).includes(alg)) throw new Error(`the client assertion's alg ${JSON.stringify(header.alg)} is not accepted (${ASSERTION_ALGS.join(", ")})`);
205
+ // A key registered with an alg signs only that alg (RFC 7517 §4.4).
206
+ const candidates = opts.keys.filter((k) => (!header.kid || k.kid === header.kid) && k.use !== "enc" && (!k.alg || k.alg === alg));
207
+ if (!candidates.length) throw new Error(`no key${header.kid ? ` with kid ${header.kid}` : ""} in the client's JWKS`);
208
+ const input = Buffer.from(`${parts[0]}.${parts[1]}`);
209
+ const sig = Buffer.from(parts[2], "base64url");
210
+ const good = candidates.some((jwk) => {
211
+ try {
212
+ const key = crypto.createPublicKey({ key: jwk, format: "jwk" });
213
+ // The key's type must be the alg's: an RSA key cannot be presented as ES256.
214
+ if (!algFits(alg, key)) return false;
215
+ return verifyBytes(alg, input, sig, key);
216
+ } catch {
217
+ return false;
218
+ }
219
+ });
220
+ if (!good) throw new Error("the client assertion's signature does not verify against the client's keys");
221
+ if (claims.iss !== opts.clientId || claims.sub !== opts.clientId) throw new Error("the client assertion's iss and sub must be the client_id");
222
+ // Plain string comparison (RFC 7519 §4.1.3): a trailing slash is another audience.
223
+ const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
224
+ if (!aud.some((a) => typeof a === "string" && opts.audiences.includes(a))) throw new Error(`the client assertion is for ${JSON.stringify(claims.aud)}, not this server`);
225
+ const now = Math.floor((opts.nowMs ?? Date.now()) / 1000);
226
+ const skew = opts.skewSeconds ?? 60;
227
+ if (typeof claims.exp !== "number") throw new Error("the client assertion has no exp");
228
+ if (claims.exp + skew < now) throw new Error("the client assertion has expired");
229
+ if (typeof claims.nbf === "number" && claims.nbf - skew > now) throw new Error("the client assertion is not valid yet");
230
+ const issued = typeof claims.iat === "number" ? claims.iat : now;
231
+ if (claims.exp - issued > ASSERTION_MAX_SECONDS || claims.exp - now > ASSERTION_MAX_SECONDS + skew) throw new Error(`the client assertion lives longer than ${ASSERTION_MAX_SECONDS} seconds`);
232
+ if (typeof claims.jti !== "string" || !claims.jti) throw new Error("the client assertion has no jti");
233
+ return { jti: claims.jti, exp: claims.exp };
234
+ }
@@ -0,0 +1,204 @@
1
+ // Every kind of OAuth foldrun meets, in each role, and what foldrun does
2
+ // about it: supported (with the tests that prove it), planned, refused (with
3
+ // the reason a person sees), or not needed. The single source the customer
4
+ // page `foldrun-docs/oauth.md` is generated from (`node scripts/oauth-doc.mjs`)
5
+ // — the same pattern as frontmatter-fields.ts. tests/oauth-capabilities.test.ts
6
+ // fails when a supported row names a test that does not exist, or the page is
7
+ // stale.
8
+ //
9
+ // The roles:
10
+ // client an agent reaches Google, Slack, a bank — foldrun holds the token
11
+ // server an AI app signs in to /api/mcp — foldrun issues the token
12
+
13
+ export type OAuthRole = "client" | "server";
14
+ export type OAuthStatus = "supported" | "planned" | "refused" | "not-needed";
15
+
16
+ /** A test that proves a row: the repo, the file in it, and the test's name
17
+ * as written (a substring of the title is enough). */
18
+ export interface OAuthProof {
19
+ repo: "foldrun-core" | "foldrun-platform" | "foldrun-web" | "foldrun-cli";
20
+ file: string;
21
+ test: string;
22
+ }
23
+
24
+ export interface OAuthCapability {
25
+ id: string;
26
+ role: OAuthRole;
27
+ /** What it is, in a person's words. */
28
+ title: string;
29
+ /** The RFC or spec that defines it. */
30
+ spec: string;
31
+ status: OAuthStatus;
32
+ /** How to use it (supported), what is coming (planned), or why not. */
33
+ note: string;
34
+ proof?: OAuthProof[];
35
+ }
36
+
37
+ const core = (file: string, test: string): OAuthProof => ({ repo: "foldrun-core", file, test });
38
+ const platform = (file: string, test: string): OAuthProof => ({ repo: "foldrun-platform", file, test });
39
+ const web = (file: string, test: string): OAuthProof => ({ repo: "foldrun-web", file, test });
40
+ const cli = (file: string, test: string): OAuthProof => ({ repo: "foldrun-cli", file, test });
41
+ const KC = "tests/e2e-oauth-keycloak.test.ts";
42
+
43
+ export const OAUTH_CAPABILITIES: OAuthCapability[] = [
44
+ // ------------------------------------------------------------ client
45
+ {
46
+ id: "client.authorization-code-pkce", role: "client", title: "Authorization code with PKCE (S256)", spec: "RFC 6749 §4.1, RFC 7636",
47
+ status: "supported", note: "Settings → Connections or `foldrun connect`. PKCE is on for every connection.",
48
+ proof: [platform("tests/oauth-connect.test.ts", "PKCE: on by default"), platform(KC, "authorization code + PKCE S256 + PAR"), cli("tests/cli-connect.test.ts", "connect: PKCE by default")],
49
+ },
50
+ {
51
+ id: "client.refresh-rotation", role: "client", title: "Refresh, and refresh-token rotation", spec: "RFC 6749 §6, RFC 9700 §2.2.2",
52
+ status: "supported", note: "Refreshed before every use; a rotated refresh token replaces the stored one.",
53
+ proof: [core("tests/secret-types.test.ts", "a rotated refresh token is saved where the old one was"), core("tests/oauth-provider-fixtures.test.ts", "rotating refresh tokens: every refresh returns a new one"), platform(KC, "the refresh works and rotates")],
54
+ },
55
+ {
56
+ id: "client.client-credentials", role: "client", title: "Client credentials (machine to machine)", spec: "RFC 6749 §4.4",
57
+ status: "supported", note: "The \"OAuth — client credentials\" connection, or `foldrun secrets set NAME --kind m2m`.",
58
+ proof: [platform(KC, "client credentials (machine to machine)")],
59
+ },
60
+ {
61
+ id: "client.secret-post", role: "client", title: "Client secret in the form body", spec: "RFC 6749 §2.3.1",
62
+ status: "supported", note: "The default (`token_auth: client_secret_post`).",
63
+ proof: [core("tests/oauth-token.test.ts", "client_secret_post (and absent with a secret)"), platform(KC, "client_secret_post")],
64
+ },
65
+ {
66
+ id: "client.secret-basic", role: "client", title: "Client secret as HTTP Basic", spec: "RFC 6749 §2.3.1",
67
+ status: "supported", note: "`token_auth: client_secret_basic`, or `--token-auth basic`. Notion, Spotify, Reddit and Zoom need it.",
68
+ proof: [core("tests/oauth-token.test.ts", "a Basic-only token endpoint"), platform("tests/oauth-connect.test.ts", "token_auth client_secret_basic"), platform(KC, "client_secret_basic"), cli("tests/cli-connect.test.ts", "connect --token-auth basic")],
69
+ },
70
+ {
71
+ id: "client.public", role: "client", title: "Public app (no secret), PKCE only", spec: "RFC 9700 §2.1.1",
72
+ status: "supported", note: "Leave the client secret empty.",
73
+ proof: [platform("tests/oauth-connect.test.ts", "a public app (no secret) connects with PKCE"), platform(KC, "a public app (no secret)")],
74
+ },
75
+ {
76
+ id: "client.discovery", role: "client", title: "Metadata discovery from an issuer", spec: "RFC 8414, OpenID Connect Discovery",
77
+ status: "supported", note: "The Issuer field's Discover, or `--issuer`.",
78
+ proof: [core("tests/oauth-discovery-revoke.test.ts", "an RFC 8414 server"), core("tests/oauth-discovery-revoke.test.ts", "refused: a document naming another issuer"), platform(KC, "discovery (RFC 8414 / OIDC)"), cli("tests/cli-connect.test.ts", "connect --issuer: the metadata fills the URLs")],
79
+ },
80
+ {
81
+ id: "client.iss-parameter", role: "client", title: "Issuer in the authorization response (mix-up defence)", spec: "RFC 9207",
82
+ status: "supported", note: "With an issuer, the `iss` that comes back must be it.",
83
+ proof: [platform("tests/oauth-connect.test.ts", "iss: a pinned issuer must come back exactly"), platform(KC, "a callback whose iss is not the realm is refused"), cli("tests/cli-connect.test.ts", "a callback whose iss is another server is refused")],
84
+ },
85
+ {
86
+ id: "client.revocation", role: "client", title: "Revocation when a connection ends", spec: "RFC 7009",
87
+ status: "supported", note: "Deleting a connection, or reconnecting it with another app, revokes the old grant.",
88
+ proof: [core("tests/oauth-discovery-revoke.test.ts", "revoking: the refresh token, then the cached access token"), web("tests/secrets-revoke.test.ts", "deleting revokes"), platform(KC, "revocation (RFC 7009)")],
89
+ },
90
+ {
91
+ id: "client.par", role: "client", title: "Pushed authorization requests", spec: "RFC 9126",
92
+ status: "supported", note: "Used whenever the provider has a PAR endpoint.",
93
+ proof: [platform("tests/oauth-connect.test.ts", "PAR (RFC 9126)"), platform(KC, "authorization code + PKCE S256 + PAR"), cli("tests/cli-connect.test.ts", "with a PAR endpoint")],
94
+ },
95
+ {
96
+ id: "client.token-response", role: "client", title: "Token responses as providers send them", spec: "RFC 6749 §5.1",
97
+ status: "supported", note: "JSON or form-encoded, `expires_in` as a string, Slack's `authed_user`.",
98
+ proof: [core("tests/oauth-token.test.ts", "Slack: a user token alone"), core("tests/oauth-token.test.ts", "a form-encoded answer"), core("tests/oauth-provider-fixtures.test.ts", "no expires_in; instance_url the agent needs"), core("tests/oauth-provider-fixtures.test.ts", "form-encoded unless asked for JSON")],
99
+ },
100
+ {
101
+ id: "client.jwt-bearer", role: "client", title: "JWT bearer grant (service accounts)", spec: "RFC 7523 §2.1",
102
+ status: "supported", note: "The service-account connection (`--kind service-account`).",
103
+ proof: [core("tests/secret-types.test.ts", "service account with a subject")],
104
+ },
105
+ {
106
+ id: "client.rar", role: "client", title: "Rich authorization requests", spec: "RFC 9396",
107
+ status: "supported", note: "`authorization_details` as an extra authorize parameter (a JSON value).",
108
+ proof: [platform("tests/oauth-connect.test.ts", "authorization_details (RFC 9396)")],
109
+ },
110
+ {
111
+ id: "client.private-key-jwt", role: "client", title: "Signed client assertion (private_key_jwt)", spec: "RFC 7523 §2.2",
112
+ status: "supported", note: "`token_auth: private_key_jwt` with a key you paste or foldrun makes; a certificate adds Entra's thumbprint. `--token-auth jwt`.",
113
+ proof: [core("tests/oauth-assertion.test.ts", "an oauth2 secret with private_key_jwt refreshes"), platform("tests/oauth-connect.test.ts", "private_key_jwt: a saved app can have its key made"), platform(KC, "private_key_jwt (RFC 7523): code + PKCE + PAR signed"), cli("tests/cli-connect.test.ts", "connect --token-auth jwt --key-file")],
114
+ },
115
+ {
116
+ id: "client.device", role: "client", title: "Device authorization", spec: "RFC 8628",
117
+ status: "planned", note: "Part F: `foldrun connect --device`.",
118
+ },
119
+ {
120
+ 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
+ },
123
+ {
124
+ id: "client.dpop", role: "client", title: "DPoP sender-constrained tokens", spec: "RFC 9449",
125
+ status: "planned", note: "Part H: Bluesky/atproto, Okta DPoP.",
126
+ },
127
+ {
128
+ 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.",
130
+ },
131
+ {
132
+ id: "client.ciba", role: "client", title: "Client-initiated backchannel authentication", spec: "OpenID CIBA",
133
+ status: "not-needed", note: "No provider an agent needs uses it.",
134
+ },
135
+ // ------------------------------------------------------------ server (/api/mcp)
136
+ {
137
+ id: "server.authorization-code-pkce", role: "server", title: "Authorization code with PKCE (S256)", spec: "OAuth 2.1, MCP authorization",
138
+ status: "supported", note: "How claude.ai and other AI apps sign in to /api/mcp.",
139
+ proof: [web("tests/mcp-oauth.test.ts", "the code: PKCE, once, for its own app and redirect"), web("tests/mcp-sdk-oauth.test.ts", "the MCP SDK: 401 → discovery → registration")],
140
+ },
141
+ {
142
+ id: "server.refresh-rotation", role: "server", title: "Refresh rotation, reuse revokes", spec: "OAuth 2.1 §4.3.1",
143
+ status: "supported", note: "A refresh token used twice ends the connection.",
144
+ proof: [web("tests/mcp-oauth.test.ts", "refresh rotates; a refresh token used twice ends the connection")],
145
+ },
146
+ {
147
+ id: "server.client-auth", role: "server", title: "Client secret (Basic or body), or a public app", spec: "RFC 6749 §2.3.1",
148
+ status: "supported", note: "Registered apps choose `none`, `client_secret_post` or `client_secret_basic`.",
149
+ proof: [web("tests/mcp-oauth.test.ts", "a confidential app proves itself with its secret")],
150
+ },
151
+ {
152
+ id: "server.metadata", role: "server", title: "Authorization server and resource metadata", spec: "RFC 8414, RFC 9728",
153
+ status: "supported", note: "`/.well-known/oauth-authorization-server` and `/.well-known/oauth-protected-resource`.",
154
+ proof: [web("tests/mcp-oauth.test.ts", "discovery: the resource names its server")],
155
+ },
156
+ {
157
+ id: "server.registration", role: "server", title: "Dynamic registration and client metadata documents", spec: "RFC 7591, CIMD",
158
+ status: "supported", note: "An app registers itself, or names itself by an https URL.",
159
+ proof: [web("tests/mcp-oauth.test.ts", "registration: https or loopback redirect URIs only")],
160
+ },
161
+ {
162
+ id: "server.resource-indicators", role: "server", title: "Resource indicators", spec: "RFC 8707",
163
+ status: "supported", note: "Tokens are bound to /api/mcp.",
164
+ proof: [web("tests/mcp-oauth.test.ts", "the access token opens /api/mcp and its tools")],
165
+ },
166
+ {
167
+ id: "server.iss-parameter", role: "server", title: "Issuer in the authorization response", spec: "RFC 9207",
168
+ status: "supported", note: "Every answer carries `iss`.",
169
+ proof: [web("tests/mcp-oauth.test.ts", "the approval page's request")],
170
+ },
171
+ {
172
+ id: "server.revocation", role: "server", title: "Revocation endpoint", spec: "RFC 7009",
173
+ status: "supported", note: "`POST /api/mcp/oauth/revoke`: an app ends its own connection.",
174
+ proof: [web("tests/mcp-oauth.test.ts", "revocation (RFC 7009): advertised")],
175
+ },
176
+ {
177
+ 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.",
179
+ },
180
+ {
181
+ id: "server.private-key-jwt", role: "server", title: "Signed client assertion (private_key_jwt)", spec: "RFC 7523 §2.2",
182
+ status: "supported", note: "Apps that register `jwks` or `jwks_uri` sign in with assertions; each `jti` is used once.",
183
+ proof: [web("tests/mcp-oauth.test.ts", "private_key_jwt (RFC 7523): an app with a JWKS signs in"), core("tests/oauth-assertion.test.ts", "the verifier refuses")],
184
+ },
185
+ // ------------------------------------------------------------ refused, both roles
186
+ {
187
+ id: "refused.implicit", role: "client", title: "Implicit grant", spec: "RFC 9700 §2.1.2",
188
+ status: "refused", note: "Tokens in the browser's URL leak; OAuth 2.1 removes it.",
189
+ },
190
+ {
191
+ id: "refused.password", role: "client", title: "Resource owner password grant", spec: "RFC 9700 §2.4",
192
+ status: "refused", note: "foldrun never holds a person's password for another service.",
193
+ },
194
+ {
195
+ id: "refused.pkce-plain", role: "server", title: "PKCE `plain`", spec: "RFC 9700 §2.1.1",
196
+ status: "refused", note: "Only S256 is accepted: `plain` protects nothing if the request is seen.",
197
+ proof: [web("tests/mcp-oauth.test.ts", "the approval page's request")],
198
+ },
199
+ {
200
+ id: "refused.wildcard-redirect", role: "server", title: "Wildcard or prefix redirect matching", spec: "RFC 9700 §4.1",
201
+ status: "refused", note: "Redirect URIs match exactly (a loopback port may vary).",
202
+ proof: [web("tests/mcp-oauth.test.ts", "the approval page's request")],
203
+ },
204
+ ];
@@ -13,10 +13,14 @@
13
13
  // base64 of the raw pair, which breaks on a secret
14
14
  // holding ':' or '%'
15
15
  // none a public client: client_id in the body, no secret
16
+ // private_key_jwt a JWT signed with the client's private key, no
17
+ // secret at all (RFC 7523 §2.2, oauth-assertion.ts)
16
18
  //
17
19
  // Absent means the old behaviour: the body, with the secret if there is one.
18
20
 
19
- export const TOKEN_AUTH_METHODS = ["client_secret_post", "client_secret_basic", "none"] as const;
21
+ import { ASSERTION_TYPE, assertionKeyProblem, signClientAssertion, type AssertionAlg } from "./oauth-assertion.ts";
22
+
23
+ export const TOKEN_AUTH_METHODS = ["client_secret_post", "client_secret_basic", "none", "private_key_jwt"] as const;
20
24
  export type TokenAuth = (typeof TOKEN_AUTH_METHODS)[number];
21
25
 
22
26
  /** The short spellings a person types (`--token-auth basic`), to the RFC names. */
@@ -25,6 +29,7 @@ export function parseTokenAuth(value: string): TokenAuth | null {
25
29
  if ((TOKEN_AUTH_METHODS as readonly string[]).includes(v)) return v as TokenAuth;
26
30
  if (v === "post" || v === "body") return "client_secret_post";
27
31
  if (v === "basic") return "client_secret_basic";
32
+ if (v === "jwt" || v === "private-key-jwt" || v === "key") return "private_key_jwt";
28
33
  return null;
29
34
  }
30
35
 
@@ -32,10 +37,16 @@ export interface TokenClient {
32
37
  client_id: string;
33
38
  client_secret?: string;
34
39
  token_auth?: TokenAuth;
40
+ /** private_key_jwt: the private key (PEM or JWK), its kid and alg, and
41
+ * its certificate when the provider names keys by it (oauth-assertion.ts). */
42
+ client_assertion_key?: string;
43
+ client_assertion_kid?: string;
44
+ client_assertion_alg?: AssertionAlg;
45
+ client_assertion_cert?: string;
35
46
  }
36
47
 
37
48
  /** Why a client's token_auth cannot work, or null. */
38
- export function tokenAuthProblem(client: { client_secret?: string; token_auth?: unknown }): string | 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 {
39
50
  const auth = client.token_auth;
40
51
  if (auth === undefined) return null;
41
52
  if (typeof auth !== "string" || !(TOKEN_AUTH_METHODS as readonly string[]).includes(auth)) {
@@ -45,6 +56,11 @@ export function tokenAuthProblem(client: { client_secret?: string; token_auth?:
45
56
  if (auth === "client_secret_basic" && !hasSecret) return "token_auth client_secret_basic needs a client_secret";
46
57
  if (auth === "client_secret_post" && !hasSecret) return "token_auth client_secret_post needs a client_secret — a public app is token_auth none";
47
58
  if (auth === "none" && hasSecret) return "token_auth none is a public client — it sends no client_secret; leave the secret empty or pick client_secret_post or client_secret_basic";
59
+ if (auth === "private_key_jwt") {
60
+ // One method per request: a signed assertion, never a secret beside it.
61
+ if (hasSecret) return "token_auth private_key_jwt signs with a key and sends no client_secret — leave the secret empty";
62
+ return assertionKeyProblem(client);
63
+ }
48
64
  return null;
49
65
  }
50
66
 
@@ -60,12 +76,18 @@ const formEncode = (s: string) => new URLSearchParams({ x: s }).toString().slice
60
76
  * pushed authorization request carries it as a parameter of the request it
61
77
  * describes even when the client authenticates with Basic (RFC 9126 §2.1).
62
78
  */
63
- export function oauthTokenRequest(client: TokenClient, params: Record<string, string>, opts: { clientIdInBody?: boolean } = {}): { headers: Record<string, string>; body: string } {
79
+ export function oauthTokenRequest(
80
+ client: TokenClient,
81
+ params: Record<string, string>,
82
+ /** `audience`: the endpoint the request goes to — the `aud` of a
83
+ * private_key_jwt assertion (required for that method). */
84
+ opts: { clientIdInBody?: boolean; audience?: string } = {},
85
+ ): { headers: Record<string, string>; body: string } {
64
86
  const problem = tokenAuthProblem(client);
65
87
  if (problem) throw new Error(problem);
66
88
  const form = new URLSearchParams();
67
89
  for (const [k, v] of Object.entries(params)) {
68
- if (k === "client_id" || k === "client_secret") continue;
90
+ if (k === "client_id" || k === "client_secret" || k === "client_assertion" || k === "client_assertion_type") continue;
69
91
  form.set(k, v);
70
92
  }
71
93
  const headers: Record<string, string> = {
@@ -80,6 +102,11 @@ export function oauthTokenRequest(client: TokenClient, params: Record<string, st
80
102
  const pair = `${formEncode(client.client_id)}:${formEncode(client.client_secret!)}`;
81
103
  headers.authorization = `Basic ${Buffer.from(pair, "utf8").toString("base64")}`;
82
104
  if (opts.clientIdInBody) form.set("client_id", client.client_id);
105
+ } else if (auth === "private_key_jwt") {
106
+ if (!opts.audience) throw new Error("a private_key_jwt request needs the endpoint it goes to (its assertion's aud)");
107
+ form.set("client_id", client.client_id);
108
+ form.set("client_assertion_type", ASSERTION_TYPE);
109
+ form.set("client_assertion", signClientAssertion(client as TokenClient & { client_assertion_key: string }, opts.audience));
83
110
  } else {
84
111
  form.set("client_id", client.client_id);
85
112
  if (auth === "client_secret_post") form.set("client_secret", client.client_secret!);