@nylorun/runtime 0.20.0-beta → 0.21.0-beta
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/CHANGELOG.md +64 -0
- package/README.md +15 -10
- package/dist/api/components.d.ts +155 -36
- package/dist/api/components.js +3 -3
- package/dist/api/http/app.js +2 -0
- package/dist/api/http/routes/mcp.d.ts +9 -0
- package/dist/api/http/routes/mcp.js +32 -0
- package/dist/api/http/routes/vaults.d.ts +1 -9
- package/dist/api/http/routes/vaults.js +3 -103
- package/dist/api/openapi.js +6 -3
- package/dist/artifacts/media-types.d.ts +2 -0
- package/dist/artifacts/media-types.js +5 -0
- package/dist/gates/http-tool.d.ts +7 -0
- package/dist/gates/http-tool.js +34 -3
- package/dist/gates/mcp-handler.d.ts +4 -1
- package/dist/gates/mcp-handler.js +64 -8
- package/dist/gates/tenant-vaults.d.ts +9 -15
- package/dist/gates/tenant-vaults.js +9 -17
- package/dist/gates/tool-client.js +36 -17
- package/dist/gates/tool-contract.d.ts +19 -3
- package/dist/gates/tool-contract.js +9 -3
- package/dist/gates/tool-gate.d.ts +2 -2
- package/dist/harness/calls.d.ts +2 -0
- package/dist/harness/calls.js +13 -3
- package/dist/harness-api/record.d.ts +7 -6
- package/dist/harness-api/record.js +99 -17
- package/dist/harness-api/server.js +35 -27
- package/dist/harness-api/start.js +2 -1
- package/dist/host/config-for.d.ts +0 -2
- package/dist/host/config-for.js +0 -1
- package/dist/host/gates.js +0 -1
- package/dist/host/main.js +13 -3
- package/dist/host/stack-config.d.ts +5 -8
- package/dist/host/stack-config.js +8 -19
- package/dist/index.d.ts +0 -1
- package/dist/keys/client.js +1 -9
- package/dist/keys/keys.d.ts +11 -13
- package/dist/keys/keys.js +5 -7
- package/dist/management-openapi.json +315 -345
- package/dist/mcp/connect.d.ts +159 -13
- package/dist/mcp/connect.js +538 -55
- package/dist/mcp/pool.d.ts +11 -2
- package/dist/mcp/pool.js +74 -27
- package/dist/mcp/preview.d.ts +35 -0
- package/dist/mcp/preview.js +215 -0
- package/dist/mcp/snapshot.d.ts +79 -5
- package/dist/mcp/snapshot.js +145 -27
- package/dist/model/catalog.d.ts +14 -0
- package/dist/model/catalog.js +14 -1
- package/dist/model/pi-model.js +36 -2
- package/dist/openapi.json +362 -123
- package/dist/redact.d.ts +19 -1
- package/dist/redact.js +42 -13
- package/dist/sandbox/session-sandbox.d.ts +7 -0
- package/dist/sandbox/session-sandbox.js +89 -28
- package/dist/store/postgres/drizzle/0016_mcp_oauth_removed.sql +5 -0
- package/dist/store/postgres/drizzle/meta/_journal.json +7 -0
- package/dist/store/postgres/schema.d.ts +2 -261
- package/dist/store/postgres/schema.js +2 -36
- package/dist/store/postgres/store.js +1 -18
- package/dist/store/postgres/tenant.d.ts +14 -0
- package/dist/store/postgres/tenant.js +22 -2
- package/dist/store/types.d.ts +2 -8
- package/dist/tenant/artifact-tool.d.ts +9 -1
- package/dist/tenant/artifact-tool.js +113 -9
- package/dist/tenant/context.d.ts +5 -6
- package/dist/tenant/effects.d.ts +2 -2
- package/dist/tenant/effects.js +20 -5
- package/dist/tenant/ephemeral.d.ts +0 -6
- package/dist/tenant/ephemeral.js +0 -1
- package/dist/tenant/outbound.d.ts +14 -5
- package/dist/tenant/outbound.js +21 -9
- package/dist/tenant/runtime.js +9 -14
- package/dist/tenant/sessions.js +20 -3
- package/dist/tenant/store-pg.js +9 -0
- package/dist/tenant/tool-results.d.ts +57 -0
- package/dist/tenant/tool-results.js +298 -0
- package/dist/tenant/tool-search.d.ts +43 -0
- package/dist/tenant/tool-search.js +100 -0
- package/dist/tenant/transcript.d.ts +108 -0
- package/dist/tenant/transcript.js +13 -0
- package/dist/tenant/types.d.ts +0 -14
- package/dist/vault/crypto.d.ts +0 -7
- package/dist/vault/crypto.js +0 -16
- package/dist/vault/headers.d.ts +17 -0
- package/dist/vault/headers.js +61 -0
- package/dist/vault/service.d.ts +11 -68
- package/dist/vault/service.js +83 -438
- package/dist/vault/sources.d.ts +20 -48
- package/dist/vault/sources.js +25 -189
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -3
- package/dist/vault/oauth.d.ts +0 -55
- package/dist/vault/oauth.js +0 -186
package/dist/vault/service.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* ## Public surface
|
|
8
8
|
*
|
|
9
9
|
* ```ts
|
|
10
|
-
* new VaultService({ store: SessionStore, kek: () => Buffer
|
|
10
|
+
* new VaultService({ store: SessionStore, kek: () => Buffer })
|
|
11
11
|
*
|
|
12
12
|
* // Administration: each opens its own transaction and writes its audit rows
|
|
13
13
|
* // in that transaction. Creates and rotations replay by idempotency key.
|
|
@@ -25,13 +25,10 @@
|
|
|
25
25
|
* assertAttachment(t: Tx, ownerUserId: string, vaultIds: readonly string[], selections: readonly CredentialSelection[], options?: { opaque?: boolean }): Promise<void>
|
|
26
26
|
* recordAttachment(t: Tx, sessionId: string, vaultIds: readonly string[]): Promise<void>
|
|
27
27
|
*
|
|
28
|
-
* // Use: opens its own transactions; never call it inside one. `
|
|
29
|
-
* // (`vault/sources.ts`)
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* // MCP OAuth connect (F9 C2, installation vaults only): network calls outside any transaction.
|
|
33
|
-
* startOAuth(input: { vaultId; server; url; clientId?; redirectUri }): Promise<{ authorizeUrl; expiresAt }>
|
|
34
|
-
* finishOAuth(input: { state; code?; error? }): Promise<{ vaultId; credentialId }>
|
|
28
|
+
* // Use: opens its own transactions; never call it inside one. `sessionCredentials`
|
|
29
|
+
* // (`vault/sources.ts`) calls it for a session's MCP servers and HTTP tools; a tool preview
|
|
30
|
+
* // (R2b C12, `mcp/preview.ts`) for no session.
|
|
31
|
+
* authorize(input: { sessionId?; vaultIds; credentialSelections; url; serverName? }): Promise<AuthorizeResult>
|
|
35
32
|
*
|
|
36
33
|
* // Host model credential: each opens its own transaction.
|
|
37
34
|
* getHostModel(): Promise<HostModelView>
|
|
@@ -61,42 +58,37 @@
|
|
|
61
58
|
* (ciphertext only) in one transaction and decides there. A refusal decided
|
|
62
59
|
* from those rows (missing vault, ambiguous, selection mismatch) writes its
|
|
63
60
|
* audit row in that same transaction. Decryption happens after it commits,
|
|
64
|
-
* so the plaintext never enters a transaction.
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
* transaction before the
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
61
|
+
* so the plaintext never enters a transaction. The `use`/`approved` audit
|
|
62
|
+
* row is written in a final transaction before the header is returned: if
|
|
63
|
+
* that write fails, `authorize` rejects and no header leaves the vault, so
|
|
64
|
+
* every approved use has an audit row. Unreadable ciphertext is audited in
|
|
65
|
+
* its own transaction before the refusal is returned.
|
|
66
|
+
*
|
|
67
|
+
* ## Credentials (R2b C2)
|
|
68
|
+
*
|
|
69
|
+
* A credential is bound to the URL a manifest names and sends a `bearer` token or a `headers`
|
|
70
|
+
* map, both sealed. Its `via` (where requests go, such as a gateway) and `identity` (the header
|
|
71
|
+
* that names the session owner) are not secret: they live unsealed in the binding, beside the
|
|
72
|
+
* URL and the header names, and a rotation keeps them unless it changes them. The seal's AAD
|
|
73
|
+
* binds the type and URL only, and matching is by URL only, so `via` never decides which
|
|
74
|
+
* credential is chosen.
|
|
72
75
|
*/
|
|
73
|
-
import { createHash,
|
|
76
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
74
77
|
import { INSTALLATION_OWNER } from "@nylorun/core/contracts";
|
|
75
78
|
import { canonical } from "../store/canonical.js";
|
|
76
|
-
import { decryptSecret, encryptSecret,
|
|
77
|
-
import { HttpError } from "../tenant/http.js";
|
|
78
|
-
import { authorizationUrl, discoverOAuthServer, exchangeCode, oauthClient, } from "./oauth.js";
|
|
79
|
+
import { decryptSecret, encryptSecret, VaultCryptoError } from "./crypto.js";
|
|
79
80
|
import { VaultError } from "./error.js";
|
|
81
|
+
import { checkCredentialHeaders } from "./headers.js";
|
|
80
82
|
import { hostModelCatalog } from "../model/catalog.js";
|
|
81
83
|
import { normalizeVaultUrl } from "./url.js";
|
|
82
|
-
const REFRESH_SKEW_MS = 60_000;
|
|
83
|
-
const REFRESH_TIMEOUT_MS = 30_000;
|
|
84
|
-
/** How long an MCP OAuth connect may take between its start and its callback (F9 C2). */
|
|
85
|
-
export const OAUTH_PENDING_MS = 10 * 60_000;
|
|
86
84
|
export const HOST_VAULT_ID = "host";
|
|
87
85
|
const HOST_MODEL_ID = "host-model";
|
|
88
86
|
export class VaultService {
|
|
89
87
|
store;
|
|
90
88
|
kek;
|
|
91
|
-
fetchImpl;
|
|
92
|
-
refreshTimeoutMs;
|
|
93
|
-
/** Refreshes in progress by credential id: concurrent uses share one. */
|
|
94
|
-
refreshing = new Map();
|
|
95
89
|
constructor(options) {
|
|
96
90
|
this.store = options.store;
|
|
97
91
|
this.kek = options.kek;
|
|
98
|
-
this.fetchImpl = options.fetch;
|
|
99
|
-
this.refreshTimeoutMs = options.refreshTimeoutMs ?? REFRESH_TIMEOUT_MS;
|
|
100
92
|
}
|
|
101
93
|
// --- administration --------------------------------------------------------
|
|
102
94
|
async createVault(body) {
|
|
@@ -166,14 +158,16 @@ export class VaultService {
|
|
|
166
158
|
const id = randomUUID();
|
|
167
159
|
const url = normalizeVaultUrl(body.auth.url);
|
|
168
160
|
const type = body.auth.type;
|
|
169
|
-
const
|
|
161
|
+
const payload = payloadOf(body.auth);
|
|
162
|
+
const binding = bindingOf(url, payload, body.auth);
|
|
163
|
+
const sealed = encryptSecret(kek, credentialAad(vaultId, id, type, url), Buffer.from(JSON.stringify(payload), "utf8"));
|
|
170
164
|
const row = {
|
|
171
165
|
id,
|
|
172
166
|
vaultId,
|
|
173
167
|
name: body.name,
|
|
174
168
|
type,
|
|
175
|
-
bindingJson: JSON.stringify(
|
|
176
|
-
expiresAt:
|
|
169
|
+
bindingJson: JSON.stringify(binding),
|
|
170
|
+
expiresAt: null,
|
|
177
171
|
createdAt: new Date().toISOString(),
|
|
178
172
|
rotatedAt: null,
|
|
179
173
|
...sealed,
|
|
@@ -206,18 +200,13 @@ export class VaultService {
|
|
|
206
200
|
const row = await this.credentialRow(t, vaultId, id);
|
|
207
201
|
if (row.type !== body.auth.type)
|
|
208
202
|
throw new VaultError(409, "Credential type cannot change");
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
body.auth.expiresAt === undefined || body.auth.expiresAt === null
|
|
217
|
-
? null
|
|
218
|
-
: requireTimestamp(body.auth.expiresAt);
|
|
219
|
-
}
|
|
220
|
-
const updated = await this.writePayload(t, kek, row, next, expiresAt, new Date().toISOString());
|
|
203
|
+
const current = credentialBinding(row);
|
|
204
|
+
const next = payloadOf(body.auth);
|
|
205
|
+
const binding = bindingOf(current.url, next, {
|
|
206
|
+
via: body.auth.via === undefined ? current.via : body.auth.via,
|
|
207
|
+
identity: body.auth.identity === undefined ? current.identity : body.auth.identity,
|
|
208
|
+
});
|
|
209
|
+
const updated = await this.writePayload(t, kek, row, next, binding, new Date().toISOString());
|
|
221
210
|
await this.audit(t, {
|
|
222
211
|
actor: "application",
|
|
223
212
|
action: "rotate",
|
|
@@ -286,9 +275,13 @@ export class VaultService {
|
|
|
286
275
|
async authorize(input) {
|
|
287
276
|
const url = normalizeVaultUrl(input.url);
|
|
288
277
|
const decided = await this.store.tx(async (t) => {
|
|
278
|
+
const scopes = new Map();
|
|
289
279
|
for (const id of input.vaultIds) {
|
|
290
|
-
|
|
280
|
+
const found = await t.getVault(id);
|
|
281
|
+
if (found) {
|
|
282
|
+
scopes.set(id, found.scope);
|
|
291
283
|
continue;
|
|
284
|
+
}
|
|
292
285
|
await this.audit(t, {
|
|
293
286
|
actor: "host",
|
|
294
287
|
action: "use",
|
|
@@ -334,7 +327,10 @@ export class VaultService {
|
|
|
334
327
|
},
|
|
335
328
|
};
|
|
336
329
|
}
|
|
337
|
-
return {
|
|
330
|
+
return {
|
|
331
|
+
row: chosen.row,
|
|
332
|
+
vault: scopes.get(chosen.row.vaultId) === "installation" ? "installation" : "user",
|
|
333
|
+
};
|
|
338
334
|
});
|
|
339
335
|
if ("result" in decided)
|
|
340
336
|
return decided.result;
|
|
@@ -364,14 +360,8 @@ export class VaultService {
|
|
|
364
360
|
reason: "unreadable",
|
|
365
361
|
};
|
|
366
362
|
}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
if (refreshed.status === "refused")
|
|
370
|
-
return refreshed;
|
|
371
|
-
payload = refreshed.payload;
|
|
372
|
-
}
|
|
373
|
-
const token = row.type === "bearer" ? payload.token : payload.accessToken;
|
|
374
|
-
if (!token) {
|
|
363
|
+
const headers = sendHeaders(row, payload);
|
|
364
|
+
if (!headers) {
|
|
375
365
|
return {
|
|
376
366
|
status: "refused",
|
|
377
367
|
url,
|
|
@@ -388,198 +378,15 @@ export class VaultService {
|
|
|
388
378
|
target: url,
|
|
389
379
|
outcome: "approved",
|
|
390
380
|
}));
|
|
381
|
+
const binding = credentialBinding(row);
|
|
391
382
|
return {
|
|
392
383
|
status: "authorized",
|
|
393
384
|
url,
|
|
394
|
-
headers
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
/**
|
|
399
|
-
* Starts signing the installation in to the MCP server at `url`: discovery, a client (the
|
|
400
|
-
* given id, or one registered now), an S256 PKCE verifier and a `state`, kept for ten minutes
|
|
401
|
-
* in a pending row (verifier and secret sealed, state hashed). Returns where to send the
|
|
402
|
-
* browser. Installation vaults only.
|
|
403
|
-
*/
|
|
404
|
-
async startOAuth(input) {
|
|
405
|
-
const url = normalizeVaultUrl(input.url);
|
|
406
|
-
const redirectUri = callbackUrl(input.redirectUri);
|
|
407
|
-
await this.store.tx(async (t) => {
|
|
408
|
-
await this.installationVault(t, input.vaultId);
|
|
409
|
-
await t.deleteExpiredOAuthPending(new Date().toISOString());
|
|
410
|
-
});
|
|
411
|
-
const kek = this.kek();
|
|
412
|
-
const server = await discoverOAuthServer(url, this.fetchImpl);
|
|
413
|
-
const client = await oauthClient(server, {
|
|
414
|
-
...(input.clientId === undefined ? {} : { clientId: input.clientId }),
|
|
415
|
-
redirectUri,
|
|
416
|
-
fetchFn: this.fetchImpl,
|
|
417
|
-
});
|
|
418
|
-
const state = randomBytes(32).toString("base64url");
|
|
419
|
-
const { authorizeUrl, codeVerifier } = await authorizationUrl(server, client, { redirectUri, state });
|
|
420
|
-
const stateHash = hashState(state);
|
|
421
|
-
const now = Date.now();
|
|
422
|
-
const expiresAt = new Date(now + OAUTH_PENDING_MS).toISOString();
|
|
423
|
-
const row = {
|
|
424
|
-
stateHash,
|
|
425
|
-
vaultId: input.vaultId,
|
|
426
|
-
server: input.server,
|
|
427
|
-
url,
|
|
428
|
-
tokenEndpoint: server.metadata.token_endpoint,
|
|
429
|
-
clientId: client.clientId,
|
|
430
|
-
tokenEndpointAuth: client.tokenEndpointAuth,
|
|
431
|
-
resource: server.resource ?? null,
|
|
432
|
-
kekId: "",
|
|
433
|
-
clientSecret: null,
|
|
434
|
-
codeVerifier: new Uint8Array(),
|
|
435
|
-
redirectUri,
|
|
436
|
-
expiresAt,
|
|
437
|
-
createdAt: new Date(now).toISOString(),
|
|
438
|
-
};
|
|
439
|
-
const verifier = sealBytes(kek, pendingAad(row, "codeVerifier"), Buffer.from(codeVerifier, "utf8"));
|
|
440
|
-
const secret = client.clientSecret
|
|
441
|
-
? sealBytes(kek, pendingAad(row, "clientSecret"), Buffer.from(client.clientSecret, "utf8"))
|
|
442
|
-
: null;
|
|
443
|
-
await this.store.tx(async (t) => {
|
|
444
|
-
await this.installationVault(t, input.vaultId);
|
|
445
|
-
await t.insertOAuthPending({
|
|
446
|
-
...row,
|
|
447
|
-
kekId: kekIdOf(kek),
|
|
448
|
-
codeVerifier: verifier,
|
|
449
|
-
clientSecret: secret,
|
|
450
|
-
});
|
|
451
|
-
await this.audit(t, {
|
|
452
|
-
actor: "application",
|
|
453
|
-
action: "oauth_start",
|
|
454
|
-
vaultId: input.vaultId,
|
|
455
|
-
target: url,
|
|
456
|
-
outcome: "started",
|
|
457
|
-
});
|
|
458
|
-
});
|
|
459
|
-
return { authorizeUrl, expiresAt };
|
|
460
|
-
}
|
|
461
|
-
/**
|
|
462
|
-
* Finishes a connect from its callback: takes the pending row of `state` (deleted whatever
|
|
463
|
-
* happens next), exchanges the code, and seals an `oauth` credential bound to the server's
|
|
464
|
-
* URL in the installation vault, rotating the vault's OAuth credential for that URL when it
|
|
465
|
-
* has one.
|
|
466
|
-
*/
|
|
467
|
-
async finishOAuth(input) {
|
|
468
|
-
const row = await this.store.tx((t) => t.takeOAuthPending(hashState(input.state)));
|
|
469
|
-
if (!row || Date.parse(row.expiresAt) <= Date.now())
|
|
470
|
-
throw new HttpError(400, "This sign-in is unknown, already used or expired: start the connect again", { code: "oauth_state_invalid" });
|
|
471
|
-
const failed = async (error) => {
|
|
472
|
-
await this.store.tx((t) => this.audit(t, {
|
|
473
|
-
actor: "application",
|
|
474
|
-
action: "oauth_connect",
|
|
475
|
-
vaultId: row.vaultId,
|
|
476
|
-
target: row.url,
|
|
477
|
-
outcome: "failed",
|
|
478
|
-
}));
|
|
479
|
-
throw error;
|
|
385
|
+
headers,
|
|
386
|
+
...(binding.via === undefined ? {} : { via: binding.via }),
|
|
387
|
+
...(binding.identity === undefined ? {} : { identity: binding.identity }),
|
|
388
|
+
vault: decided.vault,
|
|
480
389
|
};
|
|
481
|
-
if (input.error !== undefined || !input.code)
|
|
482
|
-
return failed(new HttpError(400, `The authorization server did not grant access (${oauthErrorName(input.error)})`, { code: "oauth_failed" }));
|
|
483
|
-
const kek = this.kek();
|
|
484
|
-
let client;
|
|
485
|
-
let codeVerifier;
|
|
486
|
-
try {
|
|
487
|
-
codeVerifier = openSealed(kek, row, "codeVerifier", row.codeVerifier);
|
|
488
|
-
client = {
|
|
489
|
-
clientId: row.clientId,
|
|
490
|
-
tokenEndpointAuth: row.tokenEndpointAuth,
|
|
491
|
-
...(row.clientSecret ? { clientSecret: openSealed(kek, row, "clientSecret", row.clientSecret) } : {}),
|
|
492
|
-
};
|
|
493
|
-
}
|
|
494
|
-
catch (error) {
|
|
495
|
-
if (!(error instanceof VaultCryptoError))
|
|
496
|
-
throw error;
|
|
497
|
-
return failed(new HttpError(500, "The sign-in could not be read with this vault key", { code: "oauth_failed" }));
|
|
498
|
-
}
|
|
499
|
-
let tokens;
|
|
500
|
-
try {
|
|
501
|
-
tokens = await exchangeCode({
|
|
502
|
-
tokenEndpoint: row.tokenEndpoint,
|
|
503
|
-
client,
|
|
504
|
-
code: input.code,
|
|
505
|
-
codeVerifier,
|
|
506
|
-
redirectUri: row.redirectUri,
|
|
507
|
-
...(row.resource === null ? {} : { resource: row.resource }),
|
|
508
|
-
fetchFn: this.fetchImpl,
|
|
509
|
-
});
|
|
510
|
-
}
|
|
511
|
-
catch (error) {
|
|
512
|
-
return failed(error instanceof HttpError ? error : new HttpError(502, "The OAuth code exchange failed", { code: "oauth_failed" }));
|
|
513
|
-
}
|
|
514
|
-
if (tokens.token_type.toLowerCase() !== "bearer")
|
|
515
|
-
return failed(new HttpError(502, `The authorization server issued a ${tokens.token_type} token; MCP needs a bearer token`, {
|
|
516
|
-
code: "oauth_failed",
|
|
517
|
-
}));
|
|
518
|
-
const payload = {
|
|
519
|
-
accessToken: tokens.access_token,
|
|
520
|
-
...(tokens.refresh_token
|
|
521
|
-
? {
|
|
522
|
-
refreshToken: tokens.refresh_token,
|
|
523
|
-
tokenEndpoint: row.tokenEndpoint,
|
|
524
|
-
clientId: client.clientId,
|
|
525
|
-
tokenEndpointAuth: client.tokenEndpointAuth,
|
|
526
|
-
...(client.clientSecret ? { clientSecret: client.clientSecret } : {}),
|
|
527
|
-
}
|
|
528
|
-
: {}),
|
|
529
|
-
};
|
|
530
|
-
const now = new Date();
|
|
531
|
-
const expiresAt = typeof tokens.expires_in === "number" && Number.isFinite(tokens.expires_in)
|
|
532
|
-
? new Date(now.getTime() + tokens.expires_in * 1000).toISOString()
|
|
533
|
-
: null;
|
|
534
|
-
return this.store.tx(async (t) => {
|
|
535
|
-
await this.installationVault(t, row.vaultId);
|
|
536
|
-
const existing = (await t.credentialsForVault(row.vaultId, { type: "oauth" })).find((item) => bindingUrl(item) === row.url);
|
|
537
|
-
if (existing) {
|
|
538
|
-
await this.writePayload(t, kek, existing, payload, expiresAt, now.toISOString());
|
|
539
|
-
await this.audit(t, {
|
|
540
|
-
actor: "application",
|
|
541
|
-
action: "oauth_connect",
|
|
542
|
-
vaultId: row.vaultId,
|
|
543
|
-
credentialId: existing.id,
|
|
544
|
-
target: row.url,
|
|
545
|
-
outcome: "rotated",
|
|
546
|
-
});
|
|
547
|
-
return { vaultId: row.vaultId, credentialId: existing.id };
|
|
548
|
-
}
|
|
549
|
-
const id = randomUUID();
|
|
550
|
-
const sealed = encryptSecret(kek, credentialAad(row.vaultId, id, "oauth", row.url), Buffer.from(JSON.stringify(payload), "utf8"));
|
|
551
|
-
await t.insertCredential({
|
|
552
|
-
id,
|
|
553
|
-
vaultId: row.vaultId,
|
|
554
|
-
name: row.server,
|
|
555
|
-
type: "oauth",
|
|
556
|
-
bindingJson: JSON.stringify({ url: row.url }),
|
|
557
|
-
expiresAt,
|
|
558
|
-
createdAt: now.toISOString(),
|
|
559
|
-
rotatedAt: null,
|
|
560
|
-
...sealed,
|
|
561
|
-
});
|
|
562
|
-
await this.audit(t, {
|
|
563
|
-
actor: "application",
|
|
564
|
-
action: "oauth_connect",
|
|
565
|
-
vaultId: row.vaultId,
|
|
566
|
-
credentialId: id,
|
|
567
|
-
target: row.url,
|
|
568
|
-
outcome: "created",
|
|
569
|
-
});
|
|
570
|
-
return { vaultId: row.vaultId, credentialId: id };
|
|
571
|
-
});
|
|
572
|
-
}
|
|
573
|
-
/** An installation vault, else the 404 of a missing one (a user vault is a 400). */
|
|
574
|
-
async installationVault(t, id) {
|
|
575
|
-
const row = await t.getVault(id);
|
|
576
|
-
if (!row || row.scope === "host")
|
|
577
|
-
throw new VaultError(404, "Vault not found");
|
|
578
|
-
if (row.scope !== "installation")
|
|
579
|
-
throw new HttpError(400, "MCP OAuth connect stores its credential in an installation vault only", {
|
|
580
|
-
code: "request_rejected",
|
|
581
|
-
});
|
|
582
|
-
return row;
|
|
583
390
|
}
|
|
584
391
|
// --- host model ----------------------------------------------------------------
|
|
585
392
|
async getHostModel() {
|
|
@@ -675,128 +482,6 @@ export class VaultService {
|
|
|
675
482
|
}));
|
|
676
483
|
}
|
|
677
484
|
// --- internals -----------------------------------------------------------------
|
|
678
|
-
/** Calls the token endpoint outside any transaction, then stores the result. */
|
|
679
|
-
/**
|
|
680
|
-
* Refreshes an OAuth grant once for every concurrent use in this process. A rotating refresh
|
|
681
|
-
* token is valid once, so two refreshes with it would fail the second, or make the provider
|
|
682
|
-
* revoke the grant.
|
|
683
|
-
*/
|
|
684
|
-
refreshShared(kek, row, payload, sessionId, url) {
|
|
685
|
-
let pending = this.refreshing.get(row.id);
|
|
686
|
-
if (!pending) {
|
|
687
|
-
pending = this.refreshLatest(kek, row, payload, sessionId, url).finally(() => this.refreshing.delete(row.id));
|
|
688
|
-
this.refreshing.set(row.id, pending);
|
|
689
|
-
}
|
|
690
|
-
return pending;
|
|
691
|
-
}
|
|
692
|
-
/** Uses a token another process refreshed instead of refreshing again, before and after. */
|
|
693
|
-
async refreshLatest(kek, row, payload, sessionId, url) {
|
|
694
|
-
const before = await this.rotatedSince(kek, row);
|
|
695
|
-
if (before)
|
|
696
|
-
return { status: "ok", payload: before };
|
|
697
|
-
const refreshed = await this.refresh(kek, row, payload, sessionId, url);
|
|
698
|
-
if (refreshed.status === "ok")
|
|
699
|
-
return refreshed;
|
|
700
|
-
// The loser of a race with another process fails with the spent refresh token.
|
|
701
|
-
const after = await this.rotatedSince(kek, row);
|
|
702
|
-
return after ? { status: "ok", payload: after } : refreshed;
|
|
703
|
-
}
|
|
704
|
-
/** The credential's payload when it was rotated after `row` was read and is not due. */
|
|
705
|
-
async rotatedSince(kek, row) {
|
|
706
|
-
const current = await this.store.tx((t) => t.getCredential(row.id));
|
|
707
|
-
if (!current || current.vaultId !== row.vaultId || !isUserCredential(current))
|
|
708
|
-
return undefined;
|
|
709
|
-
if (current.rotatedAt === row.rotatedAt || dueForRefresh(current.expiresAt))
|
|
710
|
-
return undefined;
|
|
711
|
-
try {
|
|
712
|
-
return readPayload(kek, current);
|
|
713
|
-
}
|
|
714
|
-
catch {
|
|
715
|
-
return undefined;
|
|
716
|
-
}
|
|
717
|
-
}
|
|
718
|
-
async refresh(kek, row, payload, sessionId, url) {
|
|
719
|
-
const failRefresh = async () => {
|
|
720
|
-
await this.store.tx((t) => this.audit(t, {
|
|
721
|
-
actor: "host",
|
|
722
|
-
action: "refresh",
|
|
723
|
-
vaultId: row.vaultId,
|
|
724
|
-
credentialId: row.id,
|
|
725
|
-
sessionId,
|
|
726
|
-
target: payload.tokenEndpoint,
|
|
727
|
-
outcome: "refresh_failed",
|
|
728
|
-
}));
|
|
729
|
-
return {
|
|
730
|
-
status: "refused",
|
|
731
|
-
url,
|
|
732
|
-
credentialIds: [row.id],
|
|
733
|
-
reason: "refresh_failed",
|
|
734
|
-
};
|
|
735
|
-
};
|
|
736
|
-
if (!payload.refreshToken || !payload.tokenEndpoint || !payload.clientId)
|
|
737
|
-
return failRefresh();
|
|
738
|
-
let next;
|
|
739
|
-
try {
|
|
740
|
-
const body = new URLSearchParams({
|
|
741
|
-
grant_type: "refresh_token",
|
|
742
|
-
refresh_token: payload.refreshToken,
|
|
743
|
-
client_id: payload.clientId,
|
|
744
|
-
});
|
|
745
|
-
const headers = {
|
|
746
|
-
"content-type": "application/x-www-form-urlencoded",
|
|
747
|
-
};
|
|
748
|
-
if (payload.tokenEndpointAuth === "client_secret_post") {
|
|
749
|
-
if (!payload.clientSecret)
|
|
750
|
-
return failRefresh();
|
|
751
|
-
body.set("client_secret", payload.clientSecret);
|
|
752
|
-
}
|
|
753
|
-
if (payload.tokenEndpointAuth === "client_secret_basic") {
|
|
754
|
-
if (!payload.clientSecret)
|
|
755
|
-
return failRefresh();
|
|
756
|
-
const encoded = Buffer.from(`${encodeURIComponent(payload.clientId)}:${encodeURIComponent(payload.clientSecret)}`).toString("base64");
|
|
757
|
-
headers.authorization = `Basic ${encoded}`;
|
|
758
|
-
}
|
|
759
|
-
const response = await this.fetchImpl(payload.tokenEndpoint, {
|
|
760
|
-
method: "POST",
|
|
761
|
-
headers,
|
|
762
|
-
body: body.toString(),
|
|
763
|
-
redirect: "error",
|
|
764
|
-
signal: AbortSignal.timeout(this.refreshTimeoutMs),
|
|
765
|
-
});
|
|
766
|
-
if (!response.ok)
|
|
767
|
-
return failRefresh();
|
|
768
|
-
const json = (await response.json());
|
|
769
|
-
if (typeof json.access_token !== "string" || json.access_token.length === 0)
|
|
770
|
-
return failRefresh();
|
|
771
|
-
next = {
|
|
772
|
-
...payload,
|
|
773
|
-
accessToken: json.access_token,
|
|
774
|
-
refreshToken: typeof json.refresh_token === "string"
|
|
775
|
-
? json.refresh_token
|
|
776
|
-
: payload.refreshToken,
|
|
777
|
-
};
|
|
778
|
-
const expiresAt = typeof json.expires_in === "number" && Number.isFinite(json.expires_in)
|
|
779
|
-
? new Date(Date.now() + json.expires_in * 1000).toISOString()
|
|
780
|
-
: null;
|
|
781
|
-
await this.store.tx(async (t) => {
|
|
782
|
-
const current = await this.credentialRow(t, row.vaultId, row.id);
|
|
783
|
-
await this.writePayload(t, kek, current, next, expiresAt, new Date().toISOString());
|
|
784
|
-
await this.audit(t, {
|
|
785
|
-
actor: "host",
|
|
786
|
-
action: "refresh",
|
|
787
|
-
vaultId: row.vaultId,
|
|
788
|
-
credentialId: row.id,
|
|
789
|
-
sessionId,
|
|
790
|
-
target: payload.tokenEndpoint,
|
|
791
|
-
outcome: "approved",
|
|
792
|
-
});
|
|
793
|
-
});
|
|
794
|
-
}
|
|
795
|
-
catch {
|
|
796
|
-
return failRefresh();
|
|
797
|
-
}
|
|
798
|
-
return { status: "ok", payload: next };
|
|
799
|
-
}
|
|
800
485
|
async replay(t, key, body, create) {
|
|
801
486
|
const hash = createHash("sha256")
|
|
802
487
|
.update(requestHash(body))
|
|
@@ -815,10 +500,10 @@ export class VaultService {
|
|
|
815
500
|
});
|
|
816
501
|
return created;
|
|
817
502
|
}
|
|
818
|
-
/** Re-seals a user credential's payload and returns the updated row. */
|
|
819
|
-
async writePayload(t, kek, row, payload,
|
|
503
|
+
/** Re-seals a user credential's payload with its new binding and returns the updated row. */
|
|
504
|
+
async writePayload(t, kek, row, payload, binding, rotatedAt) {
|
|
820
505
|
const sealed = encryptSecret(kek, credentialAad(row.vaultId, row.id, row.type, bindingUrl(row)), Buffer.from(JSON.stringify(payload), "utf8"));
|
|
821
|
-
const patch = {
|
|
506
|
+
const patch = { rotatedAt, bindingJson: JSON.stringify(binding), ...sealed };
|
|
822
507
|
if (!(await t.updateCredential(row.vaultId, row.id, patch)))
|
|
823
508
|
throw new VaultError(404, "Credential not found");
|
|
824
509
|
return { ...row, ...patch };
|
|
@@ -937,40 +622,6 @@ export class VaultService {
|
|
|
937
622
|
});
|
|
938
623
|
}
|
|
939
624
|
}
|
|
940
|
-
function hashState(state) {
|
|
941
|
-
return createHash("sha256").update(state, "utf8").digest("hex");
|
|
942
|
-
}
|
|
943
|
-
function pendingAad(row, field) {
|
|
944
|
-
return Buffer.from(canonical({ table: "oauth_pending", stateHash: row.stateHash, vaultId: row.vaultId, field }), "utf8");
|
|
945
|
-
}
|
|
946
|
-
function openSealed(kek, row, field, sealed) {
|
|
947
|
-
const plaintext = openBytes(kek, pendingAad(row, field), Buffer.from(sealed), row.kekId);
|
|
948
|
-
try {
|
|
949
|
-
return plaintext.toString("utf8");
|
|
950
|
-
}
|
|
951
|
-
finally {
|
|
952
|
-
plaintext.fill(0);
|
|
953
|
-
}
|
|
954
|
-
}
|
|
955
|
-
/** The callback URL the authorization server sends the browser back to. */
|
|
956
|
-
function callbackUrl(value) {
|
|
957
|
-
let url;
|
|
958
|
-
try {
|
|
959
|
-
url = new URL(value);
|
|
960
|
-
}
|
|
961
|
-
catch {
|
|
962
|
-
throw new VaultError(400, "The OAuth redirect URI is invalid");
|
|
963
|
-
}
|
|
964
|
-
if (url.protocol !== "https:" && url.protocol !== "http:")
|
|
965
|
-
throw new VaultError(400, "The OAuth redirect URI must be http or https");
|
|
966
|
-
return url.href;
|
|
967
|
-
}
|
|
968
|
-
/** An authorization server's `error`, safe to show: an OAuth error code, or a stand-in. */
|
|
969
|
-
function oauthErrorName(error) {
|
|
970
|
-
if (error === undefined)
|
|
971
|
-
return "no code";
|
|
972
|
-
return /^[A-Za-z0-9_.-]{1,64}$/.test(error) ? error : "error";
|
|
973
|
-
}
|
|
974
625
|
function isUserCredential(row) {
|
|
975
626
|
return row.type !== "model";
|
|
976
627
|
}
|
|
@@ -988,14 +639,20 @@ function vaultInfoOf(row) {
|
|
|
988
639
|
createdAt: row.createdAt,
|
|
989
640
|
};
|
|
990
641
|
}
|
|
642
|
+
function credentialBinding(row) {
|
|
643
|
+
return JSON.parse(row.bindingJson);
|
|
644
|
+
}
|
|
991
645
|
function credentialInfoOf(row) {
|
|
646
|
+
const binding = credentialBinding(row);
|
|
992
647
|
return {
|
|
993
648
|
id: row.id,
|
|
994
649
|
vaultId: row.vaultId,
|
|
995
650
|
name: row.name,
|
|
996
651
|
type: row.type,
|
|
997
|
-
binding:
|
|
998
|
-
...(
|
|
652
|
+
binding: { url: binding.url },
|
|
653
|
+
...(binding.headerNames === undefined ? {} : { headerNames: binding.headerNames }),
|
|
654
|
+
...(binding.via === undefined ? {} : { via: binding.via }),
|
|
655
|
+
...(binding.identity === undefined ? {} : { identity: binding.identity }),
|
|
999
656
|
createdAt: row.createdAt,
|
|
1000
657
|
...(row.rotatedAt ? { rotatedAt: row.rotatedAt } : {}),
|
|
1001
658
|
};
|
|
@@ -1099,44 +756,32 @@ function choose(matches, selection) {
|
|
|
1099
756
|
};
|
|
1100
757
|
return { kind: "use", row: chosen };
|
|
1101
758
|
}
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
}
|
|
1107
|
-
function payloadFromCreate(body) {
|
|
1108
|
-
if (body.auth.type === "bearer")
|
|
1109
|
-
return { token: body.auth.token };
|
|
1110
|
-
const refresh = body.auth.refresh;
|
|
1111
|
-
const tokenEndpoint = refresh
|
|
1112
|
-
? normalizeVaultUrl(refresh.tokenEndpoint, { httpsOnly: true })
|
|
1113
|
-
: undefined;
|
|
1114
|
-
if (body.auth.expiresAt)
|
|
1115
|
-
requireTimestamp(body.auth.expiresAt);
|
|
759
|
+
/** The sealed part of a create or rotate: a token, or a header map with lower-cased names. */
|
|
760
|
+
function payloadOf(auth) {
|
|
761
|
+
if (auth.type === "bearer")
|
|
762
|
+
return { token: auth.token };
|
|
1116
763
|
return {
|
|
1117
|
-
|
|
1118
|
-
...(refresh
|
|
1119
|
-
? {
|
|
1120
|
-
refreshToken: refresh.refreshToken,
|
|
1121
|
-
tokenEndpoint,
|
|
1122
|
-
clientId: refresh.clientId,
|
|
1123
|
-
tokenEndpointAuth: refresh.tokenEndpointAuth.type,
|
|
1124
|
-
...(refresh.tokenEndpointAuth.type === "none"
|
|
1125
|
-
? {}
|
|
1126
|
-
: { clientSecret: refresh.tokenEndpointAuth.clientSecret }),
|
|
1127
|
-
}
|
|
1128
|
-
: {}),
|
|
764
|
+
headers: Object.fromEntries(Object.entries(auth.headers).map(([name, value]) => [name.toLowerCase(), value])),
|
|
1129
765
|
};
|
|
1130
766
|
}
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
767
|
+
/** The unsealed binding of a credential that sends `payload`; refuses a reserved header name. */
|
|
768
|
+
function bindingOf(url, payload, routing) {
|
|
769
|
+
const sent = payload.headers ? Object.keys(payload.headers) : ["authorization"];
|
|
770
|
+
checkCredentialHeaders(sent, routing.identity ?? undefined);
|
|
771
|
+
return {
|
|
772
|
+
url,
|
|
773
|
+
...(routing.via ? { via: routing.via } : {}),
|
|
774
|
+
...(routing.identity ? { identity: { header: routing.identity.header.toLowerCase() } } : {}),
|
|
775
|
+
...(payload.headers ? { headerNames: sent } : {}),
|
|
776
|
+
};
|
|
1135
777
|
}
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
778
|
+
/** The headers a credential sends, or undefined when its payload holds none. */
|
|
779
|
+
function sendHeaders(row, payload) {
|
|
780
|
+
if (row.type === "headers")
|
|
781
|
+
return payload.headers && Object.keys(payload.headers).length > 0
|
|
782
|
+
? { ...payload.headers }
|
|
783
|
+
: undefined;
|
|
784
|
+
return payload.token ? { authorization: `Bearer ${payload.token}` } : undefined;
|
|
1140
785
|
}
|
|
1141
786
|
export function modelBinding(row) {
|
|
1142
787
|
const binding = JSON.parse(row.bindingJson);
|