open-managed-agents 0.1.1 → 0.2.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/README.md +32 -7
- package/dist/scripts/alpha-smoke.mjs +40 -2
- package/dist/scripts/oma-doctor.js +1 -1
- package/dist/scripts/oma-models.js +20 -16
- package/dist/src/control-plane/app.js +75 -4
- package/dist/src/control-plane/events/service.js +82 -8
- package/dist/src/control-plane/models/catalog.js +29 -13
- package/dist/src/control-plane/models/credential-store.js +203 -0
- package/dist/src/control-plane/sessions/pi/mcp/client.js +5 -1
- package/dist/src/control-plane/sessions/pi/runner.js +59 -32
- package/dist/src/control-plane/sessions/pi/sandbox/container-engine.js +32 -0
- package/dist/src/control-plane/sessions/pi/sandbox/docker-egress.js +8 -0
- package/dist/src/control-plane/sessions/pi/sandbox/docker.js +20 -5
- package/dist/src/control-plane/sessions/pi/sandbox/microsandbox.js +18 -3
- package/dist/src/control-plane/sessions/pi/sandbox/provider.js +16 -9
- package/dist/src/control-plane/sessions/service.js +5 -0
- package/dist/src/control-plane/vaults/console-mcp-oauth.js +545 -0
- package/dist/src/control-plane/vaults/service.js +4 -1
- package/dist/src/control-plane/vaults/store.js +8 -2
- package/dist/src/main.js +1 -1
- package/dist/ui/managed-agents-console/README.md +22 -15
- package/dist/ui/managed-agents-console/docs/vaults.md +16 -2
- package/dist/ui/managed-agents-console/index.html +4 -0
- package/dist/ui/managed-agents-console/src/api.js +40 -0
- package/dist/ui/managed-agents-console/src/console.css +4 -1
- package/dist/ui/managed-agents-console/src/forms.jsx +79 -16
- package/dist/ui/managed-agents-console/src/session-vaults-data.js +40 -0
- package/dist/ui/managed-agents-console/src/vaults.jsx +104 -9
- package/package.json +15 -7
|
@@ -30,6 +30,7 @@ export class DefaultSessionService {
|
|
|
30
30
|
startedAt = new Date().toISOString();
|
|
31
31
|
deleteSessionRows;
|
|
32
32
|
assertDeletable;
|
|
33
|
+
awaitDeletable;
|
|
33
34
|
idempotencyLedger;
|
|
34
35
|
createSessionRowsWithIdempotency;
|
|
35
36
|
pendingSnapshotCleanupRetryDelayMs;
|
|
@@ -48,6 +49,7 @@ export class DefaultSessionService {
|
|
|
48
49
|
throw new Error("DefaultSessionService requires assertDeletable for safe session deletion");
|
|
49
50
|
}
|
|
50
51
|
this.assertDeletable = opts.assertDeletable;
|
|
52
|
+
this.awaitDeletable = opts.awaitDeletable;
|
|
51
53
|
this.maxActiveSessionsPerWorkspace = opts.maxActiveSessionsPerWorkspace;
|
|
52
54
|
this.onAdmissionRejected = opts.onAdmissionRejected;
|
|
53
55
|
this.modelAvailability = opts.modelAvailability;
|
|
@@ -348,6 +350,9 @@ export class DefaultSessionService {
|
|
|
348
350
|
// Domain-owned liveness invariant: reject deleting a running session before
|
|
349
351
|
// any row/file mutation, so no caller (route or internal) can tear down a
|
|
350
352
|
// live runtime. Synchronous, so it runs in the same tick as the row removal.
|
|
353
|
+
// A session that already reported idle may still be collecting outputs;
|
|
354
|
+
// let that settle first so a client deleting after idle is not rejected.
|
|
355
|
+
await this.awaitDeletable?.(workspaceId, sessionId);
|
|
351
356
|
this.assertDeletable(workspaceId, sessionId);
|
|
352
357
|
const result = this.deleteSessionRows?.(workspaceId, sessionId) ??
|
|
353
358
|
this.deleteSessionRowsWithDefaultStore(workspaceId, sessionId);
|
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { auth, selectClientAuthMethod, } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
3
|
+
import { invalidRequest, notFound } from "../errors.js";
|
|
4
|
+
export const CONSOLE_MCP_OAUTH_FLOW_TTL_MS = 10 * 60 * 1000;
|
|
5
|
+
export const CONSOLE_MCP_OAUTH_MAX_BODY_BYTES = 64 * 1024;
|
|
6
|
+
export class ConsoleMcpOauthService {
|
|
7
|
+
vaults;
|
|
8
|
+
fetch;
|
|
9
|
+
opts;
|
|
10
|
+
flows = new Map();
|
|
11
|
+
flowIdsByState = new Map();
|
|
12
|
+
constructor(vaults, fetch, opts = {}) {
|
|
13
|
+
this.vaults = vaults;
|
|
14
|
+
this.fetch = fetch;
|
|
15
|
+
this.opts = opts;
|
|
16
|
+
}
|
|
17
|
+
async startConnect(workspaceId, input, callbackUrl) {
|
|
18
|
+
const body = objectInput(input);
|
|
19
|
+
const vaultId = stringField(body, "vault_id");
|
|
20
|
+
const serverUrl = mcpServerUrl(stringField(body, "mcp_server_url"));
|
|
21
|
+
const displayName = optionalStringField(body, "display_name");
|
|
22
|
+
this.vaults.retrieveVault(workspaceId, vaultId);
|
|
23
|
+
return this.start({
|
|
24
|
+
workspaceId,
|
|
25
|
+
vaultId,
|
|
26
|
+
...(displayName === undefined ? {} : { displayName }),
|
|
27
|
+
serverUrl,
|
|
28
|
+
callbackUrl,
|
|
29
|
+
operation: "connect",
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
async startReauthorize(workspaceId, input, callbackUrl) {
|
|
33
|
+
const body = objectInput(input);
|
|
34
|
+
const vaultId = stringField(body, "vault_id");
|
|
35
|
+
const credentialId = stringField(body, "credential_id");
|
|
36
|
+
const credential = this.vaults.retrieveCredential(workspaceId, vaultId, credentialId);
|
|
37
|
+
if (credential.archived_at !== null)
|
|
38
|
+
throw invalidRequest("Credential is archived.");
|
|
39
|
+
if (credential.auth.type !== "mcp_oauth") {
|
|
40
|
+
throw invalidRequest("Only MCP OAuth credentials can be reauthorized");
|
|
41
|
+
}
|
|
42
|
+
const oauth = this.vaults.readOauthValidationSnapshot(workspaceId, vaultId, credentialId);
|
|
43
|
+
if (oauth?.refresh === undefined) {
|
|
44
|
+
throw invalidRequest("This OAuth credential has no reusable client registration");
|
|
45
|
+
}
|
|
46
|
+
// Without the connect-time authorization server, a hostile MCP server
|
|
47
|
+
// could echo the stored token endpoint and take the redirect (#257).
|
|
48
|
+
if (oauth.refresh.authorizationServer === undefined) {
|
|
49
|
+
throw invalidRequest("This OAuth credential was connected before OMA recorded its authorization server. Delete it and connect again to reauthorize.");
|
|
50
|
+
}
|
|
51
|
+
return this.start({
|
|
52
|
+
workspaceId,
|
|
53
|
+
vaultId,
|
|
54
|
+
credentialId,
|
|
55
|
+
serverUrl: credential.auth.mcp_server_url,
|
|
56
|
+
callbackUrl,
|
|
57
|
+
operation: "reauthorize",
|
|
58
|
+
boundTokenEndpoint: oauth.refresh.tokenEndpoint,
|
|
59
|
+
boundAuthorizationServer: oauth.refresh.authorizationServer,
|
|
60
|
+
clientInformation: {
|
|
61
|
+
client_id: oauth.refresh.clientId,
|
|
62
|
+
...(oauth.secrets.clientSecret === undefined
|
|
63
|
+
? {}
|
|
64
|
+
: { client_secret: oauth.secrets.clientSecret }),
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
status(workspaceId, flowId) {
|
|
69
|
+
const flow = this.flowForWorkspace(workspaceId, flowId);
|
|
70
|
+
this.expire(flow);
|
|
71
|
+
return publicStatus(flow);
|
|
72
|
+
}
|
|
73
|
+
deny(state) {
|
|
74
|
+
const flow = this.consumeCallbackState(state);
|
|
75
|
+
flow.status = "failed";
|
|
76
|
+
flow.error = {
|
|
77
|
+
code: "authorization_denied",
|
|
78
|
+
message: "Authorization was denied or cancelled at the provider.",
|
|
79
|
+
};
|
|
80
|
+
flow.provider.clearSecrets();
|
|
81
|
+
return { flowId: flow.id, ok: false };
|
|
82
|
+
}
|
|
83
|
+
async complete(state, code) {
|
|
84
|
+
const flow = this.consumeCallbackState(state);
|
|
85
|
+
if (!code) {
|
|
86
|
+
flow.status = "failed";
|
|
87
|
+
flow.error = {
|
|
88
|
+
code: "callback_invalid",
|
|
89
|
+
message: "The provider callback did not include an authorization code.",
|
|
90
|
+
};
|
|
91
|
+
return { flowId: flow.id, ok: false };
|
|
92
|
+
}
|
|
93
|
+
flow.status = "completing";
|
|
94
|
+
let safetyFailure;
|
|
95
|
+
try {
|
|
96
|
+
const result = await auth(flow.provider, {
|
|
97
|
+
serverUrl: flow.serverUrl,
|
|
98
|
+
authorizationCode: code,
|
|
99
|
+
fetchFn: this.oauthFetch((error) => { safetyFailure = error; }),
|
|
100
|
+
});
|
|
101
|
+
if (result !== "AUTHORIZED" || flow.provider.savedTokens === undefined) {
|
|
102
|
+
throw new Error("OAuth token exchange did not produce tokens");
|
|
103
|
+
}
|
|
104
|
+
flow.credential = this.persist(flow, flow.provider.savedTokens);
|
|
105
|
+
flow.status = "connected";
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
flow.status = "failed";
|
|
109
|
+
flow.error = safetyFailure ?? classifyOauthError(error, "token_exchange_failed");
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
flow.provider.clearSecrets();
|
|
113
|
+
}
|
|
114
|
+
return { flowId: flow.id, ok: flow.status === "connected" };
|
|
115
|
+
}
|
|
116
|
+
async start(input) {
|
|
117
|
+
this.cleanup();
|
|
118
|
+
const now = this.now();
|
|
119
|
+
const flowId = opaqueId("oauth_flow");
|
|
120
|
+
const state = opaqueId("oauth_state");
|
|
121
|
+
const provider = new FlowProvider({
|
|
122
|
+
redirectUrl: input.callbackUrl,
|
|
123
|
+
state,
|
|
124
|
+
clientInformation: input.clientInformation,
|
|
125
|
+
boundTokenEndpoint: input.boundTokenEndpoint,
|
|
126
|
+
boundAuthorizationServer: input.boundAuthorizationServer,
|
|
127
|
+
});
|
|
128
|
+
const flow = {
|
|
129
|
+
id: flowId,
|
|
130
|
+
state,
|
|
131
|
+
workspaceId: input.workspaceId,
|
|
132
|
+
vaultId: input.vaultId,
|
|
133
|
+
...(input.credentialId === undefined ? {} : { credentialId: input.credentialId }),
|
|
134
|
+
...(input.displayName === undefined ? {} : { displayName: input.displayName }),
|
|
135
|
+
serverUrl: input.serverUrl,
|
|
136
|
+
redirectUrl: input.callbackUrl,
|
|
137
|
+
operation: input.operation,
|
|
138
|
+
createdAtMs: now.getTime(),
|
|
139
|
+
expiresAtMs: now.getTime() + (this.opts.flowTtlMs ?? CONSOLE_MCP_OAUTH_FLOW_TTL_MS),
|
|
140
|
+
status: "pending",
|
|
141
|
+
provider,
|
|
142
|
+
};
|
|
143
|
+
this.flows.set(flowId, flow);
|
|
144
|
+
this.flowIdsByState.set(state, flowId);
|
|
145
|
+
let safetyFailure;
|
|
146
|
+
try {
|
|
147
|
+
const result = await auth(provider, {
|
|
148
|
+
serverUrl: input.serverUrl,
|
|
149
|
+
fetchFn: this.oauthFetch((error) => { safetyFailure = error; }),
|
|
150
|
+
});
|
|
151
|
+
if (result !== "REDIRECT" || provider.authorizationUrl === undefined) {
|
|
152
|
+
throw new Error("OAuth provider did not return an authorization redirect");
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
flow_id: flow.id,
|
|
156
|
+
authorization_url: provider.authorizationUrl.toString(),
|
|
157
|
+
expires_at: new Date(flow.expiresAtMs).toISOString(),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
this.flowIdsByState.delete(state);
|
|
162
|
+
provider.clearSecrets();
|
|
163
|
+
flow.status = "failed";
|
|
164
|
+
flow.error = safetyFailure ?? classifyOauthError(error, "provider_unsupported");
|
|
165
|
+
throw invalidRequest(flow.error.message);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
oauthFetch(onSafetyFailure) {
|
|
169
|
+
return boundedOauthFetch(this.fetch, {
|
|
170
|
+
timeoutMs: this.opts.operationTimeoutMs ?? 30_000,
|
|
171
|
+
maxBodyBytes: this.opts.maxBodyBytes ?? CONSOLE_MCP_OAUTH_MAX_BODY_BYTES,
|
|
172
|
+
}, onSafetyFailure);
|
|
173
|
+
}
|
|
174
|
+
persist(flow, tokens) {
|
|
175
|
+
const client = flow.provider.savedClientInformation;
|
|
176
|
+
const discovery = flow.provider.savedDiscoveryState;
|
|
177
|
+
const tokenEndpoint = discovery?.authorizationServerMetadata?.token_endpoint;
|
|
178
|
+
const refreshToken = tokens.refresh_token;
|
|
179
|
+
const clientId = client?.client_id;
|
|
180
|
+
const expiresAt = tokens.expires_in === undefined
|
|
181
|
+
? undefined
|
|
182
|
+
: new Date(this.now().getTime() + tokens.expires_in * 1000).toISOString();
|
|
183
|
+
try {
|
|
184
|
+
if (flow.operation === "reauthorize") {
|
|
185
|
+
return this.vaults.updateCredential(flow.workspaceId, flow.vaultId, flow.credentialId, {
|
|
186
|
+
auth: {
|
|
187
|
+
type: "mcp_oauth",
|
|
188
|
+
access_token: tokens.access_token,
|
|
189
|
+
expires_at: expiresAt ?? null,
|
|
190
|
+
...(refreshToken === undefined
|
|
191
|
+
? {}
|
|
192
|
+
: { refresh: { refresh_token: refreshToken } }),
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
const canRefresh = refreshToken !== undefined && clientId !== undefined && tokenEndpoint !== undefined;
|
|
197
|
+
const authType = client === undefined
|
|
198
|
+
? "none"
|
|
199
|
+
: selectClientAuthMethod(client, discovery?.authorizationServerMetadata?.token_endpoint_auth_methods_supported ?? []);
|
|
200
|
+
return this.vaults.createCredential(flow.workspaceId, flow.vaultId, {
|
|
201
|
+
...(flow.displayName === undefined ? {} : { display_name: flow.displayName }),
|
|
202
|
+
auth: {
|
|
203
|
+
type: "mcp_oauth",
|
|
204
|
+
mcp_server_url: flow.serverUrl,
|
|
205
|
+
access_token: tokens.access_token,
|
|
206
|
+
...(expiresAt === undefined ? {} : { expires_at: expiresAt }),
|
|
207
|
+
...(canRefresh
|
|
208
|
+
? {
|
|
209
|
+
refresh: {
|
|
210
|
+
token_endpoint: tokenEndpoint,
|
|
211
|
+
client_id: clientId,
|
|
212
|
+
...(tokens.scope === undefined ? {} : { scope: tokens.scope }),
|
|
213
|
+
refresh_token: refreshToken,
|
|
214
|
+
token_endpoint_auth: {
|
|
215
|
+
type: authType,
|
|
216
|
+
...(client?.client_secret === undefined
|
|
217
|
+
? {}
|
|
218
|
+
: { client_secret: client.client_secret }),
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
}
|
|
222
|
+
: {}),
|
|
223
|
+
},
|
|
224
|
+
}, discovery?.authorizationServerUrl === undefined
|
|
225
|
+
? {}
|
|
226
|
+
: { oauthAuthorizationServer: discovery.authorizationServerUrl });
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
throw Object.assign(new Error("OAuth completed, but OMA could not save the credential"), {
|
|
230
|
+
cause: error,
|
|
231
|
+
oauthPersistenceFailure: true,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
consumeCallbackState(state) {
|
|
236
|
+
if (!state)
|
|
237
|
+
throw invalidRequest("The OAuth callback state is missing");
|
|
238
|
+
const flowId = this.flowIdsByState.get(state);
|
|
239
|
+
if (flowId === undefined)
|
|
240
|
+
throw invalidRequest("The OAuth callback is unknown or was already used");
|
|
241
|
+
const flow = this.flows.get(flowId);
|
|
242
|
+
if (flow === undefined || !safeEqual(state, flow.state)) {
|
|
243
|
+
throw invalidRequest("The OAuth callback is unknown or was already used");
|
|
244
|
+
}
|
|
245
|
+
this.flowIdsByState.delete(state);
|
|
246
|
+
if (this.expire(flow)) {
|
|
247
|
+
flow.error = {
|
|
248
|
+
code: "callback_expired",
|
|
249
|
+
message: "The OAuth connection expired. Start Connect again.",
|
|
250
|
+
};
|
|
251
|
+
throw invalidRequest(flow.error.message);
|
|
252
|
+
}
|
|
253
|
+
if (flow.status !== "pending") {
|
|
254
|
+
throw invalidRequest("The OAuth callback is unknown or was already used");
|
|
255
|
+
}
|
|
256
|
+
return flow;
|
|
257
|
+
}
|
|
258
|
+
flowForWorkspace(workspaceId, flowId) {
|
|
259
|
+
const flow = this.flows.get(flowId);
|
|
260
|
+
if (flow === undefined || flow.workspaceId !== workspaceId) {
|
|
261
|
+
throw notFound(`OAuth flow ${flowId} not found`);
|
|
262
|
+
}
|
|
263
|
+
return flow;
|
|
264
|
+
}
|
|
265
|
+
expire(flow) {
|
|
266
|
+
if (flow.status === "pending" && this.now().getTime() >= flow.expiresAtMs) {
|
|
267
|
+
this.flowIdsByState.delete(flow.state);
|
|
268
|
+
flow.provider.clearSecrets();
|
|
269
|
+
flow.status = "expired";
|
|
270
|
+
flow.error = {
|
|
271
|
+
code: "callback_expired",
|
|
272
|
+
message: "The OAuth connection expired. Start Connect again.",
|
|
273
|
+
};
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
return flow.status === "expired";
|
|
277
|
+
}
|
|
278
|
+
cleanup() {
|
|
279
|
+
const cutoff = this.now().getTime() - (this.opts.flowTtlMs ?? CONSOLE_MCP_OAUTH_FLOW_TTL_MS);
|
|
280
|
+
for (const [id, flow] of this.flows) {
|
|
281
|
+
this.expire(flow);
|
|
282
|
+
if (flow.expiresAtMs < cutoff)
|
|
283
|
+
this.flows.delete(id);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
now() {
|
|
287
|
+
return this.opts.now?.() ?? new Date();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
class FlowProvider {
|
|
291
|
+
input;
|
|
292
|
+
authorizationUrl;
|
|
293
|
+
savedClientInformation;
|
|
294
|
+
savedTokens;
|
|
295
|
+
savedCodeVerifier;
|
|
296
|
+
savedDiscoveryState;
|
|
297
|
+
clientMetadata;
|
|
298
|
+
constructor(input) {
|
|
299
|
+
this.input = input;
|
|
300
|
+
this.savedClientInformation = input.clientInformation;
|
|
301
|
+
this.clientMetadata = {
|
|
302
|
+
redirect_uris: [input.redirectUrl],
|
|
303
|
+
client_name: "Open Managed Agents",
|
|
304
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
305
|
+
response_types: ["code"],
|
|
306
|
+
token_endpoint_auth_method: "none",
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
get redirectUrl() {
|
|
310
|
+
return this.input.redirectUrl;
|
|
311
|
+
}
|
|
312
|
+
state() {
|
|
313
|
+
return this.input.state;
|
|
314
|
+
}
|
|
315
|
+
clientInformation() {
|
|
316
|
+
return this.savedClientInformation;
|
|
317
|
+
}
|
|
318
|
+
saveClientInformation(value) {
|
|
319
|
+
this.savedClientInformation = value;
|
|
320
|
+
}
|
|
321
|
+
tokens() {
|
|
322
|
+
return undefined;
|
|
323
|
+
}
|
|
324
|
+
saveTokens(value) {
|
|
325
|
+
this.savedTokens = value;
|
|
326
|
+
}
|
|
327
|
+
redirectToAuthorization(url) {
|
|
328
|
+
this.authorizationUrl = url;
|
|
329
|
+
}
|
|
330
|
+
saveCodeVerifier(value) {
|
|
331
|
+
this.savedCodeVerifier = value;
|
|
332
|
+
}
|
|
333
|
+
codeVerifier() {
|
|
334
|
+
if (this.savedCodeVerifier === undefined)
|
|
335
|
+
throw new Error("PKCE verifier is missing");
|
|
336
|
+
return this.savedCodeVerifier;
|
|
337
|
+
}
|
|
338
|
+
// The MCP server picks the authorization server, so on reauthorize a
|
|
339
|
+
// hostile or compromised server could point the stored client secret at
|
|
340
|
+
// its own token endpoint (GHSA-6qxp-vccf-f47h). Stored credentials carry no
|
|
341
|
+
// SDK issuer stamp, so bind to the token endpoint saved at connect time:
|
|
342
|
+
// the only place the SDK sends the secret, and to the authorization server
|
|
343
|
+
// recorded at connect, so a server echoing the token endpoint cannot take
|
|
344
|
+
// the browser redirect (code injection, #257). The SDK saves discovery
|
|
345
|
+
// state before the redirect and before any token request.
|
|
346
|
+
saveDiscoveryState(value) {
|
|
347
|
+
const bound = this.input.boundTokenEndpoint;
|
|
348
|
+
const tokenEndpoint = value.authorizationServerMetadata?.token_endpoint;
|
|
349
|
+
const boundServer = this.input.boundAuthorizationServer;
|
|
350
|
+
if ((bound !== undefined && (tokenEndpoint === undefined || !sameUrl(tokenEndpoint, bound))) ||
|
|
351
|
+
(boundServer !== undefined && !sameUrl(value.authorizationServerUrl, boundServer))) {
|
|
352
|
+
throw Object.assign(new Error("OAuth authorization server changed since this credential was connected"), {
|
|
353
|
+
oauthAuthorizationServerChanged: true,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
this.savedDiscoveryState = value;
|
|
357
|
+
}
|
|
358
|
+
discoveryState() {
|
|
359
|
+
return this.savedDiscoveryState;
|
|
360
|
+
}
|
|
361
|
+
clearSecrets() {
|
|
362
|
+
this.savedTokens = undefined;
|
|
363
|
+
this.savedCodeVerifier = undefined;
|
|
364
|
+
if (this.savedClientInformation?.client_secret !== undefined) {
|
|
365
|
+
const { client_secret: _discarded, ...clientInformation } = this.savedClientInformation;
|
|
366
|
+
this.savedClientInformation = clientInformation;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
function publicStatus(flow) {
|
|
371
|
+
const oauth = flow.credential?.auth.type === "mcp_oauth" ? flow.credential.auth : undefined;
|
|
372
|
+
return {
|
|
373
|
+
flow_id: flow.id,
|
|
374
|
+
status: flow.status,
|
|
375
|
+
expires_at: new Date(flow.expiresAtMs).toISOString(),
|
|
376
|
+
...(flow.credential === undefined ? {} : { credential_id: flow.credential.id }),
|
|
377
|
+
...(oauth === undefined ? {} : { refreshable: oauth.refresh !== undefined }),
|
|
378
|
+
...(flow.error === undefined ? {} : { error: flow.error }),
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
function classifyOauthError(error, fallback) {
|
|
382
|
+
if (isObject(error) && error.oauthAuthorizationServerChanged === true) {
|
|
383
|
+
return {
|
|
384
|
+
code: "authorization_server_changed",
|
|
385
|
+
message: "The MCP server now names a different authorization server than when this credential was connected. Delete the credential and connect again if the change is expected.",
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
if (isObject(error) && error.oauthPersistenceFailure === true) {
|
|
389
|
+
return {
|
|
390
|
+
code: "credential_persist_failed",
|
|
391
|
+
message: "Authorization succeeded, but OMA could not save the credential.",
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
const message = error instanceof Error ? error.message.toLowerCase() : "";
|
|
395
|
+
if (message.includes("registration endpoint") ||
|
|
396
|
+
message.includes("client information") ||
|
|
397
|
+
message.includes("dynamic client registration")) {
|
|
398
|
+
return {
|
|
399
|
+
code: "provider_unsupported",
|
|
400
|
+
message: "This MCP provider does not support automatic client registration. A pre-registered client is not supported in this console flow yet.",
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
if (message.includes("fetch failed") ||
|
|
404
|
+
message.includes("timeout") ||
|
|
405
|
+
message.includes("aborted") ||
|
|
406
|
+
message.includes("egress denied") ||
|
|
407
|
+
message.includes("response too large")) {
|
|
408
|
+
return {
|
|
409
|
+
code: "provider_unreachable",
|
|
410
|
+
message: "OMA could not safely reach the MCP authorization service.",
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
return fallback === "provider_unsupported"
|
|
414
|
+
? {
|
|
415
|
+
code: fallback,
|
|
416
|
+
message: "This MCP server did not expose a supported OAuth authorization flow.",
|
|
417
|
+
}
|
|
418
|
+
: {
|
|
419
|
+
code: fallback,
|
|
420
|
+
message: "The provider could not complete the OAuth token exchange. Start Connect again.",
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
function boundedOauthFetch(inner, opts, onSafetyFailure) {
|
|
424
|
+
return async (url, init = {}) => {
|
|
425
|
+
const timeout = new AbortController();
|
|
426
|
+
const timer = setTimeout(() => {
|
|
427
|
+
onSafetyFailure(oauthFetchSafetyFailure());
|
|
428
|
+
timeout.abort();
|
|
429
|
+
}, opts.timeoutMs);
|
|
430
|
+
const signal = init.signal == null
|
|
431
|
+
? timeout.signal
|
|
432
|
+
: AbortSignal.any([init.signal, timeout.signal]);
|
|
433
|
+
try {
|
|
434
|
+
const response = await inner(url, { ...init, signal });
|
|
435
|
+
const declared = Number(response.headers.get("content-length"));
|
|
436
|
+
if (Number.isFinite(declared) && declared > opts.maxBodyBytes) {
|
|
437
|
+
await response.body?.cancel();
|
|
438
|
+
onSafetyFailure(oauthFetchSafetyFailure());
|
|
439
|
+
throw new Error("OAuth response too large");
|
|
440
|
+
}
|
|
441
|
+
let bytes;
|
|
442
|
+
try {
|
|
443
|
+
bytes = await readBoundedBody(response, opts.maxBodyBytes);
|
|
444
|
+
}
|
|
445
|
+
catch (error) {
|
|
446
|
+
if (error instanceof Error && error.message === "OAuth response too large") {
|
|
447
|
+
onSafetyFailure(oauthFetchSafetyFailure());
|
|
448
|
+
}
|
|
449
|
+
throw error;
|
|
450
|
+
}
|
|
451
|
+
return new Response(bytes, {
|
|
452
|
+
status: response.status,
|
|
453
|
+
statusText: response.statusText,
|
|
454
|
+
headers: response.headers,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
finally {
|
|
458
|
+
clearTimeout(timer);
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
function oauthFetchSafetyFailure() {
|
|
463
|
+
return {
|
|
464
|
+
code: "provider_unreachable",
|
|
465
|
+
message: "OMA could not safely reach the MCP authorization service.",
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
async function readBoundedBody(response, maxBodyBytes) {
|
|
469
|
+
if (response.body === null)
|
|
470
|
+
return new Uint8Array();
|
|
471
|
+
const reader = response.body.getReader();
|
|
472
|
+
const chunks = [];
|
|
473
|
+
let total = 0;
|
|
474
|
+
try {
|
|
475
|
+
while (true) {
|
|
476
|
+
const { done, value } = await reader.read();
|
|
477
|
+
if (done)
|
|
478
|
+
break;
|
|
479
|
+
total += value.byteLength;
|
|
480
|
+
if (total > maxBodyBytes)
|
|
481
|
+
throw new Error("OAuth response too large");
|
|
482
|
+
chunks.push(value);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
catch (error) {
|
|
486
|
+
await reader.cancel().catch(() => undefined);
|
|
487
|
+
throw error;
|
|
488
|
+
}
|
|
489
|
+
const bytes = new Uint8Array(total);
|
|
490
|
+
let offset = 0;
|
|
491
|
+
for (const chunk of chunks) {
|
|
492
|
+
bytes.set(chunk, offset);
|
|
493
|
+
offset += chunk.byteLength;
|
|
494
|
+
}
|
|
495
|
+
return bytes;
|
|
496
|
+
}
|
|
497
|
+
function opaqueId(prefix) {
|
|
498
|
+
return `${prefix}_${randomBytes(24).toString("base64url")}`;
|
|
499
|
+
}
|
|
500
|
+
function safeEqual(left, right) {
|
|
501
|
+
const a = Buffer.from(left);
|
|
502
|
+
const b = Buffer.from(right);
|
|
503
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
504
|
+
}
|
|
505
|
+
function mcpServerUrl(value) {
|
|
506
|
+
try {
|
|
507
|
+
const url = new URL(value);
|
|
508
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
509
|
+
throw new Error();
|
|
510
|
+
if (url.username || url.password || url.hash)
|
|
511
|
+
throw new Error();
|
|
512
|
+
return url.toString();
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
throw invalidRequest("`mcp_server_url` must be an http(s) URL without userinfo or a fragment");
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
function objectInput(input) {
|
|
519
|
+
if (!isObject(input))
|
|
520
|
+
throw invalidRequest("Request body must be a JSON object");
|
|
521
|
+
return input;
|
|
522
|
+
}
|
|
523
|
+
function stringField(input, field) {
|
|
524
|
+
const value = input[field];
|
|
525
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
526
|
+
throw invalidRequest(`\`${field}\` must be a non-empty string`);
|
|
527
|
+
}
|
|
528
|
+
return value.trim();
|
|
529
|
+
}
|
|
530
|
+
function optionalStringField(input, field) {
|
|
531
|
+
if (input[field] === undefined)
|
|
532
|
+
return undefined;
|
|
533
|
+
return stringField(input, field);
|
|
534
|
+
}
|
|
535
|
+
function isObject(value) {
|
|
536
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
537
|
+
}
|
|
538
|
+
function sameUrl(a, b) {
|
|
539
|
+
try {
|
|
540
|
+
return new URL(a).href === new URL(b).href;
|
|
541
|
+
}
|
|
542
|
+
catch {
|
|
543
|
+
return false;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
@@ -70,7 +70,7 @@ export class DefaultVaultService {
|
|
|
70
70
|
}
|
|
71
71
|
return { id: vaultId, type: "vault_deleted" };
|
|
72
72
|
}
|
|
73
|
-
createCredential(workspaceId, vaultId, input) {
|
|
73
|
+
createCredential(workspaceId, vaultId, input, internal = {}) {
|
|
74
74
|
const vault = this.store.retrieveVault(workspaceId, vaultId);
|
|
75
75
|
if (!vault)
|
|
76
76
|
throw notFound(`Vault ${vaultId} not found`);
|
|
@@ -96,6 +96,9 @@ export class DefaultVaultService {
|
|
|
96
96
|
archived_at: null,
|
|
97
97
|
},
|
|
98
98
|
token: req.secretValue,
|
|
99
|
+
...(req.auth.type === "mcp_oauth" && internal.oauthAuthorizationServer !== undefined
|
|
100
|
+
? { oauthAuthorizationServer: internal.oauthAuthorizationServer }
|
|
101
|
+
: {}),
|
|
99
102
|
nextRefreshAt: oauthSchedule(new Date(now), req.auth, req.auth.type === "mcp_oauth" && req.auth.refresh !== undefined),
|
|
100
103
|
}));
|
|
101
104
|
if (req.auth.type === "mcp_oauth" && req.auth.refresh !== undefined) {
|
|
@@ -28,6 +28,7 @@ CREATE TABLE IF NOT EXISTS vault_credentials (
|
|
|
28
28
|
client_id TEXT,
|
|
29
29
|
scope TEXT,
|
|
30
30
|
token_endpoint_auth_type TEXT,
|
|
31
|
+
oauth_authorization_server TEXT,
|
|
31
32
|
expires_at TEXT,
|
|
32
33
|
auth_version INTEGER NOT NULL DEFAULT 1,
|
|
33
34
|
refresh_status TEXT,
|
|
@@ -95,8 +96,9 @@ export class SqliteVaultStore {
|
|
|
95
96
|
this.insertCredentialStmt = this.db.prepare(`INSERT INTO vault_credentials (
|
|
96
97
|
id, workspace_id, vault_id, type, display_name, metadata, auth_type,
|
|
97
98
|
mcp_server_url, token_endpoint, client_id, scope, token_endpoint_auth_type,
|
|
99
|
+
oauth_authorization_server,
|
|
98
100
|
expires_at, auth_version, next_refresh_at, created_at, updated_at, archived_at
|
|
99
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
101
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
100
102
|
this.retrieveCredentialActiveStmt = this.db.prepare(`SELECT * FROM vault_credentials
|
|
101
103
|
WHERE workspace_id = ? AND vault_id = ? AND id = ? AND archived_at IS NULL`);
|
|
102
104
|
this.retrieveCredentialAnyStmt = this.db.prepare(`SELECT * FROM vault_credentials
|
|
@@ -249,7 +251,7 @@ export class SqliteVaultStore {
|
|
|
249
251
|
this.requireSecrets().put(c.workspace_id, secretName(c.vault_id, c.id), record.token);
|
|
250
252
|
this.insertCredentialStmt.run(c.id, c.workspace_id, c.vault_id, c.type, c.display_name, JSON.stringify(c.metadata), c.auth.type, c.auth.mcp_server_url, c.auth.type === "mcp_oauth" ? c.auth.refresh?.token_endpoint ?? null : null, c.auth.type === "mcp_oauth" ? c.auth.refresh?.client_id ?? null : null, c.auth.type === "mcp_oauth" ? c.auth.refresh?.scope ?? null : null, c.auth.type === "mcp_oauth"
|
|
251
253
|
? c.auth.refresh?.token_endpoint_auth.type ?? null
|
|
252
|
-
: null, c.auth.type === "mcp_oauth" ? c.auth.expires_at ?? null : null, c.auth_version, record.nextRefreshAt ?? null, c.created_at, c.updated_at, c.archived_at);
|
|
254
|
+
: null, record.oauthAuthorizationServer ?? null, c.auth.type === "mcp_oauth" ? c.auth.expires_at ?? null : null, c.auth_version, record.nextRefreshAt ?? null, c.created_at, c.updated_at, c.archived_at);
|
|
253
255
|
return c;
|
|
254
256
|
});
|
|
255
257
|
}
|
|
@@ -470,6 +472,9 @@ export class SqliteVaultStore {
|
|
|
470
472
|
: {
|
|
471
473
|
refresh: {
|
|
472
474
|
tokenEndpoint: auth.refresh.token_endpoint,
|
|
475
|
+
...(row.oauth_authorization_server === null
|
|
476
|
+
? {}
|
|
477
|
+
: { authorizationServer: row.oauth_authorization_server }),
|
|
473
478
|
clientId: auth.refresh.client_id,
|
|
474
479
|
...(auth.refresh.scope === undefined
|
|
475
480
|
? {}
|
|
@@ -660,6 +665,7 @@ function ensureVaultCredentialColumns(db) {
|
|
|
660
665
|
["client_id", "TEXT"],
|
|
661
666
|
["scope", "TEXT"],
|
|
662
667
|
["token_endpoint_auth_type", "TEXT"],
|
|
668
|
+
["oauth_authorization_server", "TEXT"],
|
|
663
669
|
["expires_at", "TEXT"],
|
|
664
670
|
["auth_version", "INTEGER NOT NULL DEFAULT 1"],
|
|
665
671
|
["refresh_status", "TEXT"],
|
package/dist/src/main.js
CHANGED
|
@@ -50,7 +50,7 @@ export async function startAppliance(env = process.env, opts = {}) {
|
|
|
50
50
|
const resolved = resolveApplianceEnv(env);
|
|
51
51
|
const port = parseAppliancePort(resolved.OMA_PORT);
|
|
52
52
|
const host = resolved.OMA_HOST ?? "127.0.0.1";
|
|
53
|
-
const plane = createDeploymentControlPlane(resolved, {
|
|
53
|
+
const plane = await createDeploymentControlPlane(resolved, {
|
|
54
54
|
...(opts.onboarding === undefined ? {} : { consoleBootstrap: opts.onboarding.bootstrap }),
|
|
55
55
|
});
|
|
56
56
|
const { app, stores, authMode } = plane;
|