@openparachute/hub 0.7.1 → 0.7.2-rc.1
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/README.md +13 -14
- package/package.json +1 -1
- package/src/__tests__/admin-agent-grants.test.ts +1547 -0
- package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
- package/src/__tests__/admin-connections-credentials.test.ts +8 -4
- package/src/__tests__/admin-connections.test.ts +211 -57
- package/src/__tests__/admin-csrf-belt.test.ts +7 -7
- package/src/__tests__/admin-lock.test.ts +600 -0
- package/src/__tests__/admin-module-token.test.ts +36 -8
- package/src/__tests__/admin-vaults.test.ts +8 -8
- package/src/__tests__/api-modules-ops.test.ts +17 -16
- package/src/__tests__/api-modules.test.ts +35 -36
- package/src/__tests__/api-ready.test.ts +2 -2
- package/src/__tests__/clients.test.ts +91 -0
- package/src/__tests__/grants-store.test.ts +219 -0
- package/src/__tests__/hub-server.test.ts +9 -5
- package/src/__tests__/migrate.test.ts +1 -1
- package/src/__tests__/module-manifest.test.ts +11 -11
- package/src/__tests__/oauth-client.test.ts +446 -0
- package/src/__tests__/oauth-flows-store.test.ts +141 -0
- package/src/__tests__/oauth-handlers.test.ts +124 -26
- package/src/__tests__/operator-token.test.ts +2 -2
- package/src/__tests__/scope-explanations.test.ts +3 -3
- package/src/__tests__/serve-boot.test.ts +14 -14
- package/src/__tests__/serve.test.ts +26 -0
- package/src/__tests__/service-spec-discovery.test.ts +26 -18
- package/src/__tests__/services-manifest.test.ts +60 -48
- package/src/__tests__/setup-gate.test.ts +52 -3
- package/src/__tests__/setup-wizard.test.ts +86 -280
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/upgrade.test.ts +276 -0
- package/src/__tests__/vault-remove.test.ts +393 -0
- package/src/admin-agent-grants.ts +1365 -0
- package/src/admin-agent-token.ts +147 -0
- package/src/admin-connections.ts +67 -50
- package/src/admin-host-admin-token.ts +14 -1
- package/src/admin-lock.ts +281 -0
- package/src/admin-module-token.ts +15 -7
- package/src/admin-vault-admin-token.ts +8 -1
- package/src/admin-vaults.ts +12 -12
- package/src/api-admin-lock.ts +335 -0
- package/src/api-modules-ops.ts +3 -2
- package/src/api-modules.ts +9 -9
- package/src/cli.ts +13 -1
- package/src/clients.ts +88 -0
- package/src/commands/install.ts +7 -0
- package/src/commands/serve-boot.ts +5 -4
- package/src/commands/serve.ts +45 -19
- package/src/commands/setup.ts +4 -3
- package/src/commands/upgrade.ts +118 -2
- package/src/commands/vault-remove.ts +361 -0
- package/src/commands/wizard.ts +4 -4
- package/src/connections-store.ts +3 -3
- package/src/grants-store.ts +272 -0
- package/src/help.ts +4 -1
- package/src/hub-server.ts +209 -27
- package/src/hub-settings.ts +23 -8
- package/src/jwt-sign.ts +5 -1
- package/src/module-manifest.ts +2 -2
- package/src/oauth-client.ts +497 -0
- package/src/oauth-flows-store.ts +163 -0
- package/src/oauth-handlers.ts +40 -13
- package/src/operator-token.ts +1 -1
- package/src/origin-check.ts +7 -2
- package/src/resource-binding.ts +4 -4
- package/src/scope-explanations.ts +3 -3
- package/src/service-spec.ts +56 -43
- package/src/setup-wizard.ts +56 -240
- package/web/ui/dist/assets/index-B5AUE359.js +61 -0
- package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/src/admin-channel-token.ts +0 -135
- package/web/ui/dist/assets/index-Cxtod68O.js +0 -61
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hub-as-OAuth-CLIENT engine (Phase 4b-2, agent-connectors design 2026-06-18).
|
|
3
|
+
*
|
|
4
|
+
* A Parachute hub is a spec-compliant OAuth *issuer* (RFC 8414 / 9728 / 7591,
|
|
5
|
+
* PKCE S256, authorization_code + refresh_token). 4b-2 makes THIS hub act as a
|
|
6
|
+
* *client* of ANOTHER hub's issuer (or any RFC-compliant MCP issuer): discover →
|
|
7
|
+
* dynamic-client-register → build an authorize URL the operator's browser
|
|
8
|
+
* follows → exchange the returned code → refresh / revoke headlessly.
|
|
9
|
+
*
|
|
10
|
+
* Every function takes an injected `fetchFn` so the engine is fully unit-testable
|
|
11
|
+
* offline (the tests mock all network). The default is the global `fetch`.
|
|
12
|
+
*
|
|
13
|
+
* Security: the PKCE verifier + every token are SECRETS — this module never logs
|
|
14
|
+
* them. Callers persist them only to the 0600 flow / grant stores.
|
|
15
|
+
*/
|
|
16
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
17
|
+
|
|
18
|
+
/** A typed error for any OAuth-client failure (discovery, DCR, token exchange). */
|
|
19
|
+
export class OAuthClientError extends Error {
|
|
20
|
+
constructor(message: string, cause?: unknown) {
|
|
21
|
+
super(message, cause !== undefined ? { cause } : undefined);
|
|
22
|
+
this.name = "OAuthClientError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type FetchFn = typeof fetch;
|
|
27
|
+
|
|
28
|
+
/** Default outbound timeout for every OAuth-client request. */
|
|
29
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `fetch` with an AbortController timeout. The hub has no shared fetch wrapper,
|
|
33
|
+
* so this lives here for all outbound OAuth-client calls — a slow/hung remote
|
|
34
|
+
* issuer must not stall the approve request indefinitely.
|
|
35
|
+
*/
|
|
36
|
+
export async function fetchWithTimeout(
|
|
37
|
+
url: string,
|
|
38
|
+
init: (RequestInit & { timeout?: number }) | undefined = undefined,
|
|
39
|
+
fetchFn: FetchFn = fetch,
|
|
40
|
+
): Promise<Response> {
|
|
41
|
+
const { timeout = DEFAULT_TIMEOUT_MS, ...rest } = init ?? {};
|
|
42
|
+
const controller = new AbortController();
|
|
43
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
44
|
+
try {
|
|
45
|
+
return await fetchFn(url, { ...rest, signal: controller.signal });
|
|
46
|
+
} finally {
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ===========================================================================
|
|
52
|
+
// PKCE
|
|
53
|
+
// ===========================================================================
|
|
54
|
+
|
|
55
|
+
/** Generate a PKCE code verifier — 32 random bytes, base64url. SECRET. */
|
|
56
|
+
export function generateCodeVerifier(): string {
|
|
57
|
+
return randomBytes(32).toString("base64url");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** S256 challenge for a verifier (matches `auth-codes.ts:verifyPkce`). */
|
|
61
|
+
export function generateCodeChallenge(verifier: string): string {
|
|
62
|
+
return createHash("sha256").update(verifier).digest("base64url");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Random single-use `state` — 32 random bytes, base64url. */
|
|
66
|
+
export function generateState(): string {
|
|
67
|
+
return randomBytes(32).toString("base64url");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ===========================================================================
|
|
71
|
+
// Discovery (RFC 9728 → RFC 8414)
|
|
72
|
+
// ===========================================================================
|
|
73
|
+
|
|
74
|
+
export interface DiscoveryResult {
|
|
75
|
+
readonly issuer: string;
|
|
76
|
+
readonly authorizationEndpoint: string;
|
|
77
|
+
readonly tokenEndpoint: string;
|
|
78
|
+
readonly registrationEndpoint?: string;
|
|
79
|
+
readonly revocationEndpoint?: string;
|
|
80
|
+
/** From RFC 9728 protected-resource metadata, falling back to RFC 8414. */
|
|
81
|
+
readonly scopesSupported?: readonly string[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function originOf(url: string): string {
|
|
85
|
+
try {
|
|
86
|
+
return new URL(url).origin;
|
|
87
|
+
} catch {
|
|
88
|
+
throw new OAuthClientError(`invalid mcp url: ${url}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function fetchJson(
|
|
93
|
+
url: string,
|
|
94
|
+
fetchFn: FetchFn,
|
|
95
|
+
what: string,
|
|
96
|
+
): Promise<Record<string, unknown>> {
|
|
97
|
+
let res: Response;
|
|
98
|
+
try {
|
|
99
|
+
res = await fetchWithTimeout(url, { headers: { accept: "application/json" } }, fetchFn);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
throw new OAuthClientError(`${what}: request to ${url} failed`, err);
|
|
102
|
+
}
|
|
103
|
+
if (!res.ok) {
|
|
104
|
+
throw new OAuthClientError(`${what}: ${url} returned ${res.status}`);
|
|
105
|
+
}
|
|
106
|
+
let body: unknown;
|
|
107
|
+
try {
|
|
108
|
+
body = await res.json();
|
|
109
|
+
} catch (err) {
|
|
110
|
+
throw new OAuthClientError(`${what}: ${url} returned non-JSON`, err);
|
|
111
|
+
}
|
|
112
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
113
|
+
throw new OAuthClientError(`${what}: ${url} returned a non-object body`);
|
|
114
|
+
}
|
|
115
|
+
return body as Record<string, unknown>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function asString(v: unknown): string | undefined {
|
|
119
|
+
return typeof v === "string" && v.length > 0 ? v : undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function asStringArray(v: unknown): string[] | undefined {
|
|
123
|
+
if (!Array.isArray(v)) return undefined;
|
|
124
|
+
const out = v.filter((x): x is string => typeof x === "string");
|
|
125
|
+
return out.length > 0 ? out : undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Discover the issuer + endpoints for a remote MCP URL.
|
|
130
|
+
*
|
|
131
|
+
* 1. RFC 9728: GET <mcp-origin>/.well-known/oauth-protected-resource →
|
|
132
|
+
* authorization_servers[0] = issuer (+ scopes_supported).
|
|
133
|
+
* 2. RFC 8414: GET <issuer>/.well-known/oauth-authorization-server →
|
|
134
|
+
* authorization_endpoint, token_endpoint, registration_endpoint,
|
|
135
|
+
* revocation_endpoint (+ scopes_supported fallback).
|
|
136
|
+
*
|
|
137
|
+
* If the resource has no 9728 doc, falls back to treating the MCP origin itself
|
|
138
|
+
* as the issuer and reading its 8414 doc directly (8414-only resources).
|
|
139
|
+
*/
|
|
140
|
+
export async function discover(mcpUrl: string, fetchFn: FetchFn = fetch): Promise<DiscoveryResult> {
|
|
141
|
+
const origin = originOf(mcpUrl);
|
|
142
|
+
|
|
143
|
+
// Step 1 — RFC 9728 (best-effort; some resources only expose 8414).
|
|
144
|
+
let issuer: string | undefined;
|
|
145
|
+
let prScopes: string[] | undefined;
|
|
146
|
+
try {
|
|
147
|
+
const pr = await fetchJson(
|
|
148
|
+
`${origin}/.well-known/oauth-protected-resource`,
|
|
149
|
+
fetchFn,
|
|
150
|
+
"protected-resource discovery",
|
|
151
|
+
);
|
|
152
|
+
const servers = asStringArray(pr.authorization_servers);
|
|
153
|
+
issuer = servers?.[0];
|
|
154
|
+
prScopes = asStringArray(pr.scopes_supported);
|
|
155
|
+
} catch {
|
|
156
|
+
// No 9728 doc — fall back to the MCP origin as issuer.
|
|
157
|
+
issuer = undefined;
|
|
158
|
+
}
|
|
159
|
+
if (!issuer) issuer = origin;
|
|
160
|
+
|
|
161
|
+
// Step 2 — RFC 8414 on the issuer.
|
|
162
|
+
const as = await fetchJson(
|
|
163
|
+
`${issuer.replace(/\/+$/, "")}/.well-known/oauth-authorization-server`,
|
|
164
|
+
fetchFn,
|
|
165
|
+
"authorization-server discovery",
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const authorizationEndpoint = asString(as.authorization_endpoint);
|
|
169
|
+
const tokenEndpoint = asString(as.token_endpoint);
|
|
170
|
+
if (!authorizationEndpoint || !tokenEndpoint) {
|
|
171
|
+
throw new OAuthClientError(
|
|
172
|
+
"authorization-server metadata missing authorization_endpoint or token_endpoint",
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
const asIssuer = asString(as.issuer) ?? issuer;
|
|
176
|
+
const asScopes = asStringArray(as.scopes_supported);
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
issuer: asIssuer,
|
|
180
|
+
authorizationEndpoint,
|
|
181
|
+
tokenEndpoint,
|
|
182
|
+
...(asString(as.registration_endpoint)
|
|
183
|
+
? { registrationEndpoint: asString(as.registration_endpoint) }
|
|
184
|
+
: {}),
|
|
185
|
+
...(asString(as.revocation_endpoint)
|
|
186
|
+
? { revocationEndpoint: asString(as.revocation_endpoint) }
|
|
187
|
+
: {}),
|
|
188
|
+
// 9728 scopes win (they describe THIS resource); 8414 is the fallback.
|
|
189
|
+
...((prScopes ?? asScopes) ? { scopesSupported: prScopes ?? asScopes } : {}),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ===========================================================================
|
|
194
|
+
// Least-privilege scope derivation for Parachute-vault MCP URLs (#671)
|
|
195
|
+
// ===========================================================================
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* `…/vault/<name>/mcp` — a Parachute vault MCP endpoint. Anchored: the path must
|
|
199
|
+
* END at `/mcp` right after the vault name so a longer path
|
|
200
|
+
* (`/vault/x/mcp/extra`) doesn't masquerade as a vault MCP. `<name>` is the
|
|
201
|
+
* vault-name charset (the same conservative slug `validateVaultName` enforces —
|
|
202
|
+
* a non-empty run of letters/digits/`._-`), captured group 1.
|
|
203
|
+
*/
|
|
204
|
+
const VAULT_MCP_PATH_RE = /\/vault\/([a-z0-9][a-z0-9._-]*)\/mcp\/?$/i;
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Derive the least-privilege OAuth scope to request when starting an mcp-grant
|
|
208
|
+
* OAuth flow against a remote MCP URL (#671).
|
|
209
|
+
*
|
|
210
|
+
* The OAuth-start path historically requested the resource's ENTIRE advertised
|
|
211
|
+
* `scopes_supported`. For a Parachute vault MCP that set includes broad scopes
|
|
212
|
+
* (`hub:admin`, `vault:<name>:write`, …) — wildly over-privileged for an agent
|
|
213
|
+
* that only needs to READ the vault. So when the target URL is a Parachute vault
|
|
214
|
+
* MCP (`…/vault/<name>/mcp`), request a single least-privilege
|
|
215
|
+
* `vault:<name>:read` scope instead of the full set. Write is a deliberate
|
|
216
|
+
* future knob, not the default.
|
|
217
|
+
*
|
|
218
|
+
* For any non-vault-shaped MCP URL (or one with no parseable vault name), returns
|
|
219
|
+
* `null` — the caller keeps the existing behavior (request `scopes_supported`, or
|
|
220
|
+
* omit `scope` when the resource advertises none).
|
|
221
|
+
*/
|
|
222
|
+
export function deriveVaultScopeFromMcpUrl(mcpUrl: string): string | null {
|
|
223
|
+
let parsed: URL;
|
|
224
|
+
try {
|
|
225
|
+
parsed = new URL(mcpUrl);
|
|
226
|
+
} catch {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
const match = VAULT_MCP_PATH_RE.exec(parsed.pathname);
|
|
230
|
+
const name = match?.[1];
|
|
231
|
+
if (!name) return null;
|
|
232
|
+
return `vault:${name}:read`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ===========================================================================
|
|
236
|
+
// Dynamic client registration (RFC 7591)
|
|
237
|
+
// ===========================================================================
|
|
238
|
+
|
|
239
|
+
export interface RegisterResult {
|
|
240
|
+
readonly clientId: string;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Register THIS hub as a public OAuth client at the issuer's DCR endpoint. */
|
|
244
|
+
export async function registerClient(
|
|
245
|
+
registrationEndpoint: string,
|
|
246
|
+
redirectUri: string,
|
|
247
|
+
fetchFn: FetchFn = fetch,
|
|
248
|
+
): Promise<RegisterResult> {
|
|
249
|
+
let res: Response;
|
|
250
|
+
try {
|
|
251
|
+
res = await fetchWithTimeout(
|
|
252
|
+
registrationEndpoint,
|
|
253
|
+
{
|
|
254
|
+
method: "POST",
|
|
255
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
256
|
+
body: JSON.stringify({
|
|
257
|
+
client_name: "Parachute Hub (agent connector)",
|
|
258
|
+
redirect_uris: [redirectUri],
|
|
259
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
260
|
+
response_types: ["code"],
|
|
261
|
+
token_endpoint_auth_method: "none",
|
|
262
|
+
}),
|
|
263
|
+
},
|
|
264
|
+
fetchFn,
|
|
265
|
+
);
|
|
266
|
+
} catch (err) {
|
|
267
|
+
throw new OAuthClientError("dynamic client registration request failed", err);
|
|
268
|
+
}
|
|
269
|
+
if (!res.ok) {
|
|
270
|
+
throw new OAuthClientError(`dynamic client registration returned ${res.status}`);
|
|
271
|
+
}
|
|
272
|
+
let body: unknown;
|
|
273
|
+
try {
|
|
274
|
+
body = await res.json();
|
|
275
|
+
} catch (err) {
|
|
276
|
+
throw new OAuthClientError("dynamic client registration returned non-JSON", err);
|
|
277
|
+
}
|
|
278
|
+
const clientId = asString((body as Record<string, unknown>)?.client_id);
|
|
279
|
+
if (!clientId) {
|
|
280
|
+
throw new OAuthClientError("dynamic client registration response missing client_id");
|
|
281
|
+
}
|
|
282
|
+
return { clientId };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ===========================================================================
|
|
286
|
+
// Authorize URL
|
|
287
|
+
// ===========================================================================
|
|
288
|
+
|
|
289
|
+
export interface BuildAuthorizeUrlOpts {
|
|
290
|
+
readonly authorizationEndpoint: string;
|
|
291
|
+
readonly clientId: string;
|
|
292
|
+
readonly redirectUri: string;
|
|
293
|
+
readonly scope?: string;
|
|
294
|
+
readonly state: string;
|
|
295
|
+
readonly codeChallenge: string;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Build the authorize URL the operator's browser follows (PKCE S256). */
|
|
299
|
+
export function buildAuthorizeUrl(opts: BuildAuthorizeUrlOpts): string {
|
|
300
|
+
const url = new URL(opts.authorizationEndpoint);
|
|
301
|
+
url.searchParams.set("response_type", "code");
|
|
302
|
+
url.searchParams.set("client_id", opts.clientId);
|
|
303
|
+
url.searchParams.set("redirect_uri", opts.redirectUri);
|
|
304
|
+
if (opts.scope) url.searchParams.set("scope", opts.scope);
|
|
305
|
+
url.searchParams.set("state", opts.state);
|
|
306
|
+
url.searchParams.set("code_challenge", opts.codeChallenge);
|
|
307
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
308
|
+
return url.toString();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ===========================================================================
|
|
312
|
+
// Token exchange + refresh
|
|
313
|
+
// ===========================================================================
|
|
314
|
+
|
|
315
|
+
export interface TokenResult {
|
|
316
|
+
readonly access_token: string;
|
|
317
|
+
readonly refresh_token?: string;
|
|
318
|
+
/** ISO — computed from `expires_in` at exchange time. Absent if not advertised. */
|
|
319
|
+
readonly expiresAt?: string;
|
|
320
|
+
readonly scope?: string;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function postToken(
|
|
324
|
+
tokenEndpoint: string,
|
|
325
|
+
form: Record<string, string>,
|
|
326
|
+
fetchFn: FetchFn,
|
|
327
|
+
now: () => Date,
|
|
328
|
+
): Promise<TokenResult> {
|
|
329
|
+
const body = new URLSearchParams(form);
|
|
330
|
+
let res: Response;
|
|
331
|
+
try {
|
|
332
|
+
res = await fetchWithTimeout(
|
|
333
|
+
tokenEndpoint,
|
|
334
|
+
{
|
|
335
|
+
method: "POST",
|
|
336
|
+
headers: {
|
|
337
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
338
|
+
accept: "application/json",
|
|
339
|
+
},
|
|
340
|
+
body: body.toString(),
|
|
341
|
+
},
|
|
342
|
+
fetchFn,
|
|
343
|
+
);
|
|
344
|
+
} catch (err) {
|
|
345
|
+
throw new OAuthClientError("token request failed", err);
|
|
346
|
+
}
|
|
347
|
+
let parsed: Record<string, unknown> = {};
|
|
348
|
+
try {
|
|
349
|
+
parsed = (await res.json()) as Record<string, unknown>;
|
|
350
|
+
} catch {
|
|
351
|
+
// fall through — handled by the !res.ok branch / missing-token branch
|
|
352
|
+
}
|
|
353
|
+
if (!res.ok) {
|
|
354
|
+
const err = asString(parsed.error) ?? `http ${res.status}`;
|
|
355
|
+
const desc = asString(parsed.error_description);
|
|
356
|
+
throw new OAuthClientError(`token endpoint error: ${err}${desc ? ` (${desc})` : ""}`);
|
|
357
|
+
}
|
|
358
|
+
const accessToken = asString(parsed.access_token);
|
|
359
|
+
if (!accessToken) {
|
|
360
|
+
throw new OAuthClientError("token response missing access_token");
|
|
361
|
+
}
|
|
362
|
+
let expiresAt: string | undefined;
|
|
363
|
+
const expiresIn = parsed.expires_in;
|
|
364
|
+
if (typeof expiresIn === "number" && Number.isFinite(expiresIn)) {
|
|
365
|
+
expiresAt = new Date(now().getTime() + expiresIn * 1000).toISOString();
|
|
366
|
+
}
|
|
367
|
+
return {
|
|
368
|
+
access_token: accessToken,
|
|
369
|
+
...(asString(parsed.refresh_token) ? { refresh_token: asString(parsed.refresh_token) } : {}),
|
|
370
|
+
...(expiresAt ? { expiresAt } : {}),
|
|
371
|
+
...(asString(parsed.scope) ? { scope: asString(parsed.scope) } : {}),
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export interface ExchangeCodeOpts {
|
|
376
|
+
readonly tokenEndpoint: string;
|
|
377
|
+
readonly code: string;
|
|
378
|
+
readonly redirectUri: string;
|
|
379
|
+
readonly codeVerifier: string;
|
|
380
|
+
readonly clientId: string;
|
|
381
|
+
/** Test seam for the clock (expiresAt computation). */
|
|
382
|
+
readonly now?: () => Date;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Exchange an authorization code for tokens (PKCE). */
|
|
386
|
+
export function exchangeCode(
|
|
387
|
+
opts: ExchangeCodeOpts,
|
|
388
|
+
fetchFn: FetchFn = fetch,
|
|
389
|
+
): Promise<TokenResult> {
|
|
390
|
+
return postToken(
|
|
391
|
+
opts.tokenEndpoint,
|
|
392
|
+
{
|
|
393
|
+
grant_type: "authorization_code",
|
|
394
|
+
code: opts.code,
|
|
395
|
+
redirect_uri: opts.redirectUri,
|
|
396
|
+
code_verifier: opts.codeVerifier,
|
|
397
|
+
client_id: opts.clientId,
|
|
398
|
+
},
|
|
399
|
+
fetchFn,
|
|
400
|
+
opts.now ?? (() => new Date()),
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export interface RefreshTokenOpts {
|
|
405
|
+
readonly tokenEndpoint: string;
|
|
406
|
+
readonly refreshToken: string;
|
|
407
|
+
readonly clientId: string;
|
|
408
|
+
readonly now?: () => Date;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Refresh an access token. */
|
|
412
|
+
export function refreshToken(
|
|
413
|
+
opts: RefreshTokenOpts,
|
|
414
|
+
fetchFn: FetchFn = fetch,
|
|
415
|
+
): Promise<TokenResult> {
|
|
416
|
+
return postToken(
|
|
417
|
+
opts.tokenEndpoint,
|
|
418
|
+
{
|
|
419
|
+
grant_type: "refresh_token",
|
|
420
|
+
refresh_token: opts.refreshToken,
|
|
421
|
+
client_id: opts.clientId,
|
|
422
|
+
},
|
|
423
|
+
fetchFn,
|
|
424
|
+
opts.now ?? (() => new Date()),
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ===========================================================================
|
|
429
|
+
// Revocation (RFC 7009) — best-effort
|
|
430
|
+
// ===========================================================================
|
|
431
|
+
|
|
432
|
+
export interface RevokeRemoteOpts {
|
|
433
|
+
readonly revocationEndpoint: string;
|
|
434
|
+
readonly refreshToken: string;
|
|
435
|
+
readonly clientId: string;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Best-effort revoke the refresh token at the issuer's revocation endpoint.
|
|
440
|
+
* Swallows all errors — revocation is a courtesy; a failure must not block the
|
|
441
|
+
* local teardown (the grant material is dropped regardless).
|
|
442
|
+
*/
|
|
443
|
+
export async function revokeRemote(
|
|
444
|
+
opts: RevokeRemoteOpts,
|
|
445
|
+
fetchFn: FetchFn = fetch,
|
|
446
|
+
): Promise<void> {
|
|
447
|
+
try {
|
|
448
|
+
await fetchWithTimeout(
|
|
449
|
+
opts.revocationEndpoint,
|
|
450
|
+
{
|
|
451
|
+
method: "POST",
|
|
452
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
453
|
+
body: new URLSearchParams({
|
|
454
|
+
token: opts.refreshToken,
|
|
455
|
+
token_type_hint: "refresh_token",
|
|
456
|
+
client_id: opts.clientId,
|
|
457
|
+
}).toString(),
|
|
458
|
+
},
|
|
459
|
+
fetchFn,
|
|
460
|
+
);
|
|
461
|
+
} catch {
|
|
462
|
+
// Best-effort — never throw.
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* The OAuth-client module surface, gathered as an interface so it can be injected
|
|
468
|
+
* into the grants handler for testing (a fake replaces the real network calls).
|
|
469
|
+
*/
|
|
470
|
+
export interface OAuthClient {
|
|
471
|
+
generateCodeVerifier(): string;
|
|
472
|
+
generateCodeChallenge(verifier: string): string;
|
|
473
|
+
generateState(): string;
|
|
474
|
+
discover(mcpUrl: string, fetchFn?: FetchFn): Promise<DiscoveryResult>;
|
|
475
|
+
registerClient(
|
|
476
|
+
registrationEndpoint: string,
|
|
477
|
+
redirectUri: string,
|
|
478
|
+
fetchFn?: FetchFn,
|
|
479
|
+
): Promise<RegisterResult>;
|
|
480
|
+
buildAuthorizeUrl(opts: BuildAuthorizeUrlOpts): string;
|
|
481
|
+
exchangeCode(opts: ExchangeCodeOpts, fetchFn?: FetchFn): Promise<TokenResult>;
|
|
482
|
+
refreshToken(opts: RefreshTokenOpts, fetchFn?: FetchFn): Promise<TokenResult>;
|
|
483
|
+
revokeRemote(opts: RevokeRemoteOpts, fetchFn?: FetchFn): Promise<void>;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** The real OAuth-client implementation (the default injected into the handler). */
|
|
487
|
+
export const realOAuthClient: OAuthClient = {
|
|
488
|
+
generateCodeVerifier,
|
|
489
|
+
generateCodeChallenge,
|
|
490
|
+
generateState,
|
|
491
|
+
discover,
|
|
492
|
+
registerClient,
|
|
493
|
+
buildAuthorizeUrl,
|
|
494
|
+
exchangeCode,
|
|
495
|
+
refreshToken,
|
|
496
|
+
revokeRemote,
|
|
497
|
+
};
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agent-oauth-flows.json` — the persisted registry of **in-flight agent-grant
|
|
3
|
+
* OAuth consent flows** (Phase 4b-2, agent-connectors design 2026-06-18).
|
|
4
|
+
*
|
|
5
|
+
* When the operator approves a `kind:mcp` grant that speaks OAuth, the hub (as
|
|
6
|
+
* the OAuth CLIENT) starts a consent flow: it mints a PKCE verifier + a random
|
|
7
|
+
* `state`, persists a record HERE keyed by `state`, and returns an authorize URL
|
|
8
|
+
* the operator's browser follows. The remote issuer redirects back to
|
|
9
|
+
* `GET /oauth/agent-grant/callback?code=&state=`; that handler looks the record
|
|
10
|
+
* up by `state` (single-use, delete-on-use), exchanges the code, and stores the
|
|
11
|
+
* grant material.
|
|
12
|
+
*
|
|
13
|
+
* Why ON-DISK (not in-memory): the 10-minute consent window can span a hub
|
|
14
|
+
* restart (the supervisor may restart the hub mid-consent). An in-memory map
|
|
15
|
+
* would orphan the `state` on restart — the callback would then fail to find the
|
|
16
|
+
* flow and the operator would have to re-approve.
|
|
17
|
+
*
|
|
18
|
+
* The `verifier` is a PKCE SECRET — this file is written 0600 (same discipline
|
|
19
|
+
* as `agent-grants.json`), and the verifier is NEVER logged, NEVER returned.
|
|
20
|
+
*
|
|
21
|
+
* One file, a flat array. Cardinality is "a handful of concurrent consents", and
|
|
22
|
+
* stale records self-prune on every read/write (10-min TTL).
|
|
23
|
+
*/
|
|
24
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
25
|
+
import { dirname } from "node:path";
|
|
26
|
+
|
|
27
|
+
/** TTL for a pending consent flow — 10 minutes (spans a possible hub restart). */
|
|
28
|
+
export const FLOW_TTL_MS = 10 * 60 * 1000;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* An in-flight OAuth consent flow, bound to a single-use `state`. Persisted so a
|
|
32
|
+
* mid-consent hub restart doesn't orphan the `state`.
|
|
33
|
+
*/
|
|
34
|
+
export interface PendingFlow {
|
|
35
|
+
/** Single-use CSRF token — the lookup key + the callback's only gate. */
|
|
36
|
+
readonly state: string;
|
|
37
|
+
/** The grant this consent will populate on success. */
|
|
38
|
+
readonly grantId: string;
|
|
39
|
+
/** Discovered issuer (for the authorize/token endpoints). */
|
|
40
|
+
readonly issuer: string;
|
|
41
|
+
/** DCR client_id (or a reused one for the same issuer). */
|
|
42
|
+
readonly clientId: string;
|
|
43
|
+
/** Discovered token endpoint — where the callback exchanges the code. */
|
|
44
|
+
readonly tokenEndpoint: string;
|
|
45
|
+
/** Discovered revocation endpoint (cached for the eventual revoke). */
|
|
46
|
+
readonly revocationEndpoint?: string;
|
|
47
|
+
/** PKCE verifier — a SECRET. 0600 at rest; never logged, never returned. */
|
|
48
|
+
readonly verifier: string;
|
|
49
|
+
/** The remote MCP URL the grant connects to (stored into material on success). */
|
|
50
|
+
readonly mcpUrl: string;
|
|
51
|
+
/** Requested scope (space-joined), or absent to let the issuer default. */
|
|
52
|
+
readonly scope?: string;
|
|
53
|
+
/** The hub's callback URL registered for this flow. */
|
|
54
|
+
readonly redirectUri: string;
|
|
55
|
+
readonly createdAt: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface FlowsFile {
|
|
59
|
+
flows: PendingFlow[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isPendingFlow(v: unknown): v is PendingFlow {
|
|
63
|
+
if (!v || typeof v !== "object") return false;
|
|
64
|
+
const f = v as Record<string, unknown>;
|
|
65
|
+
return (
|
|
66
|
+
typeof f.state === "string" &&
|
|
67
|
+
typeof f.grantId === "string" &&
|
|
68
|
+
typeof f.issuer === "string" &&
|
|
69
|
+
typeof f.clientId === "string" &&
|
|
70
|
+
typeof f.tokenEndpoint === "string" &&
|
|
71
|
+
typeof f.verifier === "string" &&
|
|
72
|
+
typeof f.mcpUrl === "string" &&
|
|
73
|
+
typeof f.redirectUri === "string" &&
|
|
74
|
+
typeof f.createdAt === "string"
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Read the store. A missing/garbage file reads as empty (mirrors grants-store). */
|
|
79
|
+
function readAll(storePath: string): PendingFlow[] {
|
|
80
|
+
let buf: string;
|
|
81
|
+
try {
|
|
82
|
+
buf = readFileSync(storePath, "utf8");
|
|
83
|
+
} catch {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
let parsed: unknown;
|
|
87
|
+
try {
|
|
88
|
+
parsed = JSON.parse(buf);
|
|
89
|
+
} catch {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return [];
|
|
93
|
+
const arr = (parsed as { flows?: unknown }).flows;
|
|
94
|
+
if (!Array.isArray(arr)) return [];
|
|
95
|
+
return arr.filter(isPendingFlow);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function writeAll(storePath: string, flows: PendingFlow[]): void {
|
|
99
|
+
mkdirSync(dirname(storePath), { recursive: true });
|
|
100
|
+
const file: FlowsFile = { flows };
|
|
101
|
+
// 0600 — this file holds PKCE verifiers (secrets). `writeFileSync`'s `mode`
|
|
102
|
+
// applies at CREATE time, so a fresh file is never world-readable even for an
|
|
103
|
+
// instant; the chmodSync is belt-and-suspenders for the re-write case.
|
|
104
|
+
writeFileSync(storePath, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 });
|
|
105
|
+
try {
|
|
106
|
+
chmodSync(storePath, 0o600);
|
|
107
|
+
} catch {
|
|
108
|
+
// Best-effort on platforms without POSIX perms.
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Drop flows older than `ttlMs` relative to `now`. Returns the survivors. */
|
|
113
|
+
function prune(flows: PendingFlow[], now: number, ttlMs: number): PendingFlow[] {
|
|
114
|
+
return flows.filter((f) => {
|
|
115
|
+
const created = new Date(f.createdAt).getTime();
|
|
116
|
+
if (!Number.isFinite(created)) return false;
|
|
117
|
+
return now - created < ttlMs;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Opportunistically prune expired flows + persist if anything was dropped.
|
|
123
|
+
* Called inside put/get so stale records never accumulate.
|
|
124
|
+
*/
|
|
125
|
+
export function pruneExpiredFlows(
|
|
126
|
+
storePath: string,
|
|
127
|
+
now = Date.now(),
|
|
128
|
+
ttlMs = FLOW_TTL_MS,
|
|
129
|
+
): PendingFlow[] {
|
|
130
|
+
const all = readAll(storePath);
|
|
131
|
+
const live = prune(all, now, ttlMs);
|
|
132
|
+
if (live.length !== all.length) writeAll(storePath, live);
|
|
133
|
+
return live;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Persist a new pending flow (pruning expired ones first). Upsert by `state`. */
|
|
137
|
+
export function putFlow(storePath: string, flow: PendingFlow, now = Date.now()): void {
|
|
138
|
+
const live = prune(readAll(storePath), now, FLOW_TTL_MS);
|
|
139
|
+
const idx = live.findIndex((f) => f.state === flow.state);
|
|
140
|
+
if (idx >= 0) live[idx] = flow;
|
|
141
|
+
else live.push(flow);
|
|
142
|
+
writeAll(storePath, live);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Look up a flow by `state` (pruning expired ones first). Returns null if absent/expired. */
|
|
146
|
+
export function getFlowByState(
|
|
147
|
+
storePath: string,
|
|
148
|
+
state: string,
|
|
149
|
+
now = Date.now(),
|
|
150
|
+
): PendingFlow | null {
|
|
151
|
+
const live = pruneExpiredFlows(storePath, now);
|
|
152
|
+
return live.find((f) => f.state === state) ?? null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Delete a flow by `state` (single-use). Returns the removed flow, or null. */
|
|
156
|
+
export function deleteFlow(storePath: string, state: string): PendingFlow | null {
|
|
157
|
+
const all = readAll(storePath);
|
|
158
|
+
const idx = all.findIndex((f) => f.state === state);
|
|
159
|
+
if (idx < 0) return null;
|
|
160
|
+
const [removed] = all.splice(idx, 1);
|
|
161
|
+
writeAll(storePath, all);
|
|
162
|
+
return removed ?? null;
|
|
163
|
+
}
|