@foldrun/core 9.3.0 → 9.5.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.
@@ -24,6 +24,7 @@ import { dataRoot } from "./paths.js";
24
24
  import { platform } from "./platform.js";
25
25
  import { fetchUntrusted } from "./untrusted-fetch.js";
26
26
  import { oauthTokenRequest, readTokenResponse, tokenAuthProblem } from "./oauth-token.js";
27
+ import { isSecureUrl } from "./oauth-discovery.js";
27
28
  /**
28
29
  * GCM's auth tag, pinned to its full 16 bytes at every cipher call. Without
29
30
  * `authTagLength`, Node accepts whatever length `setAuthTag` is handed — 4, 8,
@@ -822,12 +823,15 @@ export function setOAuth2Secret(tenant, name, config, workspace, limit = {}) {
822
823
  if (config.grant_type !== "client_credentials" && !config.refresh_token?.trim()) {
823
824
  throw new Error("oauth2 secret needs refresh_token (or grant_type: client_credentials)");
824
825
  }
826
+ if (config.revocation_url !== undefined && (typeof config.revocation_url !== "string" || !isSecureUrl(config.revocation_url))) {
827
+ throw new Error("revocation_url must be https");
828
+ }
825
829
  const authProblem = tokenAuthProblem(config);
826
830
  if (authProblem)
827
831
  throw new Error(`oauth2 secret: ${authProblem}`);
828
832
  // Loopback is exempt: a token endpoint on this same machine (tests, a
829
833
  // local mock, a sidecar) crosses no wire for http to leak on.
830
- if (!/^https:\/\//.test(config.token_url) && !/^http:\/\/(127\.0\.0\.1|localhost)[:/]/.test(config.token_url)) {
834
+ if (!isSecureUrl(config.token_url)) {
831
835
  throw new Error("token_url must be https — a refresh token over http is a leaked one");
832
836
  }
833
837
  setSecret(tenant, name, OAUTH2_PREFIX + JSON.stringify(config), workspace, "oauth2", limit);
@@ -961,6 +965,129 @@ async function exchange(config, cacheKey) {
961
965
  oauthFlight.delete(cacheKey);
962
966
  }
963
967
  }
968
+ // ---------------------------------------------------------------- revocation
969
+ /** The oauth2 recipe stored at exactly this scope — never the account copy a
970
+ * workspace would fall back to: revoking is about the copy being removed. */
971
+ export function oauth2ValueAt(tenant, name, workspace) {
972
+ const v = openRecord(tenant, workspace, name, read(tenant, workspace)[name]);
973
+ return v !== null && isOAuth2Value(v) ? v : null;
974
+ }
975
+ /** The workspaces with a vault file of their own (current and legacy layout). */
976
+ function workspacesWithSecrets(tenant) {
977
+ const out = new Set();
978
+ for (const dir of ["workspaces", "projects"]) {
979
+ const base = path.join(dataRoot(), tenant, dir);
980
+ let names = [];
981
+ try {
982
+ names = fs.readdirSync(base);
983
+ }
984
+ catch {
985
+ continue;
986
+ }
987
+ for (const n of names)
988
+ if (fs.existsSync(path.join(base, n, "secrets.json")))
989
+ out.add(n);
990
+ }
991
+ return [...out];
992
+ }
993
+ /**
994
+ * Another stored secret of this account holds a grant revoking this one could
995
+ * end: the same refresh token (a copy made by hand, or one rotation reached),
996
+ * or any grant from the same app (token URL and client id). Providers such as
997
+ * Google revoke every token of a user's grant to an app together, so deleting
998
+ * the Ads connection would take down the Search Console one made with the
999
+ * same app (Codex review, 1 Nov 2026). Such a grant is left alone.
1000
+ */
1001
+ export function oauthGrantSharedElsewhere(tenant, name, workspace, refreshToken, app) {
1002
+ for (const scope of [undefined, ...workspacesWithSecrets(tenant)]) {
1003
+ for (const [other, rec] of Object.entries(read(tenant, scope))) {
1004
+ if (other === name && scope === workspace)
1005
+ continue;
1006
+ // By the value, not the optional kind tag: a recipe copied with a
1007
+ // plain setSecret is still one materializeSecrets refreshes.
1008
+ const v = openRecord(tenant, scope, other, rec);
1009
+ if (!v || !isOAuth2Value(v))
1010
+ continue;
1011
+ try {
1012
+ const other = JSON.parse(v.slice(OAUTH2_PREFIX.length));
1013
+ if (other.refresh_token === refreshToken)
1014
+ return true;
1015
+ if (app && other.grant_type !== "client_credentials" && other.token_url === app.token_url && other.client_id === app.client_id)
1016
+ return true;
1017
+ }
1018
+ catch {
1019
+ continue;
1020
+ }
1021
+ }
1022
+ }
1023
+ return false;
1024
+ }
1025
+ /**
1026
+ * Revoke what an oauth2 recipe holds at its provider (RFC 7009): the refresh
1027
+ * token, then the access token this process has cached for it. The client
1028
+ * authenticates as at the token URL (its token_auth). A 200 is success —
1029
+ * the RFC answers 200 for a token already invalid too; anything else is
1030
+ * reported in `error`, and the next token is still tried. `replacement`: the
1031
+ * recipe taking this one's place; one from the same app (token URL and client
1032
+ * id) means nothing is revoked, since a provider that revokes per app would
1033
+ * end the new grant with the old.
1034
+ */
1035
+ export async function revokeOAuth2Value(value, opts = {}) {
1036
+ let config;
1037
+ try {
1038
+ config = JSON.parse(value.slice(OAUTH2_PREFIX.length));
1039
+ }
1040
+ catch {
1041
+ return { skipped: "unreadable", revoked: [] };
1042
+ }
1043
+ if (!config.revocation_url)
1044
+ return { skipped: "no-revocation-url", revoked: [] };
1045
+ if (opts.replacement && opts.replacement.token_url === config.token_url && opts.replacement.client_id === config.client_id) {
1046
+ return { skipped: "same-app", revoked: [] };
1047
+ }
1048
+ if (config.grant_type !== "client_credentials" && opts.tenant && opts.name
1049
+ && oauthGrantSharedElsewhere(opts.tenant, opts.name, opts.workspace, config.refresh_token ?? "", { token_url: config.token_url, client_id: config.client_id })) {
1050
+ return { skipped: "shared-grant", revoked: [] };
1051
+ }
1052
+ // No redirect is followed: a 3xx is not ok, so a revocation endpoint that
1053
+ // sends us to a login page is reported, never counted as revoked.
1054
+ const fetcher = opts.fetcher ?? ((url, init) => fetchUntrusted(url, init, { sameOrigin: true, noRedirects: true }));
1055
+ const key = tokenCacheKey("oauth2", value, opts.tenant);
1056
+ const access = oauthCache.get(key)?.token;
1057
+ const tokens = [];
1058
+ if (config.grant_type !== "client_credentials" && config.refresh_token)
1059
+ tokens.push(["refresh_token", config.refresh_token]);
1060
+ if (access)
1061
+ tokens.push(["access_token", access]);
1062
+ const out = { revoked: [] };
1063
+ for (const [hint, token] of tokens) {
1064
+ try {
1065
+ const request = oauthTokenRequest(config, { token, token_type_hint: hint });
1066
+ const res = await fetcher(config.revocation_url, { method: "POST", headers: request.headers, body: request.body, redirect: "error", signal: AbortSignal.timeout(10_000) });
1067
+ if (res.ok) {
1068
+ await res.body?.cancel().catch(() => { });
1069
+ out.revoked.push(hint);
1070
+ }
1071
+ else {
1072
+ // Reported, and the next token is still tried: a provider that will
1073
+ // not revoke refresh tokens may still revoke the access token.
1074
+ const why = readTokenResponse(await res.text().catch(() => ""), res.headers.get("content-type") ?? "");
1075
+ out.error ??= `the provider refused to revoke the ${hint.replace("_", " ")} (${res.status}${why.error ? `: ${why.error_description ?? why.error}` : ""})`;
1076
+ }
1077
+ }
1078
+ catch (err) {
1079
+ out.error ??= `revoking the ${hint.replace("_", " ")} failed: ${err instanceof Error ? err.message : String(err)}`;
1080
+ }
1081
+ }
1082
+ // Every entry serving that access token goes: after a rotation the same
1083
+ // token is cached under the recipe before and after it (Codex review).
1084
+ oauthCache.delete(key);
1085
+ if (access)
1086
+ for (const [k, v] of oauthCache)
1087
+ if (v.token === access)
1088
+ oauthCache.delete(k);
1089
+ return out;
1090
+ }
964
1091
  /**
965
1092
  * Swap every oauth2 recipe in an env map for a live access token. The one
966
1093
  * async moment in secret resolution, kept out of the sync paths: callers
@@ -24,4 +24,8 @@ export declare function fetchUntrusted(url: string, init?: RequestInit, opts?: {
24
24
  sameOrigin?: boolean;
25
25
  /** The most the answer may be, read; MAX_BYTES unless said. */
26
26
  maxBytes?: number;
27
+ /** Follow no redirect at all: a 3xx comes back as the answer, for the
28
+ * caller to treat as the failure it is (a revocation that redirects to
29
+ * a login page did not revoke anything). */
30
+ noRedirects?: boolean;
27
31
  }): Promise<Response>;
@@ -93,7 +93,7 @@ export async function fetchUntrusted(url, init = {}, opts = {}) {
93
93
  for (let hop = 0;; hop++) {
94
94
  const res = await platform.fetchUntrusted(current, { ...req, redirect: "manual" }, { maxBytes });
95
95
  const location = REDIRECT.has(res.status) ? res.headers.get("location") : null;
96
- if (!location)
96
+ if (!location || opts.noRedirects)
97
97
  return capBody(res, maxBytes, current);
98
98
  // The body of a redirect is never read; free the socket.
99
99
  await res.body?.cancel().catch(() => { });
package/index.ts CHANGED
@@ -43,6 +43,7 @@ export * from "./src/storage.ts";
43
43
  export * from "./src/secrets.ts";
44
44
  export * from "./src/oauth-presets.ts";
45
45
  export * from "./src/oauth-token.ts";
46
+ export * from "./src/oauth-discovery.ts";
46
47
  export * from "./src/secret-files.ts";
47
48
  export * from "./src/notify.ts";
48
49
  export * from "./src/webhook.ts";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldrun/core",
3
- "version": "9.3.0",
3
+ "version": "9.5.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,202 @@
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: "planned", note: "Part E: Entra certificates, Okta, FAPI.",
113
+ },
114
+ {
115
+ id: "client.device", role: "client", title: "Device authorization", spec: "RFC 8628",
116
+ status: "planned", note: "Part F: `foldrun connect --device`.",
117
+ },
118
+ {
119
+ id: "client.token-exchange", role: "client", title: "Token exchange", spec: "RFC 8693",
120
+ status: "planned", note: "Part I: a `token-exchange` secret kind. Cloud STS is covered today by the keyless identity kind.",
121
+ },
122
+ {
123
+ id: "client.dpop", role: "client", title: "DPoP sender-constrained tokens", spec: "RFC 9449",
124
+ status: "planned", note: "Part H: Bluesky/atproto, Okta DPoP.",
125
+ },
126
+ {
127
+ id: "client.mtls", role: "client", title: "Mutual-TLS client auth and bound tokens", spec: "RFC 8705",
128
+ status: "planned", note: "Part H: the client-cert secret at the token endpoint.",
129
+ },
130
+ {
131
+ id: "client.ciba", role: "client", title: "Client-initiated backchannel authentication", spec: "OpenID CIBA",
132
+ status: "not-needed", note: "No provider an agent needs uses it.",
133
+ },
134
+ // ------------------------------------------------------------ server (/api/mcp)
135
+ {
136
+ id: "server.authorization-code-pkce", role: "server", title: "Authorization code with PKCE (S256)", spec: "OAuth 2.1, MCP authorization",
137
+ status: "supported", note: "How claude.ai and other AI apps sign in to /api/mcp.",
138
+ 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")],
139
+ },
140
+ {
141
+ id: "server.refresh-rotation", role: "server", title: "Refresh rotation, reuse revokes", spec: "OAuth 2.1 §4.3.1",
142
+ status: "supported", note: "A refresh token used twice ends the connection.",
143
+ proof: [web("tests/mcp-oauth.test.ts", "refresh rotates; a refresh token used twice ends the connection")],
144
+ },
145
+ {
146
+ id: "server.client-auth", role: "server", title: "Client secret (Basic or body), or a public app", spec: "RFC 6749 §2.3.1",
147
+ status: "supported", note: "Registered apps choose `none`, `client_secret_post` or `client_secret_basic`.",
148
+ proof: [web("tests/mcp-oauth.test.ts", "a confidential app proves itself with its secret")],
149
+ },
150
+ {
151
+ id: "server.metadata", role: "server", title: "Authorization server and resource metadata", spec: "RFC 8414, RFC 9728",
152
+ status: "supported", note: "`/.well-known/oauth-authorization-server` and `/.well-known/oauth-protected-resource`.",
153
+ proof: [web("tests/mcp-oauth.test.ts", "discovery: the resource names its server")],
154
+ },
155
+ {
156
+ id: "server.registration", role: "server", title: "Dynamic registration and client metadata documents", spec: "RFC 7591, CIMD",
157
+ status: "supported", note: "An app registers itself, or names itself by an https URL.",
158
+ proof: [web("tests/mcp-oauth.test.ts", "registration: https or loopback redirect URIs only")],
159
+ },
160
+ {
161
+ id: "server.resource-indicators", role: "server", title: "Resource indicators", spec: "RFC 8707",
162
+ status: "supported", note: "Tokens are bound to /api/mcp.",
163
+ proof: [web("tests/mcp-oauth.test.ts", "the access token opens /api/mcp and its tools")],
164
+ },
165
+ {
166
+ id: "server.iss-parameter", role: "server", title: "Issuer in the authorization response", spec: "RFC 9207",
167
+ status: "supported", note: "Every answer carries `iss`.",
168
+ proof: [web("tests/mcp-oauth.test.ts", "the approval page's request")],
169
+ },
170
+ {
171
+ id: "server.revocation", role: "server", title: "Revocation endpoint", spec: "RFC 7009",
172
+ status: "supported", note: "`POST /api/mcp/oauth/revoke`: an app ends its own connection.",
173
+ proof: [web("tests/mcp-oauth.test.ts", "revocation (RFC 7009): advertised")],
174
+ },
175
+ {
176
+ id: "server.client-credentials", role: "server", title: "Client credentials for machine clients", spec: "RFC 6749 §4.4",
177
+ status: "planned", note: "Part G: machine MCP clients an admin registers.",
178
+ },
179
+ {
180
+ id: "server.private-key-jwt", role: "server", title: "Signed client assertion (private_key_jwt)", spec: "RFC 7523 §2.2",
181
+ status: "planned", note: "Part E: apps that declare `jwks` or `jwks_uri`.",
182
+ },
183
+ // ------------------------------------------------------------ refused, both roles
184
+ {
185
+ id: "refused.implicit", role: "client", title: "Implicit grant", spec: "RFC 9700 §2.1.2",
186
+ status: "refused", note: "Tokens in the browser's URL leak; OAuth 2.1 removes it.",
187
+ },
188
+ {
189
+ id: "refused.password", role: "client", title: "Resource owner password grant", spec: "RFC 9700 §2.4",
190
+ status: "refused", note: "foldrun never holds a person's password for another service.",
191
+ },
192
+ {
193
+ id: "refused.pkce-plain", role: "server", title: "PKCE `plain`", spec: "RFC 9700 §2.1.1",
194
+ status: "refused", note: "Only S256 is accepted: `plain` protects nothing if the request is seen.",
195
+ proof: [web("tests/mcp-oauth.test.ts", "the approval page's request")],
196
+ },
197
+ {
198
+ id: "refused.wildcard-redirect", role: "server", title: "Wildcard or prefix redirect matching", spec: "RFC 9700 §4.1",
199
+ status: "refused", note: "Redirect URIs match exactly (a loopback port may vary).",
200
+ proof: [web("tests/mcp-oauth.test.ts", "the approval page's request")],
201
+ },
202
+ ];
@@ -0,0 +1,208 @@
1
+ // Discovery from an issuer: give foldrun the provider's issuer URL and it
2
+ // reads the authorization server's own metadata (RFC 8414, OpenID Connect
3
+ // Discovery 1.0) instead of a person typing the authorize, token and
4
+ // revocation URLs by hand — where a typo costs a consent, and an endpoint
5
+ // on the wrong host is a mix-up attack waiting to happen.
6
+ //
7
+ // Where the metadata lives, in the order tried (RFC 8414 §3.1 and §5):
8
+ // https://host/.well-known/oauth-authorization-server{/path}
9
+ // https://host/.well-known/openid-configuration{/path}
10
+ // https://host{/path}/.well-known/openid-configuration (OIDC's own spelling)
11
+ // The document's `issuer` must equal the issuer asked for, exactly (RFC 8414
12
+ // §3.3) — a document that names another issuer is refused, not trusted.
13
+
14
+ import type { TokenAuth } from "./oauth-token.ts";
15
+
16
+ /** What foldrun reads from a metadata document. */
17
+ export interface OAuthServerMetadata {
18
+ issuer: string;
19
+ authorization_endpoint?: string;
20
+ token_endpoint?: string;
21
+ revocation_endpoint?: string;
22
+ device_authorization_endpoint?: string;
23
+ pushed_authorization_request_endpoint?: string;
24
+ require_pushed_authorization_requests?: boolean;
25
+ token_endpoint_auth_methods_supported?: string[];
26
+ code_challenge_methods_supported?: string[];
27
+ authorization_response_iss_parameter_supported?: boolean;
28
+ dpop_signing_alg_values_supported?: string[];
29
+ scopes_supported?: string[];
30
+ jwks_uri?: string;
31
+ }
32
+
33
+ /** A discovered server, as the fields a saved app and an oauth2 secret take. */
34
+ export interface DiscoveredClientConfig {
35
+ issuer: string;
36
+ authorize_url?: string;
37
+ token_url: string;
38
+ revocation_url?: string;
39
+ device_url?: string;
40
+ par_url?: string;
41
+ /** The server requires PAR: an authorize URL without a request_uri fails. */
42
+ par_required?: boolean;
43
+ /** Whether the server sends `iss` with every authorization response
44
+ * (RFC 9207): when true, one that comes back without it is refused. */
45
+ iss_parameter: boolean;
46
+ /** How the client should authenticate at the token URL, given whether it
47
+ * has a secret and what the server supports; absent is the form body. */
48
+ token_auth?: TokenAuth;
49
+ /** Where the metadata was read from. */
50
+ metadata_url: string;
51
+ metadata: OAuthServerMetadata;
52
+ }
53
+
54
+ /** Loopback is exempt: a server on this machine (tests, a local Keycloak)
55
+ * crosses no wire for http to leak on. Judged on the parsed URL, never on
56
+ * how it starts: `http://localhost:80@evil.example` is evil.example
57
+ * (Codex review, 1 Nov 2026). */
58
+ export function isSecureUrl(url: string): boolean {
59
+ let u: URL;
60
+ try {
61
+ u = new URL(url);
62
+ } catch {
63
+ return false;
64
+ }
65
+ if (u.username || u.password) return false;
66
+ if (u.protocol === "https:") return true;
67
+ return u.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(u.hostname);
68
+ }
69
+
70
+ /** Every URL the metadata may be at, in the order to try. */
71
+ export function metadataUrls(issuer: string): string[] {
72
+ const u = new URL(issuer);
73
+ const pathPart = u.pathname.replace(/\/+$/, "");
74
+ const origin = u.origin;
75
+ const urls = [
76
+ `${origin}/.well-known/oauth-authorization-server${pathPart}`,
77
+ `${origin}/.well-known/openid-configuration${pathPart}`,
78
+ ];
79
+ if (pathPart) urls.push(`${origin}${pathPart}/.well-known/openid-configuration`);
80
+ return [...new Set(urls)];
81
+ }
82
+
83
+ /** Why an issuer cannot be discovered from, or null. */
84
+ export function issuerProblem(issuer: string): string | null {
85
+ let u: URL;
86
+ try {
87
+ u = new URL(issuer);
88
+ } catch {
89
+ return "issuer must be a URL, like https://login.example.com";
90
+ }
91
+ if (!isSecureUrl(issuer)) return "issuer must be https";
92
+ if (u.search || u.hash) return "issuer has no query or fragment (RFC 8414 §2)";
93
+ return null;
94
+ }
95
+
96
+ type Fetcher = (url: string, init?: RequestInit) => Promise<Response>;
97
+
98
+ /**
99
+ * Read the authorization server's metadata. `fetcher` is how the caller
100
+ * reaches the network (the platform's SSRF-guarded fetchPublic, the CLI's
101
+ * fetch). Throws with every place tried when none answers.
102
+ */
103
+ export async function fetchOAuthMetadata(issuer: string, fetcher: Fetcher): Promise<{ url: string; metadata: OAuthServerMetadata }> {
104
+ const problem = issuerProblem(issuer);
105
+ if (problem) throw new Error(problem);
106
+ const tried: string[] = [];
107
+ for (const url of metadataUrls(issuer)) {
108
+ let res: Response;
109
+ try {
110
+ res = await fetcher(url, { headers: { accept: "application/json" }, redirect: "error", signal: AbortSignal.timeout(10_000) });
111
+ } catch (err) {
112
+ tried.push(`${url} (${err instanceof Error ? err.message : String(err)})`);
113
+ continue;
114
+ }
115
+ if (!res.ok) {
116
+ await res.body?.cancel().catch(() => {});
117
+ tried.push(`${url} (${res.status})`);
118
+ continue;
119
+ }
120
+ let doc: unknown;
121
+ try {
122
+ doc = await res.json();
123
+ } catch {
124
+ tried.push(`${url} (not JSON)`);
125
+ continue;
126
+ }
127
+ if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
128
+ tried.push(`${url} (not a metadata document)`);
129
+ continue;
130
+ }
131
+ const metadata = doc as OAuthServerMetadata;
132
+ // RFC 8414 §3.3: the issuer in the document is the one asked for, or
133
+ // the document is not used. Trailing-slash differences are not forgiven.
134
+ if (metadata.issuer !== issuer) {
135
+ throw new Error(`the metadata at ${url} names the issuer ${JSON.stringify(metadata.issuer ?? null)}, not ${issuer} — refused (RFC 8414 §3.3)`);
136
+ }
137
+ return { url, metadata };
138
+ }
139
+ throw new Error(`no OAuth metadata for ${issuer} — tried ${tried.join(", ")}`);
140
+ }
141
+
142
+ const ENDPOINTS = [
143
+ "authorization_endpoint", "token_endpoint", "revocation_endpoint",
144
+ "device_authorization_endpoint", "pushed_authorization_request_endpoint", "jwks_uri",
145
+ ] as const;
146
+
147
+ /**
148
+ * What a client config should say for this server. `hasSecret`: the app
149
+ * has a client secret; `pkce`: false when the caller turned PKCE off;
150
+ * `tokenAuth`: one the caller chose, which wins over the server's list.
151
+ */
152
+ export function clientConfigFromMetadata(
153
+ found: { url: string; metadata: OAuthServerMetadata },
154
+ opts: { hasSecret: boolean; pkce?: boolean; tokenAuth?: TokenAuth; needsAuthorize?: boolean } = { hasSecret: true },
155
+ ): DiscoveredClientConfig {
156
+ const m = found.metadata;
157
+ for (const k of ENDPOINTS) {
158
+ const v = m[k];
159
+ if (v !== undefined && (typeof v !== "string" || !isSecureUrl(v))) throw new Error(`the server's ${k} is not an https URL: ${String(v)}`);
160
+ }
161
+ if (!m.token_endpoint) throw new Error(`${m.issuer} publishes no token_endpoint`);
162
+ if ((opts.needsAuthorize ?? true) && !m.authorization_endpoint) throw new Error(`${m.issuer} publishes no authorization_endpoint — it does not do browser sign-in`);
163
+ // PKCE S256 is required of every connection (RFC 9700 §2.1.1). A server
164
+ // that lists its methods and leaves S256 out would ignore our challenge.
165
+ if (opts.pkce !== false && Array.isArray(m.code_challenge_methods_supported) && !m.code_challenge_methods_supported.includes("S256")) {
166
+ throw new Error(`${m.issuer} does not support PKCE S256 (it lists ${m.code_challenge_methods_supported.join(", ") || "none"}) — refused; turn PKCE off only if you are sure (--no-pkce)`);
167
+ }
168
+ return {
169
+ issuer: m.issuer,
170
+ ...(m.authorization_endpoint ? { authorize_url: m.authorization_endpoint } : {}),
171
+ token_url: m.token_endpoint,
172
+ ...(m.revocation_endpoint ? { revocation_url: m.revocation_endpoint } : {}),
173
+ ...(m.device_authorization_endpoint ? { device_url: m.device_authorization_endpoint } : {}),
174
+ ...(m.pushed_authorization_request_endpoint ? { par_url: m.pushed_authorization_request_endpoint } : {}),
175
+ ...(m.require_pushed_authorization_requests === true ? { par_required: true } : {}),
176
+ iss_parameter: m.authorization_response_iss_parameter_supported === true,
177
+ ...pickTokenAuth(m.token_endpoint_auth_methods_supported, opts),
178
+ metadata_url: found.url,
179
+ metadata: m,
180
+ };
181
+ }
182
+
183
+ /**
184
+ * The token_auth to use. A choice the caller made is kept. Otherwise, with a
185
+ * secret: the form body when the server lists it (what every secret did
186
+ * before), else Basic — which is also the RFC 8414 default when the server
187
+ * lists nothing. Without a secret: none (a public client).
188
+ */
189
+ export function pickTokenAuth(supported: unknown, opts: { hasSecret: boolean; tokenAuth?: TokenAuth }): { token_auth?: TokenAuth } {
190
+ if (opts.tokenAuth) return { token_auth: opts.tokenAuth };
191
+ const list = Array.isArray(supported) ? supported.filter((s): s is string => typeof s === "string") : null;
192
+ // A public client sends no secret. Not refused when the list leaves out
193
+ // `none`: Keycloak takes public clients and lists only secret methods,
194
+ // so the list is not the last word on it (found by the conformance suite).
195
+ if (!opts.hasSecret) return {};
196
+ if (list && list.includes("client_secret_post")) return {};
197
+ if (!list || list.includes("client_secret_basic")) return { token_auth: "client_secret_basic" };
198
+ throw new Error(`the server takes none of the ways foldrun signs in at its token URL (it lists ${list.join(", ") || "nothing"}; foldrun sends the secret in the form body or as HTTP Basic)`);
199
+ }
200
+
201
+ /** Discover and turn into a client config in one call. */
202
+ export async function discoverOAuth(
203
+ issuer: string,
204
+ fetcher: Fetcher,
205
+ opts: { hasSecret: boolean; pkce?: boolean; tokenAuth?: TokenAuth; needsAuthorize?: boolean },
206
+ ): Promise<DiscoveredClientConfig> {
207
+ return clientConfigFromMetadata(await fetchOAuthMetadata(issuer, fetcher), opts);
208
+ }
@@ -56,8 +56,11 @@ const formEncode = (s: string) => new URLSearchParams({ x: s }).toString().slice
56
56
  * fields (grant_type, code, refresh_token, …); the client's identity is
57
57
  * added here, by its token_auth. Fields in `params` named client_id or
58
58
  * client_secret are dropped — the method decides those, never an `extra`.
59
+ * `clientIdInBody`: client_id goes in the form whatever the method — a
60
+ * pushed authorization request carries it as a parameter of the request it
61
+ * describes even when the client authenticates with Basic (RFC 9126 §2.1).
59
62
  */
60
- export function oauthTokenRequest(client: TokenClient, params: Record<string, string>): { headers: Record<string, string>; body: string } {
63
+ export function oauthTokenRequest(client: TokenClient, params: Record<string, string>, opts: { clientIdInBody?: boolean } = {}): { headers: Record<string, string>; body: string } {
61
64
  const problem = tokenAuthProblem(client);
62
65
  if (problem) throw new Error(problem);
63
66
  const form = new URLSearchParams();
@@ -76,6 +79,7 @@ export function oauthTokenRequest(client: TokenClient, params: Record<string, st
76
79
  // not the body as well.
77
80
  const pair = `${formEncode(client.client_id)}:${formEncode(client.client_secret!)}`;
78
81
  headers.authorization = `Basic ${Buffer.from(pair, "utf8").toString("base64")}`;
82
+ if (opts.clientIdInBody) form.set("client_id", client.client_id);
79
83
  } else {
80
84
  form.set("client_id", client.client_id);
81
85
  if (auth === "client_secret_post") form.set("client_secret", client.client_secret!);