@foldrun/core 9.2.1 → 9.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/secrets.ts CHANGED
@@ -24,6 +24,8 @@ import path from "node:path";
24
24
  import { dataRoot } from "./paths.ts";
25
25
  import { platform } from "./platform.ts";
26
26
  import { fetchUntrusted } from "./untrusted-fetch.ts";
27
+ import { oauthTokenRequest, readTokenResponse, tokenAuthProblem, type TokenAuth } from "./oauth-token.ts";
28
+ import { isSecureUrl } from "./oauth-discovery.ts";
27
29
 
28
30
  /**
29
31
  * GCM's auth tag, pinned to its full 16 bytes at every cipher call. Without
@@ -934,6 +936,15 @@ export interface OAuth2Config {
934
936
  grant_type?: "refresh_token" | "client_credentials";
935
937
  /** Extra form fields some providers want (audience, scope, …). */
936
938
  extra?: Record<string, string>;
939
+ /** How the client authenticates at the token endpoint (oauth-token.ts):
940
+ * absent is the form body, with the secret when there is one. */
941
+ token_auth?: TokenAuth;
942
+ /** Plain fields the provider returned beside the first token (Salesforce's
943
+ * instance_url) — kept with the recipe, never a credential. */
944
+ fields?: Record<string, string>;
945
+ /** The provider's revocation endpoint (RFC 7009): deleting or replacing
946
+ * this secret revokes its tokens there (revokeOAuth2). */
947
+ revocation_url?: string;
937
948
  }
938
949
 
939
950
  const OAUTH2_PREFIX = "@oauth2 ";
@@ -965,9 +976,14 @@ export function setOAuth2Secret(
965
976
  if (config.grant_type !== "client_credentials" && !config.refresh_token?.trim()) {
966
977
  throw new Error("oauth2 secret needs refresh_token (or grant_type: client_credentials)");
967
978
  }
979
+ if (config.revocation_url !== undefined && (typeof config.revocation_url !== "string" || !isSecureUrl(config.revocation_url))) {
980
+ throw new Error("revocation_url must be https");
981
+ }
982
+ const authProblem = tokenAuthProblem(config);
983
+ if (authProblem) throw new Error(`oauth2 secret: ${authProblem}`);
968
984
  // Loopback is exempt: a token endpoint on this same machine (tests, a
969
985
  // local mock, a sidecar) crosses no wire for http to leak on.
970
- if (!/^https:\/\//.test(config.token_url) && !/^http:\/\/(127\.0\.0\.1|localhost)[:/]/.test(config.token_url)) {
986
+ if (!isSecureUrl(config.token_url)) {
971
987
  throw new Error("token_url must be https — a refresh token over http is a leaked one");
972
988
  }
973
989
  setSecret(tenant, name, OAUTH2_PREFIX + JSON.stringify(config), workspace, "oauth2", limit);
@@ -1050,11 +1066,11 @@ async function exchange(config: OAuth2Config, cacheKey: string): Promise<OAuthLi
1050
1066
  if (pending) return pending;
1051
1067
 
1052
1068
  const flight = (async () => {
1053
- const body = new URLSearchParams({
1069
+ // One builder for every token request (oauth-token.ts): the client's
1070
+ // identity goes where its token_auth says, and an `extra` cannot set it.
1071
+ const request = oauthTokenRequest(config, {
1054
1072
  grant_type: config.grant_type ?? "refresh_token",
1055
1073
  ...(config.grant_type === "client_credentials" ? {} : { refresh_token: config.refresh_token! }),
1056
- client_id: config.client_id,
1057
- ...(config.client_secret ? { client_secret: config.client_secret } : {}),
1058
1074
  ...(config.extra ?? {}),
1059
1075
  });
1060
1076
  // A network blip is retried; a provider's answer is not. "fetch failed"
@@ -1069,8 +1085,8 @@ async function exchange(config: OAuth2Config, cacheKey: string): Promise<OAuthLi
1069
1085
  try {
1070
1086
  res = await fetchUntrusted(config.token_url, {
1071
1087
  method: "POST",
1072
- headers: { "content-type": "application/x-www-form-urlencoded" },
1073
- body: body.toString(),
1088
+ headers: request.headers,
1089
+ body: request.body,
1074
1090
  signal: AbortSignal.timeout(15_000),
1075
1091
  }, { sameOrigin: true });
1076
1092
  } catch (err) {
@@ -1082,13 +1098,8 @@ async function exchange(config: OAuth2Config, cacheKey: string): Promise<OAuthLi
1082
1098
  break;
1083
1099
  }
1084
1100
  if (!res) throw lastNetwork instanceof Error ? lastNetwork : new Error(String(lastNetwork));
1085
- const payload = (await res.json().catch(() => ({}))) as {
1086
- access_token?: string;
1087
- refresh_token?: string;
1088
- expires_in?: number;
1089
- error?: string;
1090
- error_description?: string;
1091
- };
1101
+ // JSON or form-encoded, Slack's nested user token, expires_in as a string.
1102
+ const payload = readTokenResponse(await res.text().catch(() => ""), res.headers.get("content-type") ?? "");
1092
1103
  if (!res.ok || !payload.access_token) {
1093
1104
  throw new Error(
1094
1105
  `token refresh failed (${res.status}): ${payload.error_description ?? payload.error ?? "no access_token in the reply"}`,
@@ -1113,6 +1124,135 @@ async function exchange(config: OAuth2Config, cacheKey: string): Promise<OAuthLi
1113
1124
  }
1114
1125
  }
1115
1126
 
1127
+ // ---------------------------------------------------------------- revocation
1128
+
1129
+ /** The oauth2 recipe stored at exactly this scope — never the account copy a
1130
+ * workspace would fall back to: revoking is about the copy being removed. */
1131
+ export function oauth2ValueAt(tenant: string, name: string, workspace?: string): string | null {
1132
+ const v = openRecord(tenant, workspace, name, read(tenant, workspace)[name]);
1133
+ return v !== null && isOAuth2Value(v) ? v : null;
1134
+ }
1135
+
1136
+ /** The workspaces with a vault file of their own (current and legacy layout). */
1137
+ function workspacesWithSecrets(tenant: string): string[] {
1138
+ const out = new Set<string>();
1139
+ for (const dir of ["workspaces", "projects"]) {
1140
+ const base = path.join(dataRoot(), tenant, dir);
1141
+ let names: string[] = [];
1142
+ try {
1143
+ names = fs.readdirSync(base);
1144
+ } catch {
1145
+ continue;
1146
+ }
1147
+ for (const n of names) if (fs.existsSync(path.join(base, n, "secrets.json"))) out.add(n);
1148
+ }
1149
+ return [...out];
1150
+ }
1151
+
1152
+ /**
1153
+ * Another stored secret of this account holds a grant revoking this one could
1154
+ * end: the same refresh token (a copy made by hand, or one rotation reached),
1155
+ * or any grant from the same app (token URL and client id). Providers such as
1156
+ * Google revoke every token of a user's grant to an app together, so deleting
1157
+ * the Ads connection would take down the Search Console one made with the
1158
+ * same app (Codex review, 1 Nov 2026). Such a grant is left alone.
1159
+ */
1160
+ export function oauthGrantSharedElsewhere(tenant: string, name: string, workspace: string | undefined, refreshToken: string, app?: { token_url: string; client_id: string }): boolean {
1161
+ for (const scope of [undefined, ...workspacesWithSecrets(tenant)]) {
1162
+ for (const [other, rec] of Object.entries(read(tenant, scope))) {
1163
+ if (other === name && scope === workspace) continue;
1164
+ // By the value, not the optional kind tag: a recipe copied with a
1165
+ // plain setSecret is still one materializeSecrets refreshes.
1166
+ const v = openRecord(tenant, scope, other, rec);
1167
+ if (!v || !isOAuth2Value(v)) continue;
1168
+ try {
1169
+ const other = JSON.parse(v.slice(OAUTH2_PREFIX.length)) as OAuth2Config;
1170
+ if (other.refresh_token === refreshToken) return true;
1171
+ if (app && other.grant_type !== "client_credentials" && other.token_url === app.token_url && other.client_id === app.client_id) return true;
1172
+ } catch {
1173
+ continue;
1174
+ }
1175
+ }
1176
+ }
1177
+ return false;
1178
+ }
1179
+
1180
+ /** What revoking an oauth2 secret's tokens came to. */
1181
+ export interface OAuthRevokeResult {
1182
+ /** Nothing was asked: no revocation endpoint; another secret holds the
1183
+ * same grant or one from the same app; the replacement is from the same
1184
+ * app (a reconnect: revoking would end the new grant too at providers
1185
+ * that revoke per app); or the stored recipe could not be read. */
1186
+ skipped?: "no-revocation-url" | "shared-grant" | "same-app" | "unreadable";
1187
+ /** The token types the provider accepted the revocation of. */
1188
+ revoked: ("refresh_token" | "access_token")[];
1189
+ /** Why one failed — reported, never a reason to keep a secret. */
1190
+ error?: string;
1191
+ }
1192
+
1193
+ type RevokeFetcher = (url: string, init: RequestInit) => Promise<Response>;
1194
+
1195
+ /**
1196
+ * Revoke what an oauth2 recipe holds at its provider (RFC 7009): the refresh
1197
+ * token, then the access token this process has cached for it. The client
1198
+ * authenticates as at the token URL (its token_auth). A 200 is success —
1199
+ * the RFC answers 200 for a token already invalid too; anything else is
1200
+ * reported in `error`, and the next token is still tried. `replacement`: the
1201
+ * recipe taking this one's place; one from the same app (token URL and client
1202
+ * id) means nothing is revoked, since a provider that revokes per app would
1203
+ * end the new grant with the old.
1204
+ */
1205
+ export async function revokeOAuth2Value(
1206
+ value: string,
1207
+ opts: { tenant?: string; name?: string; workspace?: string; fetcher?: RevokeFetcher; replacement?: { token_url: string; client_id: string } } = {},
1208
+ ): Promise<OAuthRevokeResult> {
1209
+ let config: OAuth2Config;
1210
+ try {
1211
+ config = JSON.parse(value.slice(OAUTH2_PREFIX.length)) as OAuth2Config;
1212
+ } catch {
1213
+ return { skipped: "unreadable", revoked: [] };
1214
+ }
1215
+ if (!config.revocation_url) return { skipped: "no-revocation-url", revoked: [] };
1216
+ if (opts.replacement && opts.replacement.token_url === config.token_url && opts.replacement.client_id === config.client_id) {
1217
+ return { skipped: "same-app", revoked: [] };
1218
+ }
1219
+ if (config.grant_type !== "client_credentials" && opts.tenant && opts.name
1220
+ && oauthGrantSharedElsewhere(opts.tenant, opts.name, opts.workspace, config.refresh_token ?? "", { token_url: config.token_url, client_id: config.client_id })) {
1221
+ return { skipped: "shared-grant", revoked: [] };
1222
+ }
1223
+ // No redirect is followed: a 3xx is not ok, so a revocation endpoint that
1224
+ // sends us to a login page is reported, never counted as revoked.
1225
+ const fetcher: RevokeFetcher = opts.fetcher ?? ((url, init) => fetchUntrusted(url, init, { sameOrigin: true, noRedirects: true }));
1226
+ const key = tokenCacheKey("oauth2", value, opts.tenant);
1227
+ const access = oauthCache.get(key)?.token;
1228
+ const tokens: ["refresh_token" | "access_token", string][] = [];
1229
+ if (config.grant_type !== "client_credentials" && config.refresh_token) tokens.push(["refresh_token", config.refresh_token]);
1230
+ if (access) tokens.push(["access_token", access]);
1231
+ const out: OAuthRevokeResult = { revoked: [] };
1232
+ for (const [hint, token] of tokens) {
1233
+ try {
1234
+ const request = oauthTokenRequest(config, { token, token_type_hint: hint });
1235
+ const res = await fetcher(config.revocation_url, { method: "POST", headers: request.headers, body: request.body, redirect: "error", signal: AbortSignal.timeout(10_000) });
1236
+ if (res.ok) {
1237
+ await res.body?.cancel().catch(() => {});
1238
+ out.revoked.push(hint);
1239
+ } else {
1240
+ // Reported, and the next token is still tried: a provider that will
1241
+ // not revoke refresh tokens may still revoke the access token.
1242
+ const why = readTokenResponse(await res.text().catch(() => ""), res.headers.get("content-type") ?? "");
1243
+ out.error ??= `the provider refused to revoke the ${hint.replace("_", " ")} (${res.status}${why.error ? `: ${why.error_description ?? why.error}` : ""})`;
1244
+ }
1245
+ } catch (err) {
1246
+ out.error ??= `revoking the ${hint.replace("_", " ")} failed: ${err instanceof Error ? err.message : String(err)}`;
1247
+ }
1248
+ }
1249
+ // Every entry serving that access token goes: after a rotation the same
1250
+ // token is cached under the recipe before and after it (Codex review).
1251
+ oauthCache.delete(key);
1252
+ if (access) for (const [k, v] of oauthCache) if (v.token === access) oauthCache.delete(k);
1253
+ return out;
1254
+ }
1255
+
1116
1256
  /**
1117
1257
  * Swap every oauth2 recipe in an env map for a live access token. The one
1118
1258
  * async moment in secret resolution, kept out of the sync paths: callers
@@ -104,6 +104,10 @@ export async function fetchUntrusted(
104
104
  sameOrigin?: boolean;
105
105
  /** The most the answer may be, read; MAX_BYTES unless said. */
106
106
  maxBytes?: number;
107
+ /** Follow no redirect at all: a 3xx comes back as the answer, for the
108
+ * caller to treat as the failure it is (a revocation that redirects to
109
+ * a login page did not revoke anything). */
110
+ noRedirects?: boolean;
107
111
  } = {},
108
112
  ): Promise<Response> {
109
113
  const maxBytes = opts.maxBytes ?? MAX_BYTES;
@@ -112,7 +116,7 @@ export async function fetchUntrusted(
112
116
  for (let hop = 0; ; hop++) {
113
117
  const res = await platform.fetchUntrusted(current, { ...req, redirect: "manual" }, { maxBytes });
114
118
  const location = REDIRECT.has(res.status) ? res.headers.get("location") : null;
115
- if (!location) return capBody(res, maxBytes, current);
119
+ if (!location || opts.noRedirects) return capBody(res, maxBytes, current);
116
120
  // The body of a redirect is never read; free the socket.
117
121
  await res.body?.cancel().catch(() => {});
118
122
  if (hop >= MAX_REDIRECTS) throw new RedirectRefused(`${url}: more than ${MAX_REDIRECTS} redirects — not followed`);