@foldrun/core 9.2.0 → 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 +1 -0
- package/dist/index.js +1 -0
- package/dist/src/oauth-token.d.ts +46 -0
- package/dist/src/oauth-token.js +164 -0
- package/dist/src/secrets.d.ts +7 -0
- package/dist/src/secrets.js +11 -6
- package/dist/src/starter.js +1 -0
- package/index.ts +1 -0
- package/package.json +1 -1
- package/src/oauth-token.ts +176 -0
- package/src/secrets.ts +16 -12
- package/src/starter.ts +1 -0
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
|
+
}
|
package/dist/src/secrets.d.ts
CHANGED
|
@@ -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. */
|
package/dist/src/secrets.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
922
|
-
body: body
|
|
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
|
-
|
|
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/dist/src/starter.js
CHANGED
|
@@ -381,6 +381,7 @@ Account-level, outside \`/workspaces\`: \`/api/secrets\`, \`/api/keys\`, \`/api/
|
|
|
381
381
|
\`/api/approvals\`, \`/api/issues\` (what went wrong, open until resolved), \`/api/usage\`, \`/api/library/<kind>\`, \`/api/team\`, \`/api/billing\`,
|
|
382
382
|
\`/api/me/notifications\` (what mail you get; a key acts for whoever minted it),
|
|
383
383
|
\`/api/me/inbox\` (what waits on you: gates, questions, issues, proposed fixes, your failed runs),
|
|
384
|
+
\`/api/mcp\` (this account as MCP tools for an AI app such as Claude Desktop or Cursor — an API key; \`foldrun mcp\` serves the same over stdio, \`foldrun docs mcp\`),
|
|
384
385
|
\`/api/account/backups\` (snapshots, restore requests), \`/api/me/onboarding\` (getting started),
|
|
385
386
|
\`/api/healthz\` and \`/api/version\` (open), \`/api/changelog\` (release notes)
|
|
386
387
|
and \`/api/metrics\` (Prometheus). Outside \`/api\`: \`/a2a/<account>/<ws>/flows/<flow>\`
|
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
|
@@ -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
|
-
|
|
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:
|
|
1073
|
-
body: body
|
|
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
|
-
|
|
1086
|
-
|
|
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"}`,
|
package/src/starter.ts
CHANGED
|
@@ -397,6 +397,7 @@ Account-level, outside \`/workspaces\`: \`/api/secrets\`, \`/api/keys\`, \`/api/
|
|
|
397
397
|
\`/api/approvals\`, \`/api/issues\` (what went wrong, open until resolved), \`/api/usage\`, \`/api/library/<kind>\`, \`/api/team\`, \`/api/billing\`,
|
|
398
398
|
\`/api/me/notifications\` (what mail you get; a key acts for whoever minted it),
|
|
399
399
|
\`/api/me/inbox\` (what waits on you: gates, questions, issues, proposed fixes, your failed runs),
|
|
400
|
+
\`/api/mcp\` (this account as MCP tools for an AI app such as Claude Desktop or Cursor — an API key; \`foldrun mcp\` serves the same over stdio, \`foldrun docs mcp\`),
|
|
400
401
|
\`/api/account/backups\` (snapshots, restore requests), \`/api/me/onboarding\` (getting started),
|
|
401
402
|
\`/api/healthz\` and \`/api/version\` (open), \`/api/changelog\` (release notes)
|
|
402
403
|
and \`/api/metrics\` (Prometheus). Outside \`/api\`: \`/a2a/<account>/<ws>/flows/<flow>\`
|