@foldrun/core 10.0.0 → 10.1.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
@@ -33,6 +33,7 @@ export * from "./src/oauth-presets.ts";
33
33
  export * from "./src/oauth-token.ts";
34
34
  export * from "./src/oauth-discovery.ts";
35
35
  export * from "./src/oauth-assertion.ts";
36
+ export * from "./src/oauth-device.ts";
36
37
  export * from "./src/secret-files.ts";
37
38
  export * from "./src/notify.ts";
38
39
  export * from "./src/webhook.ts";
package/dist/index.js CHANGED
@@ -44,6 +44,7 @@ export * from "./src/oauth-presets.js";
44
44
  export * from "./src/oauth-token.js";
45
45
  export * from "./src/oauth-discovery.js";
46
46
  export * from "./src/oauth-assertion.js";
47
+ export * from "./src/oauth-device.js";
47
48
  export * from "./src/secret-files.js";
48
49
  export * from "./src/notify.js";
49
50
  export * from "./src/webhook.js";
@@ -88,7 +88,8 @@ export const OAUTH_CAPABILITIES = [
88
88
  },
89
89
  {
90
90
  id: "client.device", role: "client", title: "Device authorization", spec: "RFC 8628",
91
- status: "planned", note: "Part F: `foldrun connect --device`.",
91
+ status: "supported", note: "Connect with a code on any device: the Connections form, or `foldrun connect --device`. PKCE is sent with the device grant.",
92
+ proof: [core("tests/oauth-device.test.ts", "pending, then slow_down"), platform("tests/oauth-connect.test.ts", "device authorization (RFC 8628): started for a saved app"), platform(KC, "device authorization (RFC 8628): started for a saved app, approved by the person at Keycloak"), web("tests/oauth-device-route.test.ts", "start → pending → approved → stored"), cli("tests/cli-connect.test.ts", "connect --device (RFC 8628)")],
92
93
  },
93
94
  {
94
95
  id: "client.token-exchange", role: "client", title: "Token exchange", spec: "RFC 8693",
@@ -149,7 +150,8 @@ export const OAUTH_CAPABILITIES = [
149
150
  },
150
151
  {
151
152
  id: "server.client-credentials", role: "server", title: "Client credentials for machine clients", spec: "RFC 6749 §4.4",
152
- status: "planned", note: "Part G: machine MCP clients an admin registers.",
153
+ status: "supported", note: "An admin registers a machine client (Settings, or `foldrun mcp clients add`) with a role, workspaces and access; no refresh token; disconnecting it makes it `invalid_client`.",
154
+ proof: [web("tests/mcp-oauth.test.ts", "machine clients (client credentials, part G)"), web("tests/mcp-oauth.test.ts", "a machine client can sign in with private_key_jwt"), cli("tests/cli-mcp.test.ts", "mcp clients add")],
153
155
  },
154
156
  {
155
157
  id: "server.private-key-jwt", role: "server", title: "Signed client assertion (private_key_jwt)", spec: "RFC 7523 §2.2",
@@ -0,0 +1,53 @@
1
+ import { type TokenClient, type TokenResponse } from "./oauth-token.ts";
2
+ export declare const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
3
+ export interface DeviceStart {
4
+ device_code: string;
5
+ user_code: string;
6
+ verification_uri: string;
7
+ /** The page with the code already in it, when the provider gives one. */
8
+ verification_uri_complete?: string;
9
+ /** Seconds the codes live. */
10
+ expires_in: number;
11
+ /** Seconds to wait between polls (default 5, RFC 8628 §3.2). */
12
+ interval: number;
13
+ /** PKCE for the device grant: sent with every poll. Keycloak requires it
14
+ * of an S256 client; a server that does not know it ignores both halves
15
+ * (found by the conformance suite). Secret: never shown to the person. */
16
+ code_verifier: string;
17
+ }
18
+ type Fetcher = (url: string, init: RequestInit) => Promise<Response>;
19
+ /** RFC 8628 §3.1–3.2: ask for a device code and a user code. */
20
+ export declare function startDeviceAuthorization(client: TokenClient & {
21
+ device_url: string;
22
+ token_url: string;
23
+ issuer?: string;
24
+ }, scopes: string, fetcher: Fetcher, extra?: Record<string, string>): Promise<DeviceStart>;
25
+ /** What one poll came to (RFC 8628 §3.5). */
26
+ export type DevicePoll = {
27
+ status: "approved";
28
+ token: TokenResponse;
29
+ } | {
30
+ status: "pending";
31
+ }
32
+ /** The provider asked for a longer wait: add 5 seconds to the interval. */
33
+ | {
34
+ status: "slow_down";
35
+ } | {
36
+ status: "denied";
37
+ reason: string;
38
+ } | {
39
+ status: "expired";
40
+ reason: string;
41
+ };
42
+ /** One token request with the device code. */
43
+ export declare function pollDeviceToken(client: TokenClient & {
44
+ token_url: string;
45
+ }, deviceCode: string, fetcher: Fetcher, codeVerifier?: string): Promise<DevicePoll>;
46
+ /** Poll until approved, denied or expired, honouring interval and slow_down. */
47
+ export declare function waitForDeviceToken(client: TokenClient & {
48
+ token_url: string;
49
+ }, start: DeviceStart, fetcher: Fetcher, opts?: {
50
+ sleep?: (ms: number) => Promise<void>;
51
+ now?: () => number;
52
+ }): Promise<TokenResponse>;
53
+ export {};
@@ -0,0 +1,112 @@
1
+ // Device authorization (RFC 8628): connect an account from somewhere with no
2
+ // browser of its own — a terminal over ssh, a server. foldrun asks the
3
+ // provider for a device code, a person opens the provider's page on any
4
+ // device and types the short user code, and foldrun polls the token endpoint
5
+ // until they have said yes (or no, or the code ran out). The grant that
6
+ // comes back is stored like any other: an oauth2 secret.
7
+ //
8
+ // One request each, so a caller can poll its own way: the CLI in a loop,
9
+ // the platform once per dashboard poll.
10
+ import crypto from "node:crypto";
11
+ import { oauthTokenRequest, readTokenResponse } from "./oauth-token.js";
12
+ export const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
13
+ /** RFC 8628 §3.1–3.2: ask for a device code and a user code. */
14
+ export async function startDeviceAuthorization(client, scopes, fetcher, extra = {}) {
15
+ // The client authenticates as at the token endpoint (§3.1); a signed
16
+ // assertion's aud is the issuer when known, else the token URL.
17
+ const verifier = crypto.randomBytes(32).toString("base64url");
18
+ const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
19
+ const request = oauthTokenRequest(client, { ...extra, ...(scopes.trim() ? { scope: scopes.trim() } : {}), code_challenge: challenge, code_challenge_method: "S256" }, { clientIdInBody: true, audience: client.issuer ?? client.token_url });
20
+ const res = await fetcher(client.device_url, { method: "POST", headers: request.headers, body: request.body, redirect: "error", signal: AbortSignal.timeout(15_000) });
21
+ const text = await res.text().catch(() => "");
22
+ let body = {};
23
+ try {
24
+ body = JSON.parse(text);
25
+ }
26
+ catch {
27
+ body = Object.fromEntries(new URLSearchParams(text));
28
+ }
29
+ if (!res.ok || typeof body.device_code !== "string" || typeof body.user_code !== "string") {
30
+ const why = (body.error_description ?? body.error ?? "no device code in the reply");
31
+ throw new Error(`the provider refused the device authorization (${res.status}): ${why}`);
32
+ }
33
+ // Some providers (Google's older endpoint) say verification_url.
34
+ const uri = (body.verification_uri ?? body.verification_url);
35
+ if (typeof uri !== "string" || !/^https?:\/\//.test(uri))
36
+ throw new Error("the provider gave no verification_uri to send the person to");
37
+ const num = (v, d) => {
38
+ const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : NaN;
39
+ return Number.isFinite(n) && n > 0 ? n : d;
40
+ };
41
+ return {
42
+ device_code: body.device_code,
43
+ user_code: body.user_code,
44
+ verification_uri: uri,
45
+ ...(typeof body.verification_uri_complete === "string" ? { verification_uri_complete: body.verification_uri_complete } : {}),
46
+ expires_in: num(body.expires_in, 600),
47
+ interval: num(body.interval, 5),
48
+ code_verifier: verifier,
49
+ };
50
+ }
51
+ /** One token request with the device code. */
52
+ export async function pollDeviceToken(client, deviceCode, fetcher, codeVerifier) {
53
+ const request = oauthTokenRequest(client, { grant_type: DEVICE_GRANT, device_code: deviceCode, ...(codeVerifier ? { code_verifier: codeVerifier } : {}) }, { audience: client.token_url });
54
+ const res = await fetcher(client.token_url, { method: "POST", headers: request.headers, body: request.body, redirect: "error", signal: AbortSignal.timeout(15_000) });
55
+ // A body cut off mid-read throws here, so the caller retries it as the
56
+ // transport failure it is — never read as an empty refusal (Codex review).
57
+ const token = readTokenResponse(await res.text(), res.headers.get("content-type") ?? "");
58
+ if (res.ok && token.access_token)
59
+ return { status: "approved", token };
60
+ switch (token.error) {
61
+ case "authorization_pending": return { status: "pending" };
62
+ case "slow_down": return { status: "slow_down" };
63
+ case "access_denied": return { status: "denied", reason: token.error_description ?? "the person said no" };
64
+ case "expired_token": return { status: "expired", reason: token.error_description ?? "the code ran out before anyone approved it" };
65
+ default:
66
+ throw new Error(`the provider refused the device token request (${res.status}): ${token.error_description ?? token.error ?? "no token in the reply"}`);
67
+ }
68
+ }
69
+ /** A failure of the wire, not of the provider: fetch's TypeError, an abort
70
+ * or a timeout. */
71
+ function isTransient(err) {
72
+ if (err instanceof TypeError)
73
+ return true;
74
+ const name = err?.name;
75
+ return name === "AbortError" || name === "TimeoutError";
76
+ }
77
+ /** Poll until approved, denied or expired, honouring interval and slow_down. */
78
+ export async function waitForDeviceToken(client, start, fetcher, opts = {}) {
79
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
80
+ const now = opts.now ?? Date.now;
81
+ const deadline = now() + start.expires_in * 1000;
82
+ let interval = start.interval;
83
+ // A client that cannot make the request (no key, a bad alg) says so now,
84
+ // not after ten minutes of retries.
85
+ oauthTokenRequest(client, { grant_type: DEVICE_GRANT, device_code: start.device_code }, { audience: client.token_url });
86
+ for (;;) {
87
+ await sleep(interval * 1000);
88
+ if (now() > deadline)
89
+ throw new Error("the code ran out before anyone approved it — run it again");
90
+ let r;
91
+ try {
92
+ r = await pollDeviceToken(client, start.device_code, fetcher, start.code_verifier);
93
+ }
94
+ catch (err) {
95
+ // Only a transport failure — a timeout, a dropped connection — is
96
+ // retried, less often (RFC 8628 §3.5); anything else is final. The
97
+ // backoff never polls faster than the interval the provider set.
98
+ if (!isTransient(err))
99
+ throw err;
100
+ interval = interval >= 60 ? interval : Math.min(interval * 2, 60);
101
+ continue;
102
+ }
103
+ if (r.status === "approved")
104
+ return r.token;
105
+ if (r.status === "slow_down")
106
+ interval += 5;
107
+ else if (r.status === "denied")
108
+ throw new Error(`the provider says no: ${r.reason}`);
109
+ else if (r.status === "expired")
110
+ throw new Error(`${r.reason} — run it again`);
111
+ }
112
+ }
package/index.ts CHANGED
@@ -45,6 +45,7 @@ export * from "./src/oauth-presets.ts";
45
45
  export * from "./src/oauth-token.ts";
46
46
  export * from "./src/oauth-discovery.ts";
47
47
  export * from "./src/oauth-assertion.ts";
48
+ export * from "./src/oauth-device.ts";
48
49
  export * from "./src/secret-files.ts";
49
50
  export * from "./src/notify.ts";
50
51
  export * from "./src/webhook.ts";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldrun/core",
3
- "version": "10.0.0",
3
+ "version": "10.1.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",
@@ -114,7 +114,8 @@ export const OAUTH_CAPABILITIES: OAuthCapability[] = [
114
114
  },
115
115
  {
116
116
  id: "client.device", role: "client", title: "Device authorization", spec: "RFC 8628",
117
- status: "planned", note: "Part F: `foldrun connect --device`.",
117
+ status: "supported", note: "Connect with a code on any device: the Connections form, or `foldrun connect --device`. PKCE is sent with the device grant.",
118
+ proof: [core("tests/oauth-device.test.ts", "pending, then slow_down"), platform("tests/oauth-connect.test.ts", "device authorization (RFC 8628): started for a saved app"), platform(KC, "device authorization (RFC 8628): started for a saved app, approved by the person at Keycloak"), web("tests/oauth-device-route.test.ts", "start → pending → approved → stored"), cli("tests/cli-connect.test.ts", "connect --device (RFC 8628)")],
118
119
  },
119
120
  {
120
121
  id: "client.token-exchange", role: "client", title: "Token exchange", spec: "RFC 8693",
@@ -175,7 +176,8 @@ export const OAUTH_CAPABILITIES: OAuthCapability[] = [
175
176
  },
176
177
  {
177
178
  id: "server.client-credentials", role: "server", title: "Client credentials for machine clients", spec: "RFC 6749 §4.4",
178
- status: "planned", note: "Part G: machine MCP clients an admin registers.",
179
+ status: "supported", note: "An admin registers a machine client (Settings, or `foldrun mcp clients add`) with a role, workspaces and access; no refresh token; disconnecting it makes it `invalid_client`.",
180
+ proof: [web("tests/mcp-oauth.test.ts", "machine clients (client credentials, part G)"), web("tests/mcp-oauth.test.ts", "a machine client can sign in with private_key_jwt"), cli("tests/cli-mcp.test.ts", "mcp clients add")],
179
181
  },
180
182
  {
181
183
  id: "server.private-key-jwt", role: "server", title: "Signed client assertion (private_key_jwt)", spec: "RFC 7523 §2.2",
@@ -0,0 +1,149 @@
1
+ // Device authorization (RFC 8628): connect an account from somewhere with no
2
+ // browser of its own — a terminal over ssh, a server. foldrun asks the
3
+ // provider for a device code, a person opens the provider's page on any
4
+ // device and types the short user code, and foldrun polls the token endpoint
5
+ // until they have said yes (or no, or the code ran out). The grant that
6
+ // comes back is stored like any other: an oauth2 secret.
7
+ //
8
+ // One request each, so a caller can poll its own way: the CLI in a loop,
9
+ // the platform once per dashboard poll.
10
+
11
+ import crypto from "node:crypto";
12
+ import { oauthTokenRequest, readTokenResponse, type TokenClient, type TokenResponse } from "./oauth-token.ts";
13
+
14
+ export const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
15
+
16
+ export interface DeviceStart {
17
+ device_code: string;
18
+ user_code: string;
19
+ verification_uri: string;
20
+ /** The page with the code already in it, when the provider gives one. */
21
+ verification_uri_complete?: string;
22
+ /** Seconds the codes live. */
23
+ expires_in: number;
24
+ /** Seconds to wait between polls (default 5, RFC 8628 §3.2). */
25
+ interval: number;
26
+ /** PKCE for the device grant: sent with every poll. Keycloak requires it
27
+ * of an S256 client; a server that does not know it ignores both halves
28
+ * (found by the conformance suite). Secret: never shown to the person. */
29
+ code_verifier: string;
30
+ }
31
+
32
+ type Fetcher = (url: string, init: RequestInit) => Promise<Response>;
33
+
34
+ /** RFC 8628 §3.1–3.2: ask for a device code and a user code. */
35
+ export async function startDeviceAuthorization(
36
+ client: TokenClient & { device_url: string; token_url: string; issuer?: string },
37
+ scopes: string,
38
+ fetcher: Fetcher,
39
+ extra: Record<string, string> = {},
40
+ ): Promise<DeviceStart> {
41
+ // The client authenticates as at the token endpoint (§3.1); a signed
42
+ // assertion's aud is the issuer when known, else the token URL.
43
+ const verifier = crypto.randomBytes(32).toString("base64url");
44
+ const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
45
+ const request = oauthTokenRequest(client, { ...extra, ...(scopes.trim() ? { scope: scopes.trim() } : {}), code_challenge: challenge, code_challenge_method: "S256" }, { clientIdInBody: true, audience: client.issuer ?? client.token_url });
46
+ const res = await fetcher(client.device_url, { method: "POST", headers: request.headers, body: request.body, redirect: "error", signal: AbortSignal.timeout(15_000) });
47
+ const text = await res.text().catch(() => "");
48
+ let body: Record<string, unknown> = {};
49
+ try {
50
+ body = JSON.parse(text) as Record<string, unknown>;
51
+ } catch {
52
+ body = Object.fromEntries(new URLSearchParams(text));
53
+ }
54
+ if (!res.ok || typeof body.device_code !== "string" || typeof body.user_code !== "string") {
55
+ const why = (body.error_description ?? body.error ?? "no device code in the reply") as string;
56
+ throw new Error(`the provider refused the device authorization (${res.status}): ${why}`);
57
+ }
58
+ // Some providers (Google's older endpoint) say verification_url.
59
+ const uri = (body.verification_uri ?? body.verification_url) as unknown;
60
+ if (typeof uri !== "string" || !/^https?:\/\//.test(uri)) throw new Error("the provider gave no verification_uri to send the person to");
61
+ const num = (v: unknown, d: number) => {
62
+ const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : NaN;
63
+ return Number.isFinite(n) && n > 0 ? n : d;
64
+ };
65
+ return {
66
+ device_code: body.device_code,
67
+ user_code: body.user_code,
68
+ verification_uri: uri,
69
+ ...(typeof body.verification_uri_complete === "string" ? { verification_uri_complete: body.verification_uri_complete } : {}),
70
+ expires_in: num(body.expires_in, 600),
71
+ interval: num(body.interval, 5),
72
+ code_verifier: verifier,
73
+ };
74
+ }
75
+
76
+ /** What one poll came to (RFC 8628 §3.5). */
77
+ export type DevicePoll =
78
+ | { status: "approved"; token: TokenResponse }
79
+ | { status: "pending" }
80
+ /** The provider asked for a longer wait: add 5 seconds to the interval. */
81
+ | { status: "slow_down" }
82
+ | { status: "denied"; reason: string }
83
+ | { status: "expired"; reason: string };
84
+
85
+ /** One token request with the device code. */
86
+ export async function pollDeviceToken(
87
+ client: TokenClient & { token_url: string },
88
+ deviceCode: string,
89
+ fetcher: Fetcher,
90
+ codeVerifier?: string,
91
+ ): Promise<DevicePoll> {
92
+ const request = oauthTokenRequest(client, { grant_type: DEVICE_GRANT, device_code: deviceCode, ...(codeVerifier ? { code_verifier: codeVerifier } : {}) }, { audience: client.token_url });
93
+ const res = await fetcher(client.token_url, { method: "POST", headers: request.headers, body: request.body, redirect: "error", signal: AbortSignal.timeout(15_000) });
94
+ // A body cut off mid-read throws here, so the caller retries it as the
95
+ // transport failure it is — never read as an empty refusal (Codex review).
96
+ const token = readTokenResponse(await res.text(), res.headers.get("content-type") ?? "");
97
+ if (res.ok && token.access_token) return { status: "approved", token };
98
+ switch (token.error) {
99
+ case "authorization_pending": return { status: "pending" };
100
+ case "slow_down": return { status: "slow_down" };
101
+ case "access_denied": return { status: "denied", reason: token.error_description ?? "the person said no" };
102
+ case "expired_token": return { status: "expired", reason: token.error_description ?? "the code ran out before anyone approved it" };
103
+ default:
104
+ throw new Error(`the provider refused the device token request (${res.status}): ${token.error_description ?? token.error ?? "no token in the reply"}`);
105
+ }
106
+ }
107
+
108
+ /** A failure of the wire, not of the provider: fetch's TypeError, an abort
109
+ * or a timeout. */
110
+ function isTransient(err: unknown): boolean {
111
+ if (err instanceof TypeError) return true;
112
+ const name = (err as { name?: unknown })?.name;
113
+ return name === "AbortError" || name === "TimeoutError";
114
+ }
115
+
116
+ /** Poll until approved, denied or expired, honouring interval and slow_down. */
117
+ export async function waitForDeviceToken(
118
+ client: TokenClient & { token_url: string },
119
+ start: DeviceStart,
120
+ fetcher: Fetcher,
121
+ opts: { sleep?: (ms: number) => Promise<void>; now?: () => number } = {},
122
+ ): Promise<TokenResponse> {
123
+ const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
124
+ const now = opts.now ?? Date.now;
125
+ const deadline = now() + start.expires_in * 1000;
126
+ let interval = start.interval;
127
+ // A client that cannot make the request (no key, a bad alg) says so now,
128
+ // not after ten minutes of retries.
129
+ oauthTokenRequest(client, { grant_type: DEVICE_GRANT, device_code: start.device_code }, { audience: client.token_url });
130
+ for (;;) {
131
+ await sleep(interval * 1000);
132
+ if (now() > deadline) throw new Error("the code ran out before anyone approved it — run it again");
133
+ let r: DevicePoll;
134
+ try {
135
+ r = await pollDeviceToken(client, start.device_code, fetcher, start.code_verifier);
136
+ } catch (err) {
137
+ // Only a transport failure — a timeout, a dropped connection — is
138
+ // retried, less often (RFC 8628 §3.5); anything else is final. The
139
+ // backoff never polls faster than the interval the provider set.
140
+ if (!isTransient(err)) throw err;
141
+ interval = interval >= 60 ? interval : Math.min(interval * 2, 60);
142
+ continue;
143
+ }
144
+ if (r.status === "approved") return r.token;
145
+ if (r.status === "slow_down") interval += 5;
146
+ else if (r.status === "denied") throw new Error(`the provider says no: ${r.reason}`);
147
+ else if (r.status === "expired") throw new Error(`${r.reason} — run it again`);
148
+ }
149
+ }