@foldrun/core 9.2.1 → 9.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -30,6 +30,7 @@ export * from "./src/package.ts";
30
30
  export * from "./src/storage.ts";
31
31
  export * from "./src/secrets.ts";
32
32
  export * from "./src/oauth-presets.ts";
33
+ export * from "./src/oauth-token.ts";
33
34
  export * from "./src/secret-files.ts";
34
35
  export * from "./src/notify.ts";
35
36
  export * from "./src/webhook.ts";
package/dist/index.js CHANGED
@@ -41,6 +41,7 @@ export * from "./src/package.js";
41
41
  export * from "./src/storage.js";
42
42
  export * from "./src/secrets.js";
43
43
  export * from "./src/oauth-presets.js";
44
+ export * from "./src/oauth-token.js";
44
45
  export * from "./src/secret-files.js";
45
46
  export * from "./src/notify.js";
46
47
  export * from "./src/webhook.js";
@@ -0,0 +1,46 @@
1
+ export declare const TOKEN_AUTH_METHODS: readonly ["client_secret_post", "client_secret_basic", "none"];
2
+ export type TokenAuth = (typeof TOKEN_AUTH_METHODS)[number];
3
+ /** The short spellings a person types (`--token-auth basic`), to the RFC names. */
4
+ export declare function parseTokenAuth(value: string): TokenAuth | null;
5
+ export interface TokenClient {
6
+ client_id: string;
7
+ client_secret?: string;
8
+ token_auth?: TokenAuth;
9
+ }
10
+ /** Why a client's token_auth cannot work, or null. */
11
+ export declare function tokenAuthProblem(client: {
12
+ client_secret?: string;
13
+ token_auth?: unknown;
14
+ }): string | null;
15
+ /**
16
+ * The headers and body of one token request. `params` is the grant's own
17
+ * fields (grant_type, code, refresh_token, …); the client's identity is
18
+ * added here, by its token_auth. Fields in `params` named client_id or
19
+ * client_secret are dropped — the method decides those, never an `extra`.
20
+ */
21
+ export declare function oauthTokenRequest(client: TokenClient, params: Record<string, string>): {
22
+ headers: Record<string, string>;
23
+ body: string;
24
+ };
25
+ /** A token endpoint's answer, read the ways providers actually write it. */
26
+ export interface TokenResponse {
27
+ access_token?: string;
28
+ refresh_token?: string;
29
+ /** Seconds; a string "3600" (some providers) is read as the number. */
30
+ expires_in?: number;
31
+ refresh_token_expires_in?: number;
32
+ token_type?: string;
33
+ scope?: string;
34
+ error?: string;
35
+ error_description?: string;
36
+ /** The other plain fields beside the token — Salesforce's instance_url,
37
+ * Slack's team — kept for the caller; never a token. */
38
+ fields: Record<string, string>;
39
+ }
40
+ /**
41
+ * Read a token endpoint's body: JSON, or form-encoded (GitHub without an
42
+ * Accept header, some older servers), whatever the content type claims.
43
+ * Slack's user-token install nests the token under `authed_user` and says
44
+ * `ok: false` with a 200 when it refuses; both are read here.
45
+ */
46
+ export declare function readTokenResponse(text: string, contentType?: string): TokenResponse;
@@ -0,0 +1,164 @@
1
+ // Every request to an OAuth token endpoint, and every answer read from one,
2
+ // goes through here: core's refresh (refresh_token, client_credentials), the
3
+ // platform's code exchange after a consent, and the CLI's own loopback
4
+ // connect. Three hand-written bodies had already drifted — none could send
5
+ // the client secret any way but in the form body, which is the one way RFC
6
+ // 6749 §2.3.1 lets a server refuse (Notion, Spotify, Reddit, Zoom do).
7
+ //
8
+ // How the client proves itself at the token endpoint (`token_auth`):
9
+ // client_secret_post id and secret in the form body (the default — what
10
+ // every stored secret did before 1 Nov 2026)
11
+ // client_secret_basic HTTP Basic, the id and secret each form-urlencoded
12
+ // first (RFC 6749 §2.3.1, Appendix B) — not plain
13
+ // base64 of the raw pair, which breaks on a secret
14
+ // holding ':' or '%'
15
+ // none a public client: client_id in the body, no secret
16
+ //
17
+ // Absent means the old behaviour: the body, with the secret if there is one.
18
+ export const TOKEN_AUTH_METHODS = ["client_secret_post", "client_secret_basic", "none"];
19
+ /** The short spellings a person types (`--token-auth basic`), to the RFC names. */
20
+ export function parseTokenAuth(value) {
21
+ const v = value.trim().toLowerCase();
22
+ if (TOKEN_AUTH_METHODS.includes(v))
23
+ return v;
24
+ if (v === "post" || v === "body")
25
+ return "client_secret_post";
26
+ if (v === "basic")
27
+ return "client_secret_basic";
28
+ return null;
29
+ }
30
+ /** Why a client's token_auth cannot work, or null. */
31
+ export function tokenAuthProblem(client) {
32
+ const auth = client.token_auth;
33
+ if (auth === undefined)
34
+ return null;
35
+ if (typeof auth !== "string" || !TOKEN_AUTH_METHODS.includes(auth)) {
36
+ return `token_auth must be one of ${TOKEN_AUTH_METHODS.join(", ")}`;
37
+ }
38
+ const hasSecret = !!client.client_secret?.trim();
39
+ if (auth === "client_secret_basic" && !hasSecret)
40
+ return "token_auth client_secret_basic needs a client_secret";
41
+ if (auth === "client_secret_post" && !hasSecret)
42
+ return "token_auth client_secret_post needs a client_secret — a public app is token_auth none";
43
+ if (auth === "none" && hasSecret)
44
+ 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";
45
+ return null;
46
+ }
47
+ /** The form-urlencoding RFC 6749 Appendix B asks for: space as '+'. */
48
+ const formEncode = (s) => new URLSearchParams({ x: s }).toString().slice(2);
49
+ /**
50
+ * The headers and body of one token request. `params` is the grant's own
51
+ * fields (grant_type, code, refresh_token, …); the client's identity is
52
+ * added here, by its token_auth. Fields in `params` named client_id or
53
+ * client_secret are dropped — the method decides those, never an `extra`.
54
+ */
55
+ export function oauthTokenRequest(client, params) {
56
+ const problem = tokenAuthProblem(client);
57
+ if (problem)
58
+ throw new Error(problem);
59
+ const form = new URLSearchParams();
60
+ for (const [k, v] of Object.entries(params)) {
61
+ if (k === "client_id" || k === "client_secret")
62
+ continue;
63
+ form.set(k, v);
64
+ }
65
+ const headers = {
66
+ "content-type": "application/x-www-form-urlencoded",
67
+ // GitHub answers form-encoded unless asked; every server may answer JSON.
68
+ accept: "application/json",
69
+ };
70
+ const auth = client.token_auth ?? (client.client_secret ? "client_secret_post" : "none");
71
+ if (auth === "client_secret_basic") {
72
+ // One method per request (RFC 6749 §2.3): the id travels in the header,
73
+ // not the body as well.
74
+ const pair = `${formEncode(client.client_id)}:${formEncode(client.client_secret)}`;
75
+ headers.authorization = `Basic ${Buffer.from(pair, "utf8").toString("base64")}`;
76
+ }
77
+ else {
78
+ form.set("client_id", client.client_id);
79
+ if (auth === "client_secret_post")
80
+ form.set("client_secret", client.client_secret);
81
+ }
82
+ return { headers, body: form.toString() };
83
+ }
84
+ /** Fields that are credentials or are read into their own slot above. */
85
+ const NOT_FIELDS = new Set([
86
+ "access_token", "refresh_token", "id_token", "expires_in", "refresh_token_expires_in",
87
+ "error", "error_description", "error_uri", "authed_user", "ok",
88
+ ]);
89
+ function seconds(v) {
90
+ const n = typeof v === "number" ? v : typeof v === "string" && v.trim() !== "" ? Number(v) : NaN;
91
+ return Number.isFinite(n) && n >= 0 ? n : undefined;
92
+ }
93
+ function str(v) {
94
+ return typeof v === "string" && v !== "" ? v : undefined;
95
+ }
96
+ /**
97
+ * Read a token endpoint's body: JSON, or form-encoded (GitHub without an
98
+ * Accept header, some older servers), whatever the content type claims.
99
+ * Slack's user-token install nests the token under `authed_user` and says
100
+ * `ok: false` with a 200 when it refuses; both are read here.
101
+ */
102
+ export function readTokenResponse(text, contentType = "") {
103
+ let raw = {};
104
+ const trimmed = text.trim();
105
+ if (trimmed.startsWith("{")) {
106
+ try {
107
+ const parsed = JSON.parse(trimmed);
108
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
109
+ raw = parsed;
110
+ }
111
+ catch {
112
+ raw = {};
113
+ }
114
+ }
115
+ else if (trimmed && (/x-www-form-urlencoded|text\/plain/i.test(contentType) || /^[\w.-]+=/.test(trimmed))) {
116
+ raw = Object.fromEntries(new URLSearchParams(trimmed));
117
+ }
118
+ // Slack: a user token alone comes back under authed_user, with its own
119
+ // refresh token and expiry when rotation is on. The top-level token (a bot
120
+ // token) wins when there is one.
121
+ let source = raw;
122
+ const nested = raw.authed_user;
123
+ if (!str(raw.access_token) && nested && typeof nested === "object" && str(nested.access_token)) {
124
+ source = nested;
125
+ }
126
+ const fields = {};
127
+ for (const [k, v] of Object.entries(raw)) {
128
+ if (NOT_FIELDS.has(k))
129
+ continue;
130
+ if (typeof v === "string" || typeof v === "number" || typeof v === "boolean")
131
+ fields[k] = String(v);
132
+ }
133
+ // From the nested object: its scope and type describe the token we took.
134
+ const scope = str(source.scope) ?? str(raw.scope);
135
+ const tokenType = str(source.token_type) ?? str(raw.token_type);
136
+ const out = { fields };
137
+ const access = str(source.access_token);
138
+ const refresh = str(source.refresh_token);
139
+ const expires = seconds(source.expires_in);
140
+ const refreshExpires = seconds(source.refresh_token_expires_in);
141
+ if (access)
142
+ out.access_token = access;
143
+ if (refresh)
144
+ out.refresh_token = refresh;
145
+ if (expires !== undefined)
146
+ out.expires_in = expires;
147
+ if (refreshExpires !== undefined)
148
+ out.refresh_token_expires_in = refreshExpires;
149
+ if (tokenType)
150
+ out.token_type = tokenType;
151
+ if (scope)
152
+ out.scope = scope;
153
+ const error = str(raw.error) ?? (raw.ok === false ? "refused" : undefined);
154
+ if (error) {
155
+ out.error = error;
156
+ // A refusal carries no token, whatever else the body held.
157
+ delete out.access_token;
158
+ delete out.refresh_token;
159
+ }
160
+ const description = str(raw.error_description);
161
+ if (description)
162
+ out.error_description = description;
163
+ return out;
164
+ }
@@ -1,3 +1,4 @@
1
+ import { type TokenAuth } from "./oauth-token.ts";
1
2
  /** Which store a secret came from. */
2
3
  export type SecretScope = "account" | "workspace";
3
4
  /**
@@ -238,6 +239,12 @@ export interface OAuth2Config {
238
239
  grant_type?: "refresh_token" | "client_credentials";
239
240
  /** Extra form fields some providers want (audience, scope, …). */
240
241
  extra?: Record<string, string>;
242
+ /** How the client authenticates at the token endpoint (oauth-token.ts):
243
+ * absent is the form body, with the secret when there is one. */
244
+ token_auth?: TokenAuth;
245
+ /** Plain fields the provider returned beside the first token (Salesforce's
246
+ * instance_url) — kept with the recipe, never a credential. */
247
+ fields?: Record<string, string>;
241
248
  }
242
249
  /** Tries at a token endpoint before a refresh is called failed: the first,
243
250
  * and two more after a short wait, for a network error, a 5xx or a 429. */
@@ -23,6 +23,7 @@ import path from "node:path";
23
23
  import { dataRoot } from "./paths.js";
24
24
  import { platform } from "./platform.js";
25
25
  import { fetchUntrusted } from "./untrusted-fetch.js";
26
+ import { oauthTokenRequest, readTokenResponse, tokenAuthProblem } from "./oauth-token.js";
26
27
  /**
27
28
  * GCM's auth tag, pinned to its full 16 bytes at every cipher call. Without
28
29
  * `authTagLength`, Node accepts whatever length `setAuthTag` is handed — 4, 8,
@@ -821,6 +822,9 @@ export function setOAuth2Secret(tenant, name, config, workspace, limit = {}) {
821
822
  if (config.grant_type !== "client_credentials" && !config.refresh_token?.trim()) {
822
823
  throw new Error("oauth2 secret needs refresh_token (or grant_type: client_credentials)");
823
824
  }
825
+ const authProblem = tokenAuthProblem(config);
826
+ if (authProblem)
827
+ throw new Error(`oauth2 secret: ${authProblem}`);
824
828
  // Loopback is exempt: a token endpoint on this same machine (tests, a
825
829
  // local mock, a sidecar) crosses no wire for http to leak on.
826
830
  if (!/^https:\/\//.test(config.token_url) && !/^http:\/\/(127\.0\.0\.1|localhost)[:/]/.test(config.token_url)) {
@@ -898,11 +902,11 @@ async function exchange(config, cacheKey) {
898
902
  if (pending)
899
903
  return pending;
900
904
  const flight = (async () => {
901
- const body = new URLSearchParams({
905
+ // One builder for every token request (oauth-token.ts): the client's
906
+ // identity goes where its token_auth says, and an `extra` cannot set it.
907
+ const request = oauthTokenRequest(config, {
902
908
  grant_type: config.grant_type ?? "refresh_token",
903
909
  ...(config.grant_type === "client_credentials" ? {} : { refresh_token: config.refresh_token }),
904
- client_id: config.client_id,
905
- ...(config.client_secret ? { client_secret: config.client_secret } : {}),
906
910
  ...(config.extra ?? {}),
907
911
  });
908
912
  // A network blip is retried; a provider's answer is not. "fetch failed"
@@ -918,8 +922,8 @@ async function exchange(config, cacheKey) {
918
922
  try {
919
923
  res = await fetchUntrusted(config.token_url, {
920
924
  method: "POST",
921
- headers: { "content-type": "application/x-www-form-urlencoded" },
922
- body: body.toString(),
925
+ headers: request.headers,
926
+ body: request.body,
923
927
  signal: AbortSignal.timeout(15_000),
924
928
  }, { sameOrigin: true });
925
929
  }
@@ -934,7 +938,8 @@ async function exchange(config, cacheKey) {
934
938
  }
935
939
  if (!res)
936
940
  throw lastNetwork instanceof Error ? lastNetwork : new Error(String(lastNetwork));
937
- const payload = (await res.json().catch(() => ({})));
941
+ // JSON or form-encoded, Slack's nested user token, expires_in as a string.
942
+ const payload = readTokenResponse(await res.text().catch(() => ""), res.headers.get("content-type") ?? "");
938
943
  if (!res.ok || !payload.access_token) {
939
944
  throw new Error(`token refresh failed (${res.status}): ${payload.error_description ?? payload.error ?? "no access_token in the reply"}`);
940
945
  }
package/index.ts CHANGED
@@ -42,6 +42,7 @@ export * from "./src/package.ts";
42
42
  export * from "./src/storage.ts";
43
43
  export * from "./src/secrets.ts";
44
44
  export * from "./src/oauth-presets.ts";
45
+ export * from "./src/oauth-token.ts";
45
46
  export * from "./src/secret-files.ts";
46
47
  export * from "./src/notify.ts";
47
48
  export * from "./src/webhook.ts";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldrun/core",
3
- "version": "9.2.1",
3
+ "version": "9.3.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,176 @@
1
+ // Every request to an OAuth token endpoint, and every answer read from one,
2
+ // goes through here: core's refresh (refresh_token, client_credentials), the
3
+ // platform's code exchange after a consent, and the CLI's own loopback
4
+ // connect. Three hand-written bodies had already drifted — none could send
5
+ // the client secret any way but in the form body, which is the one way RFC
6
+ // 6749 §2.3.1 lets a server refuse (Notion, Spotify, Reddit, Zoom do).
7
+ //
8
+ // How the client proves itself at the token endpoint (`token_auth`):
9
+ // client_secret_post id and secret in the form body (the default — what
10
+ // every stored secret did before 1 Nov 2026)
11
+ // client_secret_basic HTTP Basic, the id and secret each form-urlencoded
12
+ // first (RFC 6749 §2.3.1, Appendix B) — not plain
13
+ // base64 of the raw pair, which breaks on a secret
14
+ // holding ':' or '%'
15
+ // none a public client: client_id in the body, no secret
16
+ //
17
+ // Absent means the old behaviour: the body, with the secret if there is one.
18
+
19
+ export const TOKEN_AUTH_METHODS = ["client_secret_post", "client_secret_basic", "none"] as const;
20
+ export type TokenAuth = (typeof TOKEN_AUTH_METHODS)[number];
21
+
22
+ /** The short spellings a person types (`--token-auth basic`), to the RFC names. */
23
+ export function parseTokenAuth(value: string): TokenAuth | null {
24
+ const v = value.trim().toLowerCase();
25
+ if ((TOKEN_AUTH_METHODS as readonly string[]).includes(v)) return v as TokenAuth;
26
+ if (v === "post" || v === "body") return "client_secret_post";
27
+ if (v === "basic") return "client_secret_basic";
28
+ return null;
29
+ }
30
+
31
+ export interface TokenClient {
32
+ client_id: string;
33
+ client_secret?: string;
34
+ token_auth?: TokenAuth;
35
+ }
36
+
37
+ /** Why a client's token_auth cannot work, or null. */
38
+ export function tokenAuthProblem(client: { client_secret?: string; token_auth?: unknown }): string | null {
39
+ const auth = client.token_auth;
40
+ if (auth === undefined) return null;
41
+ if (typeof auth !== "string" || !(TOKEN_AUTH_METHODS as readonly string[]).includes(auth)) {
42
+ return `token_auth must be one of ${TOKEN_AUTH_METHODS.join(", ")}`;
43
+ }
44
+ const hasSecret = !!client.client_secret?.trim();
45
+ if (auth === "client_secret_basic" && !hasSecret) return "token_auth client_secret_basic needs a client_secret";
46
+ if (auth === "client_secret_post" && !hasSecret) return "token_auth client_secret_post needs a client_secret — a public app is token_auth none";
47
+ 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";
48
+ return null;
49
+ }
50
+
51
+ /** The form-urlencoding RFC 6749 Appendix B asks for: space as '+'. */
52
+ const formEncode = (s: string) => new URLSearchParams({ x: s }).toString().slice(2);
53
+
54
+ /**
55
+ * The headers and body of one token request. `params` is the grant's own
56
+ * fields (grant_type, code, refresh_token, …); the client's identity is
57
+ * added here, by its token_auth. Fields in `params` named client_id or
58
+ * client_secret are dropped — the method decides those, never an `extra`.
59
+ */
60
+ export function oauthTokenRequest(client: TokenClient, params: Record<string, string>): { headers: Record<string, string>; body: string } {
61
+ const problem = tokenAuthProblem(client);
62
+ if (problem) throw new Error(problem);
63
+ const form = new URLSearchParams();
64
+ for (const [k, v] of Object.entries(params)) {
65
+ if (k === "client_id" || k === "client_secret") continue;
66
+ form.set(k, v);
67
+ }
68
+ const headers: Record<string, string> = {
69
+ "content-type": "application/x-www-form-urlencoded",
70
+ // GitHub answers form-encoded unless asked; every server may answer JSON.
71
+ accept: "application/json",
72
+ };
73
+ const auth = client.token_auth ?? (client.client_secret ? "client_secret_post" : "none");
74
+ if (auth === "client_secret_basic") {
75
+ // One method per request (RFC 6749 §2.3): the id travels in the header,
76
+ // not the body as well.
77
+ const pair = `${formEncode(client.client_id)}:${formEncode(client.client_secret!)}`;
78
+ headers.authorization = `Basic ${Buffer.from(pair, "utf8").toString("base64")}`;
79
+ } else {
80
+ form.set("client_id", client.client_id);
81
+ if (auth === "client_secret_post") form.set("client_secret", client.client_secret!);
82
+ }
83
+ return { headers, body: form.toString() };
84
+ }
85
+
86
+ /** A token endpoint's answer, read the ways providers actually write it. */
87
+ export interface TokenResponse {
88
+ access_token?: string;
89
+ refresh_token?: string;
90
+ /** Seconds; a string "3600" (some providers) is read as the number. */
91
+ expires_in?: number;
92
+ refresh_token_expires_in?: number;
93
+ token_type?: string;
94
+ scope?: string;
95
+ error?: string;
96
+ error_description?: string;
97
+ /** The other plain fields beside the token — Salesforce's instance_url,
98
+ * Slack's team — kept for the caller; never a token. */
99
+ fields: Record<string, string>;
100
+ }
101
+
102
+ /** Fields that are credentials or are read into their own slot above. */
103
+ const NOT_FIELDS = new Set([
104
+ "access_token", "refresh_token", "id_token", "expires_in", "refresh_token_expires_in",
105
+ "error", "error_description", "error_uri", "authed_user", "ok",
106
+ ]);
107
+
108
+ function seconds(v: unknown): number | undefined {
109
+ const n = typeof v === "number" ? v : typeof v === "string" && v.trim() !== "" ? Number(v) : NaN;
110
+ return Number.isFinite(n) && n >= 0 ? n : undefined;
111
+ }
112
+
113
+ function str(v: unknown): string | undefined {
114
+ return typeof v === "string" && v !== "" ? v : undefined;
115
+ }
116
+
117
+ /**
118
+ * Read a token endpoint's body: JSON, or form-encoded (GitHub without an
119
+ * Accept header, some older servers), whatever the content type claims.
120
+ * Slack's user-token install nests the token under `authed_user` and says
121
+ * `ok: false` with a 200 when it refuses; both are read here.
122
+ */
123
+ export function readTokenResponse(text: string, contentType = ""): TokenResponse {
124
+ let raw: Record<string, unknown> = {};
125
+ const trimmed = text.trim();
126
+ if (trimmed.startsWith("{")) {
127
+ try {
128
+ const parsed = JSON.parse(trimmed) as unknown;
129
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) raw = parsed as Record<string, unknown>;
130
+ } catch {
131
+ raw = {};
132
+ }
133
+ } else if (trimmed && (/x-www-form-urlencoded|text\/plain/i.test(contentType) || /^[\w.-]+=/.test(trimmed))) {
134
+ raw = Object.fromEntries(new URLSearchParams(trimmed));
135
+ }
136
+
137
+ // Slack: a user token alone comes back under authed_user, with its own
138
+ // refresh token and expiry when rotation is on. The top-level token (a bot
139
+ // token) wins when there is one.
140
+ let source: Record<string, unknown> = raw;
141
+ const nested = raw.authed_user;
142
+ if (!str(raw.access_token) && nested && typeof nested === "object" && str((nested as Record<string, unknown>).access_token)) {
143
+ source = nested as Record<string, unknown>;
144
+ }
145
+
146
+ const fields: Record<string, string> = {};
147
+ for (const [k, v] of Object.entries(raw)) {
148
+ if (NOT_FIELDS.has(k)) continue;
149
+ if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") fields[k] = String(v);
150
+ }
151
+ // From the nested object: its scope and type describe the token we took.
152
+ const scope = str(source.scope) ?? str(raw.scope);
153
+ const tokenType = str(source.token_type) ?? str(raw.token_type);
154
+
155
+ const out: TokenResponse = { fields };
156
+ const access = str(source.access_token);
157
+ const refresh = str(source.refresh_token);
158
+ const expires = seconds(source.expires_in);
159
+ const refreshExpires = seconds(source.refresh_token_expires_in);
160
+ if (access) out.access_token = access;
161
+ if (refresh) out.refresh_token = refresh;
162
+ if (expires !== undefined) out.expires_in = expires;
163
+ if (refreshExpires !== undefined) out.refresh_token_expires_in = refreshExpires;
164
+ if (tokenType) out.token_type = tokenType;
165
+ if (scope) out.scope = scope;
166
+ const error = str(raw.error) ?? (raw.ok === false ? "refused" : undefined);
167
+ if (error) {
168
+ out.error = error;
169
+ // A refusal carries no token, whatever else the body held.
170
+ delete out.access_token;
171
+ delete out.refresh_token;
172
+ }
173
+ const description = str(raw.error_description);
174
+ if (description) out.error_description = description;
175
+ return out;
176
+ }
package/src/secrets.ts CHANGED
@@ -24,6 +24,7 @@ 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";
27
28
 
28
29
  /**
29
30
  * GCM's auth tag, pinned to its full 16 bytes at every cipher call. Without
@@ -934,6 +935,12 @@ export interface OAuth2Config {
934
935
  grant_type?: "refresh_token" | "client_credentials";
935
936
  /** Extra form fields some providers want (audience, scope, …). */
936
937
  extra?: Record<string, string>;
938
+ /** How the client authenticates at the token endpoint (oauth-token.ts):
939
+ * absent is the form body, with the secret when there is one. */
940
+ token_auth?: TokenAuth;
941
+ /** Plain fields the provider returned beside the first token (Salesforce's
942
+ * instance_url) — kept with the recipe, never a credential. */
943
+ fields?: Record<string, string>;
937
944
  }
938
945
 
939
946
  const OAUTH2_PREFIX = "@oauth2 ";
@@ -965,6 +972,8 @@ export function setOAuth2Secret(
965
972
  if (config.grant_type !== "client_credentials" && !config.refresh_token?.trim()) {
966
973
  throw new Error("oauth2 secret needs refresh_token (or grant_type: client_credentials)");
967
974
  }
975
+ const authProblem = tokenAuthProblem(config);
976
+ if (authProblem) throw new Error(`oauth2 secret: ${authProblem}`);
968
977
  // Loopback is exempt: a token endpoint on this same machine (tests, a
969
978
  // local mock, a sidecar) crosses no wire for http to leak on.
970
979
  if (!/^https:\/\//.test(config.token_url) && !/^http:\/\/(127\.0\.0\.1|localhost)[:/]/.test(config.token_url)) {
@@ -1050,11 +1059,11 @@ async function exchange(config: OAuth2Config, cacheKey: string): Promise<OAuthLi
1050
1059
  if (pending) return pending;
1051
1060
 
1052
1061
  const flight = (async () => {
1053
- const body = new URLSearchParams({
1062
+ // One builder for every token request (oauth-token.ts): the client's
1063
+ // identity goes where its token_auth says, and an `extra` cannot set it.
1064
+ const request = oauthTokenRequest(config, {
1054
1065
  grant_type: config.grant_type ?? "refresh_token",
1055
1066
  ...(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
1067
  ...(config.extra ?? {}),
1059
1068
  });
1060
1069
  // A network blip is retried; a provider's answer is not. "fetch failed"
@@ -1069,8 +1078,8 @@ async function exchange(config: OAuth2Config, cacheKey: string): Promise<OAuthLi
1069
1078
  try {
1070
1079
  res = await fetchUntrusted(config.token_url, {
1071
1080
  method: "POST",
1072
- headers: { "content-type": "application/x-www-form-urlencoded" },
1073
- body: body.toString(),
1081
+ headers: request.headers,
1082
+ body: request.body,
1074
1083
  signal: AbortSignal.timeout(15_000),
1075
1084
  }, { sameOrigin: true });
1076
1085
  } catch (err) {
@@ -1082,13 +1091,8 @@ async function exchange(config: OAuth2Config, cacheKey: string): Promise<OAuthLi
1082
1091
  break;
1083
1092
  }
1084
1093
  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
- };
1094
+ // JSON or form-encoded, Slack's nested user token, expires_in as a string.
1095
+ const payload = readTokenResponse(await res.text().catch(() => ""), res.headers.get("content-type") ?? "");
1092
1096
  if (!res.ok || !payload.access_token) {
1093
1097
  throw new Error(
1094
1098
  `token refresh failed (${res.status}): ${payload.error_description ?? payload.error ?? "no access_token in the reply"}`,