machine-bridge-mcp 2.0.0 → 3.0.0-beta.9

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.
Files changed (143) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/CONTRIBUTING.md +20 -25
  3. package/README.md +21 -14
  4. package/SECURITY.md +147 -123
  5. package/browser-extension/manifest.json +3 -2
  6. package/browser-extension/service-worker.js +31 -12
  7. package/docs/ARCHITECTURE.md +27 -13
  8. package/docs/AUDIT.md +85 -2
  9. package/docs/CLIENTS.md +2 -2
  10. package/docs/ENGINEERING.md +9 -3
  11. package/docs/GETTING_STARTED.md +10 -9
  12. package/docs/LOCAL_AUTHORIZATION.md +115 -72
  13. package/docs/LOGGING.md +12 -3
  14. package/docs/MANAGED_JOBS.md +24 -29
  15. package/docs/MULTI_ACCOUNT.md +98 -37
  16. package/docs/OPERATIONS.md +33 -22
  17. package/docs/OVERVIEW.md +7 -7
  18. package/docs/PROJECT_STANDARDS.md +9 -5
  19. package/docs/RELEASING.md +171 -64
  20. package/docs/TESTING.md +19 -7
  21. package/docs/THREAT_MODEL.md +138 -67
  22. package/docs/TOOL_REFERENCE.md +2 -2
  23. package/docs/UPGRADING.md +102 -18
  24. package/native/macos/MachineBridgeTrustBroker.swift +233 -0
  25. package/package.json +36 -5
  26. package/scripts/candidate-runtime-store.mjs +38 -0
  27. package/scripts/check-plan.mjs +19 -0
  28. package/scripts/check-runner.mjs +4 -1
  29. package/scripts/commit-message-check.mjs +5 -2
  30. package/scripts/coverage-check.mjs +30 -2
  31. package/scripts/github-push.mjs +11 -0
  32. package/scripts/github-release.mjs +38 -7
  33. package/scripts/install-published-prerelease.mjs +167 -0
  34. package/scripts/local-release-acceptance.mjs +23 -12
  35. package/scripts/npm-publication-policy.mjs +16 -0
  36. package/scripts/prerelease-activation.mjs +79 -0
  37. package/scripts/privacy-check.mjs +7 -5
  38. package/scripts/promotion-digest.mjs +121 -0
  39. package/scripts/publish-npm.mjs +51 -0
  40. package/scripts/published-release.mjs +80 -0
  41. package/scripts/release-acceptance.mjs +30 -1
  42. package/scripts/release-candidate-manifest.mjs +40 -0
  43. package/scripts/release-channel.mjs +106 -0
  44. package/scripts/release-impact-check.mjs +12 -22
  45. package/scripts/release-soak.mjs +237 -0
  46. package/scripts/start-release-candidate.mjs +113 -7
  47. package/src/local/account-access.mjs +6 -0
  48. package/src/local/account-admin.mjs +34 -16
  49. package/src/local/agent-context.mjs +45 -42
  50. package/src/local/app-automation.mjs +9 -4
  51. package/src/local/authority-context.mjs +106 -0
  52. package/src/local/browser-bridge-http.mjs +4 -1
  53. package/src/local/browser-bridge.mjs +14 -9
  54. package/src/local/browser-broker-routes.mjs +4 -0
  55. package/src/local/browser-broker-server.mjs +5 -4
  56. package/src/local/browser-extension-identity.mjs +50 -0
  57. package/src/local/browser-extension-protocol.mjs +9 -4
  58. package/src/local/browser-operation-service.mjs +7 -0
  59. package/src/local/browser-pairing-http.mjs +36 -0
  60. package/src/local/browser-pairing-store.mjs +56 -47
  61. package/src/local/cli-account-admin.mjs +41 -7
  62. package/src/local/cli-activate.mjs +90 -0
  63. package/src/local/cli-approval.mjs +14 -58
  64. package/src/local/cli-local-admin.mjs +5 -15
  65. package/src/local/cli-options.mjs +11 -7
  66. package/src/local/cli-service.mjs +35 -5
  67. package/src/local/cli.mjs +112 -58
  68. package/src/local/daemon-process.mjs +6 -0
  69. package/src/local/delegated-process-sandbox.mjs +147 -0
  70. package/src/local/device-identity.mjs +157 -48
  71. package/src/local/device-root-provider.mjs +87 -0
  72. package/src/local/errors.mjs +25 -3
  73. package/src/local/git-service.mjs +16 -15
  74. package/src/local/job-runner.mjs +35 -27
  75. package/src/local/log.mjs +14 -1
  76. package/src/local/macos-trust-broker.mjs +320 -0
  77. package/src/local/managed-job-lock.mjs +18 -0
  78. package/src/local/managed-job-projection.mjs +9 -1
  79. package/src/local/managed-job-runner.mjs +13 -1
  80. package/src/local/managed-job-storage.mjs +2 -1
  81. package/src/local/managed-job-terminal.mjs +138 -0
  82. package/src/local/managed-jobs.mjs +88 -32
  83. package/src/local/operation-authorization.mjs +75 -239
  84. package/src/local/operation-risk.mjs +28 -0
  85. package/src/local/operation-state-lock.mjs +7 -86
  86. package/src/local/owner-state-lock.mjs +104 -0
  87. package/src/local/policy.mjs +19 -0
  88. package/src/local/process-execution.mjs +21 -9
  89. package/src/local/process-sessions.mjs +27 -17
  90. package/src/local/process-tracker.mjs +30 -6
  91. package/src/local/process-tree-ownership.mjs +59 -0
  92. package/src/local/process-tree.mjs +22 -18
  93. package/src/local/relay-connection.mjs +47 -35
  94. package/src/local/relay-diagnostics.mjs +65 -0
  95. package/src/local/remote-configuration.mjs +48 -0
  96. package/src/local/runtime-activation.mjs +141 -0
  97. package/src/local/runtime-diagnostics.mjs +21 -0
  98. package/src/local/runtime-relay.mjs +9 -8
  99. package/src/local/runtime-reporting.mjs +8 -3
  100. package/src/local/runtime-tool-handlers.mjs +8 -8
  101. package/src/local/runtime.mjs +73 -39
  102. package/src/local/secure-file.mjs +5 -1
  103. package/src/local/security-audit-log.mjs +204 -0
  104. package/src/local/service-convergence.mjs +17 -1
  105. package/src/local/service-ownership.mjs +17 -0
  106. package/src/local/service-restart-handoff.mjs +50 -0
  107. package/src/local/service-restart-scheduler.mjs +49 -0
  108. package/src/local/service-status.mjs +47 -0
  109. package/src/local/service.mjs +142 -43
  110. package/src/local/state-inventory.mjs +21 -3
  111. package/src/local/state.mjs +155 -17
  112. package/src/local/stdio.mjs +5 -0
  113. package/src/local/tool-executor.mjs +39 -6
  114. package/src/local/tool-result-boundary.mjs +51 -0
  115. package/src/local/tools.mjs +1 -0
  116. package/src/local/trusted-executable.mjs +53 -0
  117. package/src/local/trusted-git-executable.mjs +34 -0
  118. package/src/local/trusted-github-cli.mjs +24 -0
  119. package/src/local/windows-service.mjs +20 -0
  120. package/src/local/worker-deployment.mjs +3 -4
  121. package/src/local/worker-secret-file.mjs +2 -2
  122. package/src/local/workspace-file-service.mjs +34 -31
  123. package/src/shared/admin-auth.d.mts +2 -1
  124. package/src/shared/admin-auth.mjs +3 -3
  125. package/src/shared/device-session-auth.d.mts +20 -0
  126. package/src/shared/device-session-auth.mjs +51 -0
  127. package/src/shared/server-metadata.json +1 -1
  128. package/src/shared/tool-catalog.json +2 -2
  129. package/src/worker/account-admin.ts +23 -22
  130. package/src/worker/authority.ts +9 -2
  131. package/src/worker/daemon-auth.ts +45 -67
  132. package/src/worker/daemon-sockets.ts +12 -2
  133. package/src/worker/device-session-verifier.ts +129 -0
  134. package/src/worker/dpop.ts +151 -0
  135. package/src/worker/http.ts +47 -16
  136. package/src/worker/index.ts +53 -16
  137. package/src/worker/nonce-store.ts +1 -5
  138. package/src/worker/oauth-client-admin.ts +45 -0
  139. package/src/worker/oauth-controller.ts +55 -6
  140. package/src/worker/oauth-refresh-families.ts +21 -7
  141. package/src/worker/oauth-state.ts +6 -0
  142. package/src/worker/oauth-tokens.ts +28 -8
  143. package/src/worker/websocket-protocol.ts +8 -2
@@ -19,11 +19,12 @@ import { daemonToolError, publicWorkerToolError, WorkerToolError } from "./error
19
19
  import { sanitizeDaemonPolicy, sanitizeDaemonTools } from "./policy.ts";
20
20
  import { accountRoleAllowsTool, accountRoleToolNames, type AccountRole } from "./access.ts";
21
21
  import { OAuthController, type AuthorizedToken, type OAuthControllerEnv } from "./oauth-controller.ts";
22
+ import { consumeDpopProof, verifyDpopProof } from "./dpop.ts";
22
23
  import { accountAuthoritySnapshot, decorateProjectOverview, describeDaemonCeiling } from "./authority.ts";
23
24
  import { serverInfoTool, workspaceTools } from "./tool-catalog.ts";
24
25
  import { OFFLINE_ACCESS_SCOPE, randomToken } from "./oauth-state.ts";
25
26
  import {
26
- HttpError, applyCors, baseUrl, bearerToken, corsPreflight, json, methodNotAllowed,
27
+ HttpError, applyCors, baseUrl, corsPreflight, discardRequestBody, json, methodNotAllowed, oauthAccessToken,
27
28
  parseJsonRequest, workerErrorClass,
28
29
  } from "./http.ts";
29
30
  import {
@@ -31,11 +32,11 @@ import {
31
32
  sessionInstructionText, textToolResult, validateProtocolVersionHeader, type JsonRpcRequest,
32
33
  } from "./mcp-jsonrpc.ts";
33
34
  import {
34
- closeWebSocketQuietly, isObjectRecord, rejectDaemonMessage, sendWebSocketQuietly,
35
+ closeWebSocketQuietly, isObjectRecord, rejectDaemonMessage, sendWebSocketQuietly, trySendWebSocket,
35
36
  } from "./websocket-protocol.ts";
36
37
 
37
38
  const SERVER_NAME = String(serverMetadata.name);
38
- const SERVER_VERSION = "2.0.0";
39
+ const SERVER_VERSION = "3.0.0-beta.9";
39
40
  const MCP_PROTOCOL_VERSION = String(serverMetadata.protocolVersion);
40
41
  const MCP_SUPPORTED_PROTOCOL_VERSIONS = serverMetadata.supportedProtocolVersions.map((value) => String(value));
41
42
  const DEFAULT_MAX_BODY_BYTES = 8 * 1024 * 1024;
@@ -46,7 +47,6 @@ const DAEMON_RECONNECT_GRACE_MS = 30_000;
46
47
 
47
48
  interface BridgeEnv extends OAuthControllerEnv {
48
49
  BRIDGE: DurableObjectNamespace<BridgeRoom>;
49
- ACCOUNT_ADMIN_SECRET: string;
50
50
  DAEMON_DEVICE_PUBLIC_KEY: string;
51
51
  OAUTH_TOKEN_VERSION: string;
52
52
  MBM_WORKER_MAX_BODY_BYTES?: string;
@@ -63,7 +63,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
63
63
 
64
64
  constructor(ctx: DurableObjectState, env: BridgeEnv) {
65
65
  super(ctx, env);
66
- this.oauth = new OAuthController(ctx, env, SERVER_NAME);
66
+ this.oauth = new OAuthController(ctx, env, SERVER_NAME, SERVER_VERSION);
67
67
  this.daemonRegistry = new DaemonSocketRegistry(ctx);
68
68
  }
69
69
 
@@ -107,6 +107,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
107
107
  }
108
108
  if (url.pathname === "/admin/accounts") return await this.oauth.handleAccountAdmin(request, "accounts");
109
109
  if (url.pathname === "/admin/accounts/rotate-password") return await this.oauth.handleAccountAdmin(request, "rotate-password");
110
+ if (url.pathname === "/admin/clients") return await this.oauth.handleClientAdmin(request);
110
111
  if (url.pathname === "/oauth/register") {
111
112
  if (request.method !== "POST") return methodNotAllowed("POST");
112
113
  return await this.oauth.registerClient(request);
@@ -185,12 +186,20 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
185
186
  rejectDaemonMessage(ws, "invalid_daemon_instance", 1002, "daemon instance id required");
186
187
  return;
187
188
  }
188
- if (!socketAttachment.authChallenge || !socketAttachment.authIssuedAt || !socketAttachment.authExpiresAt || !socketAttachment.workerOrigin) {
189
+ if (
190
+ !socketAttachment.authChallenge
191
+ || !socketAttachment.authIssuedAt
192
+ || !socketAttachment.authExpiresAt
193
+ || !socketAttachment.workerOrigin
194
+ || !socketAttachment.authSessionPublicKeyJson
195
+ || !socketAttachment.authSessionKeyId
196
+ || !socketAttachment.authCertificateExpiresAt
197
+ ) {
189
198
  rejectDaemonMessage(ws, "missing_daemon_challenge", 1008, "daemon challenge missing");
190
199
  return;
191
200
  }
192
201
  const authenticated = await verifyDaemonAuthentication({
193
- publicKeyJson: this.env.DAEMON_DEVICE_PUBLIC_KEY ?? "",
202
+ publicKeyJson: socketAttachment.authSessionPublicKeyJson,
194
203
  authentication: body.authentication,
195
204
  challenge: {
196
205
  scheme: "device-signature-v1",
@@ -202,6 +211,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
202
211
  server: SERVER_NAME,
203
212
  version: SERVER_VERSION,
204
213
  instanceId,
214
+ certificateExpiresAt: socketAttachment.authCertificateExpiresAt,
205
215
  });
206
216
  if (!authenticated) {
207
217
  rejectDaemonMessage(ws, "daemon_authentication_failed", 1008, "daemon authentication failed");
@@ -238,7 +248,9 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
238
248
 
239
249
  if (body.type === "heartbeat" || body.type === "ping") {
240
250
  await this.touchDaemonSocket(ws);
241
- ws.send(JSON.stringify({ type: "pong", ts: body.ts ?? Date.now() }));
251
+ if (!trySendWebSocket(ws, { type: "pong", ts: body.ts ?? Date.now() })) {
252
+ this.invalidateDaemonSocket(ws, "failed to acknowledge daemon heartbeat", "daemon pong failed", "daemon_transport_error");
253
+ }
242
254
  return;
243
255
  }
244
256
 
@@ -311,13 +323,31 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
311
323
  });
312
324
  }
313
325
 
314
- const authorized = await this.oauth.verifyAccessToken(bearerToken(request), base);
315
- if (!authorized) {
316
- try { await request.arrayBuffer(); } catch { /* The rejected body may already be unavailable; the 401 remains authoritative. */ }
317
- return new Response("OAuth bearer token required", {
326
+ const access = oauthAccessToken(request);
327
+ const authorized = await this.oauth.verifyAccessToken(access.token, base);
328
+ let dpopValid = true;
329
+ if (authorized?.dpopJkt) {
330
+ const proof = access.scheme === "dpop" ? await verifyDpopProof({
331
+ request,
332
+ expectedMethod: "POST",
333
+ expectedUrl: request.url,
334
+ accessToken: access.token,
335
+ expectedJkt: authorized.dpopJkt,
336
+ }) : null;
337
+ dpopValid = Boolean(proof && await consumeDpopProof(this.ctx.storage, proof));
338
+ } else if (authorized && access.scheme !== "bearer") {
339
+ dpopValid = false;
340
+ }
341
+ if (!authorized || !dpopValid) {
342
+ // Durable Object fetch forwarding transfers a live request stream. Consume it
343
+ // before returning, but retain no bytes, so the 401 cannot race a pending read
344
+ // and unauthenticated input never enters an in-memory request buffer.
345
+ await discardRequestBody(request, this.bodyLimitBytes());
346
+ const scheme = authorized?.dpopJkt ? "DPoP" : "Bearer";
347
+ return new Response(authorized?.dpopJkt ? "Valid DPoP proof required" : "OAuth bearer token required", {
318
348
  status: 401,
319
349
  headers: {
320
- "WWW-Authenticate": `Bearer resource_metadata="${base}/.well-known/oauth-protected-resource/mcp"`,
350
+ "WWW-Authenticate": `${scheme} resource_metadata="${base}/.well-known/oauth-protected-resource/mcp"`,
321
351
  "cache-control": "no-store",
322
352
  "content-type": "text/plain; charset=utf-8",
323
353
  "x-content-type-options": "nosniff",
@@ -509,6 +539,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
509
539
  account_id: authorized.accountId,
510
540
  account_version: authorized.accountVersion,
511
541
  client_id: authorized.clientId,
542
+ family_id: authorized.familyId,
512
543
  role: authorized.role,
513
544
  },
514
545
  }));
@@ -558,9 +589,9 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
558
589
  const [client, server] = Object.values(pair) as [WebSocket, WebSocket];
559
590
  this.ctx.acceptWebSocket(server);
560
591
  this.observability.socketCandidate();
561
- this.daemonRegistry.beginCandidate(server, challenge);
592
+ this.daemonRegistry.beginCandidate(server, challenge, preflight);
562
593
  await this.scheduleSocketAlarms();
563
- server.send(JSON.stringify({
594
+ const welcomed = trySendWebSocket(server, {
564
595
  type: "welcome",
565
596
  server: SERVER_NAME,
566
597
  version: SERVER_VERSION,
@@ -571,7 +602,12 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
571
602
  issued_at: challenge.issuedAt,
572
603
  expires_at: challenge.expiresAt,
573
604
  },
574
- }));
605
+ });
606
+ if (!welcomed) {
607
+ this.daemonRegistry.expire(server);
608
+ closeWebSocketQuietly(server, 1011, "daemon welcome failed");
609
+ await this.scheduleSocketAlarms();
610
+ }
575
611
  return new Response(null, { status: 101, webSocket: client });
576
612
  }
577
613
 
@@ -749,6 +785,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
749
785
  response_types_supported: ["code"],
750
786
  grant_types_supported: ["authorization_code", "refresh_token"],
751
787
  token_endpoint_auth_methods_supported: ["none"],
788
+ dpop_signing_alg_values_supported: ["ES256"],
752
789
  code_challenge_methods_supported: ["S256"],
753
790
  scopes_supported: [SERVER_NAME, OFFLINE_ACCESS_SCOPE],
754
791
  };
@@ -42,12 +42,8 @@ async function consumeInStorage(
42
42
  if (expiresAt <= options.now) delete nonces[nonce];
43
43
  }
44
44
  if (nonces[options.nonce]) return false;
45
+ if (Object.keys(nonces).length >= options.maximum) return false;
45
46
  nonces[options.nonce] = options.expiresAt;
46
- const entries = Object.entries(nonces).sort((left, right) => left[1] - right[1] || left[0].localeCompare(right[0]));
47
- while (entries.length > options.maximum) {
48
- const [nonce] = entries.shift()!;
49
- delete nonces[nonce];
50
- }
51
47
  await storage.put(options.key, nonces);
52
48
  return true;
53
49
  }
@@ -0,0 +1,45 @@
1
+ import { json, parseRequestBody } from "./http.ts";
2
+ import { OAUTH_REFRESH_STORE_KEY } from "./oauth-refresh-families.ts";
3
+ import type { OAuthRefreshStore, OAuthStore } from "./oauth-state.ts";
4
+
5
+ const BODY_LIMIT_BYTES = 64 * 1024;
6
+ const MAX_OAUTH_CLIENTS = 128;
7
+
8
+ export async function handleOAuthClientAdminOperation(options: {
9
+ request: Request;
10
+ store: OAuthStore;
11
+ refreshStore: OAuthRefreshStore;
12
+ storage: DurableObjectStorage;
13
+ now: number;
14
+ }): Promise<Response> {
15
+ const { request, store, refreshStore, storage, now } = options;
16
+ if (request.method === "GET") {
17
+ const clients = Object.values(store.clients)
18
+ .sort((left, right) => right.last_used_at - left.last_used_at || left.client_name.localeCompare(right.client_name))
19
+ .map((client) => ({
20
+ client_id: client.client_id,
21
+ client_name: client.client_name,
22
+ redirect_uris: [...client.redirect_uris],
23
+ created_at: client.created_at,
24
+ last_used_at: client.last_used_at,
25
+ trusted_account_id: client.trusted_account_id ?? null,
26
+ trusted_account_version: client.trusted_account_version ?? null,
27
+ trusted_role: client.trusted_role ?? null,
28
+ trusted_at: client.trusted_at ?? null,
29
+ active_access_tokens: Object.values(store.tokens).filter((token) => token.client_id === client.client_id && token.expires_at > now).length,
30
+ active_refresh_tokens: Object.values(refreshStore.tokens).filter((token) => token.client_id === client.client_id && token.expires_at > now).length,
31
+ }));
32
+ return json({ clients, maximum: MAX_OAUTH_CLIENTS });
33
+ }
34
+ if (request.method !== "DELETE") return json({ error: "method_not_allowed" }, 405, { Allow: "GET, DELETE" });
35
+ const body = await parseRequestBody(request, BODY_LIMIT_BYTES);
36
+ const clientId = String(body.client_id ?? "");
37
+ if (!/^mcp_client_[A-Za-z0-9_-]{43}$/.test(clientId)) return json({ error: "invalid_client_id" }, 400);
38
+ if (!store.clients[clientId]) return json({ error: "client_not_found" }, 404);
39
+ delete store.clients[clientId];
40
+ for (const [key, value] of Object.entries(store.codes)) if (value.client_id === clientId) delete store.codes[key];
41
+ for (const [key, value] of Object.entries(store.tokens)) if (value.client_id === clientId) delete store.tokens[key];
42
+ for (const [key, value] of Object.entries(refreshStore.tokens)) if (value.client_id === clientId) delete refreshStore.tokens[key];
43
+ await storage.put({ oauth: store, [OAUTH_REFRESH_STORE_KEY]: refreshStore });
44
+ return json({ removed: true, client_id: clientId });
45
+ }
@@ -12,6 +12,8 @@ import {
12
12
  normalizeRedirectUri, oauthRedirect, parseRequestBody, searchParamsObject,
13
13
  } from "./http.ts";
14
14
  import { authorizationPage } from "./oauth-authorization-page.ts";
15
+ import { handleOAuthClientAdminOperation } from "./oauth-client-admin.ts";
16
+ import { loadOAuthRefreshStore } from "./oauth-refresh-families.ts";
15
17
 
16
18
  const OAUTH_BODY_LIMIT_BYTES = 64 * 1024;
17
19
  const OAUTH_UNUSED_CLIENT_TTL_SECONDS = 60 * 60;
@@ -23,7 +25,6 @@ const MAX_OAUTH_CODES = 200;
23
25
  const MAX_AUTH_FAILURE_IDENTITIES = 200;
24
26
 
25
27
  export interface OAuthControllerEnv {
26
- ACCOUNT_ADMIN_SECRET: string;
27
28
  DAEMON_DEVICE_PUBLIC_KEY: string;
28
29
  OAUTH_TOKEN_VERSION: string;
29
30
  }
@@ -33,6 +34,8 @@ export interface AuthorizedToken {
33
34
  accountId: string;
34
35
  accountVersion: number;
35
36
  clientId: string;
37
+ familyId: string;
38
+ dpopJkt: string;
36
39
  role: AccountRole;
37
40
  }
38
41
 
@@ -40,12 +43,14 @@ export class OAuthController {
40
43
  private readonly ctx: DurableObjectState;
41
44
  private readonly env: OAuthControllerEnv;
42
45
  private readonly serverName: string;
46
+ private readonly serverVersion: string;
43
47
  private oauthQueue: Promise<void> = Promise.resolve();
44
48
 
45
- constructor(ctx: DurableObjectState, env: OAuthControllerEnv, serverName: string) {
49
+ constructor(ctx: DurableObjectState, env: OAuthControllerEnv, serverName: string, serverVersion: string) {
46
50
  this.ctx = ctx;
47
51
  this.env = env;
48
52
  this.serverName = serverName;
53
+ this.serverVersion = serverVersion;
49
54
  }
50
55
 
51
56
  private async oauthStore(): Promise<OAuthStore> {
@@ -108,7 +113,14 @@ export class OAuthController {
108
113
  async handleAccountAdmin(request: Request, operation: "accounts" | "rotate-password"): Promise<Response> {
109
114
  return this.withOAuthLock(async () => {
110
115
  const now = Math.floor(Date.now() / 1000);
111
- const authorization = await accountAdminAuthorized(request, this.env.ACCOUNT_ADMIN_SECRET ?? "", now);
116
+ const authorization = await accountAdminAuthorized(
117
+ request,
118
+ this.env.DAEMON_DEVICE_PUBLIC_KEY ?? "",
119
+ new URL(request.url).origin,
120
+ this.serverName,
121
+ this.serverVersion,
122
+ now,
123
+ );
112
124
  if (!authorization || !(await consumeAccountAdminNonce(this.ctx.storage, authorization, now))) {
113
125
  return json({ error: "unauthorized" }, 401);
114
126
  }
@@ -120,6 +132,26 @@ export class OAuthController {
120
132
  });
121
133
  }
122
134
 
135
+ async handleClientAdmin(request: Request): Promise<Response> {
136
+ return this.withOAuthLock(async () => {
137
+ const now = Math.floor(Date.now() / 1000);
138
+ const authorization = await accountAdminAuthorized(
139
+ request,
140
+ this.env.DAEMON_DEVICE_PUBLIC_KEY ?? "",
141
+ new URL(request.url).origin,
142
+ this.serverName,
143
+ this.serverVersion,
144
+ now,
145
+ );
146
+ if (!authorization || !(await consumeAccountAdminNonce(this.ctx.storage, authorization, now))) {
147
+ return json({ error: "unauthorized" }, 401);
148
+ }
149
+ const store = await this.oauthStore();
150
+ const refreshStore = await loadOAuthRefreshStore(store, this.ctx.storage);
151
+ return handleOAuthClientAdminOperation({ request, store, refreshStore, storage: this.ctx.storage, now });
152
+ });
153
+ }
154
+
123
155
  async registerClient(request: Request): Promise<Response> {
124
156
  const body = await parseRequestBody(request, OAUTH_BODY_LIMIT_BYTES);
125
157
  const redirectUris = body.redirect_uris;
@@ -212,9 +244,16 @@ export class OAuthController {
212
244
  const status = store.auth_failures[identity]?.blocked_until > now ? 429 : 401;
213
245
  return authorizationPage({ request, base, serverName: this.serverName, error: "Invalid account credentials.", submitted: body, status, authorization: validation.value });
214
246
  }
247
+ if (client.trusted_account_id && client.trusted_account_id !== account.account_id) {
248
+ return authorizationPage({ request, base, serverName: this.serverName, error: "This client is already bound to another account. Revoke it locally before changing accounts.", submitted: body, status: 409, authorization: validation.value });
249
+ }
215
250
  delete store.auth_failures[identity];
216
251
  client.last_used_at = now;
217
252
  client.has_been_authorized = true;
253
+ client.trusted_account_id = account.account_id;
254
+ client.trusted_account_version = account.version;
255
+ client.trusted_role = account.role;
256
+ client.trusted_at ||= now;
218
257
 
219
258
  const code = randomToken("mcp_code");
220
259
  const redirectLocation = authorizationRedirectLocation(redirectUri, code, state);
@@ -263,14 +302,24 @@ export class OAuthController {
263
302
  if (!record.version || !currentVersion || !(await safeEqual(record.version, currentVersion))) return null;
264
303
  if (record.resource !== `${base}/mcp`) return null;
265
304
  const account = store.accounts[record.account_id];
266
- if (!account || !account.active || account.version !== record.account_version || account.role !== record.role) {
305
+ const client = store.clients[record.client_id];
306
+ if (
307
+ !account
308
+ || !account.active
309
+ || account.version !== record.account_version
310
+ || account.role !== record.role
311
+ || !client
312
+ || client.trusted_account_id !== account.account_id
313
+ || client.trusted_account_version !== account.version
314
+ || client.trusted_role !== account.role
315
+ ) {
267
316
  delete store.tokens[key];
268
317
  await this.ctx.storage.put("oauth", store);
269
318
  return null;
270
319
  }
271
320
  return {
272
321
  tokenKey: key, accountId: account.account_id,
273
- accountVersion: account.version, clientId: record.client_id, role: account.role,
322
+ accountVersion: account.version, clientId: record.client_id, familyId: String(record.family_id || ""), dpopJkt: String(record.dpop_jkt || ""), role: account.role,
274
323
  };
275
324
  });
276
325
  }
@@ -288,7 +337,7 @@ export class OAuthController {
288
337
  }
289
338
 
290
339
  identityKey(): string {
291
- const key = this.env.OAUTH_TOKEN_VERSION || this.env.ACCOUNT_ADMIN_SECRET;
340
+ const key = this.env.OAUTH_TOKEN_VERSION;
292
341
  if (!key) throw new HttpError(503, "server_not_configured", "OAuth identity key is not configured");
293
342
  return key;
294
343
  }
@@ -54,13 +54,14 @@ export async function loadOAuthRefreshStore(
54
54
  changed = true;
55
55
  }
56
56
  }
57
- if (pruneOAuthRefreshReplayState(store)) changed = true;
57
+ if (pruneOAuthRefreshReplayState(store, oauthStore)) changed = true;
58
58
  if (changed || migrated) await storage.put(OAUTH_REFRESH_STORE_KEY, store);
59
59
  return store;
60
60
  }
61
61
 
62
62
 
63
63
  export function recordConsumedRefreshToken(
64
+ oauthStore: OAuthStore,
64
65
  store: OAuthRefreshStore,
65
66
  tokenHash: string,
66
67
  familyId: string,
@@ -74,16 +75,17 @@ export function recordConsumedRefreshToken(
74
75
  throw new Error("consumed refresh-token lifetime is invalid");
75
76
  }
76
77
  store.consumed[tokenHash] = { family_id: familyId, consumed_at: consumedAt, expires_at: expiresAt };
77
- pruneOAuthRefreshReplayState(store);
78
+ pruneOAuthRefreshReplayState(store, oauthStore);
78
79
  }
79
80
 
80
- export function pruneOAuthRefreshReplayState(store: OAuthRefreshStore): boolean {
81
+ export function pruneOAuthRefreshReplayState(store: OAuthRefreshStore, oauthStore?: OAuthStore): boolean {
81
82
  let changed = false;
82
83
  const consumed = Object.entries(store.consumed).sort((left, right) => (
83
84
  left[1].consumed_at - right[1].consumed_at || left[0].localeCompare(right[0])
84
85
  ));
85
86
  while (consumed.length > MAX_CONSUMED_REFRESH_TOKENS) {
86
- const [tokenHash] = consumed.shift()!;
87
+ const [tokenHash, marker] = consumed.shift()!;
88
+ revokeRefreshFamilyRecords(oauthStore, store, marker.family_id, marker.expires_at);
87
89
  delete store.consumed[tokenHash];
88
90
  changed = true;
89
91
  }
@@ -115,6 +117,7 @@ function validRefreshStoreRecords(store: OAuthRefreshStore): boolean {
115
117
  && token.version.length > 0
116
118
  && token.version.length <= 256
117
119
  && FAMILY_ID_PATTERN.test(token.family_id)
120
+ && (token.dpop_jkt === undefined || /^[A-Za-z0-9_-]{43}$/.test(token.dpop_jkt))
118
121
  && validTimestamp(token.issued_at)
119
122
  && validTimestamp(token.expires_at)
120
123
  && validTimestamp(token.family_expires_at)
@@ -160,13 +163,24 @@ export function revokeOAuthRefreshFamily(
160
163
  refreshStore: OAuthRefreshStore,
161
164
  familyId: string,
162
165
  expiresAt: number,
166
+ ): void {
167
+ revokeRefreshFamilyRecords(oauthStore, refreshStore, familyId, expiresAt);
168
+ pruneOAuthRefreshReplayState(refreshStore, oauthStore);
169
+ }
170
+
171
+ function revokeRefreshFamilyRecords(
172
+ oauthStore: OAuthStore | undefined,
173
+ refreshStore: OAuthRefreshStore,
174
+ familyId: string,
175
+ expiresAt: number,
163
176
  ): void {
164
177
  for (const [key, token] of Object.entries(refreshStore.tokens)) {
165
178
  if (token.family_id === familyId) delete refreshStore.tokens[key];
166
179
  }
167
- for (const [key, token] of Object.entries(oauthStore.tokens)) {
168
- if (token.family_id === familyId) delete oauthStore.tokens[key];
180
+ if (oauthStore) {
181
+ for (const [key, token] of Object.entries(oauthStore.tokens)) {
182
+ if (token.family_id === familyId) delete oauthStore.tokens[key];
183
+ }
169
184
  }
170
185
  refreshStore.revoked_families[familyId] = { expires_at: expiresAt, reason: "replay" };
171
- pruneOAuthRefreshReplayState(refreshStore);
172
186
  }
@@ -28,8 +28,13 @@ export interface OAuthClient {
28
28
  last_used_at: number;
29
29
  has_been_authorized?: boolean;
30
30
  registration_identity?: string;
31
+ trusted_account_id?: string;
32
+ trusted_account_version?: number;
33
+ trusted_role?: AccountRole;
34
+ trusted_at?: number;
31
35
  }
32
36
 
37
+
33
38
  export interface OAuthCode {
34
39
  client_id: string;
35
40
  account_id: string;
@@ -52,6 +57,7 @@ export interface OAuthToken {
52
57
  version: string;
53
58
  expires_at: number;
54
59
  family_id?: string;
60
+ dpop_jkt?: string;
55
61
  }
56
62
 
57
63
  export interface OAuthRefreshToken extends OAuthToken {
@@ -5,6 +5,7 @@ import {
5
5
  } from "./oauth-state.ts";
6
6
  import { HttpError, json, parseRequestBody } from "./http.ts";
7
7
  import { loadOAuthRefreshStore, OAUTH_REFRESH_STORE_KEY, recordConsumedRefreshToken, revokeOAuthRefreshFamily } from "./oauth-refresh-families.ts";
8
+ import { consumeDpopProof, verifyDpopProof, type VerifiedDpopProof } from "./dpop.ts";
8
9
 
9
10
  const ACCESS_TOKEN_TTL_SECONDS = 15 * 60;
10
11
  const REFRESH_TOKEN_IDLE_TTL_SECONDS = 60 * 60 * 24 * 14;
@@ -36,9 +37,12 @@ export async function exchangeOAuthToken(
36
37
  options: OAuthTokenExchangeOptions,
37
38
  ): Promise<Response> {
38
39
  const body = await parseRequestBody(request, OAUTH_BODY_LIMIT_BYTES);
40
+ const hasDpop = Boolean(request.headers.get("DPoP"));
41
+ const dpop = hasDpop ? await verifyDpopProof({ request, expectedMethod: "POST", expectedUrl: request.url }) : null;
42
+ if (hasDpop && !dpop) return json({ error: "invalid_dpop_proof" }, 400);
39
43
  const grantType = String(body.grant_type ?? "");
40
- if (grantType === "authorization_code") return exchangeAuthorizationCode(body, base, options);
41
- if (grantType === "refresh_token") return exchangeRefreshToken(body, base, options);
44
+ if (grantType === "authorization_code") return exchangeAuthorizationCode(body, base, options, dpop || undefined);
45
+ if (grantType === "refresh_token") return exchangeRefreshToken(body, base, options, dpop || undefined);
42
46
  return json({ error: "unsupported_grant_type" }, 400);
43
47
  }
44
48
 
@@ -46,6 +50,7 @@ async function exchangeAuthorizationCode(
46
50
  body: Record<string, unknown>,
47
51
  base: string,
48
52
  options: OAuthTokenExchangeOptions,
53
+ dpop?: VerifiedDpopProof,
49
54
  ): Promise<Response> {
50
55
  const code = String(body.code ?? "");
51
56
  const verifier = String(body.code_verifier ?? "");
@@ -69,11 +74,14 @@ async function exchangeAuthorizationCode(
69
74
  const client = oauthStore.clients[record.client_id];
70
75
  if (!client) return json({ error: "invalid_grant", error_description: "unknown client" }, 400);
71
76
 
72
- const issued = await issueTokenPair(oauthStore, refreshStore, record, options.tokenVersion);
77
+ if (dpop && !(await consumeDpopProof(options.storage, dpop))) {
78
+ return json({ error: "invalid_dpop_proof" }, 400);
79
+ }
80
+ const issued = await issueTokenPair(oauthStore, refreshStore, record, options.tokenVersion, dpop?.jkt);
73
81
  delete oauthStore.codes[code];
74
82
  client.last_used_at = Math.floor(Date.now() / 1000);
75
83
  await saveOAuthStores(oauthStore, refreshStore, options.storage);
76
- return tokenResponse(issued, record.scope);
84
+ return tokenResponse(issued, record.scope, dpop?.jkt);
77
85
  });
78
86
  }
79
87
 
@@ -81,6 +89,7 @@ async function exchangeRefreshToken(
81
89
  body: Record<string, unknown>,
82
90
  base: string,
83
91
  options: OAuthTokenExchangeOptions,
92
+ dpop?: VerifiedDpopProof,
84
93
  ): Promise<Response> {
85
94
  const refreshToken = String(body.refresh_token ?? "");
86
95
  if (!/^mcp_rt_[A-Za-z0-9_-]{43}$/.test(refreshToken)) return json({ error: "invalid_grant" }, 400);
@@ -97,6 +106,8 @@ async function exchangeRefreshToken(
97
106
  }
98
107
  return json({ error: "invalid_grant" }, 400);
99
108
  }
109
+ if (record.dpop_jkt && record.dpop_jkt !== dpop?.jkt) return json({ error: "invalid_dpop_proof" }, 400);
110
+ if (!record.dpop_jkt && dpop?.jkt) record.dpop_jkt = dpop.jkt;
100
111
  if (refreshStore.revoked_families[record.family_id]) {
101
112
  delete refreshStore.tokens[refreshKey];
102
113
  await options.storage.put(OAUTH_REFRESH_STORE_KEY, refreshStore);
@@ -124,15 +135,21 @@ async function exchangeRefreshToken(
124
135
  || account.version !== record.account_version
125
136
  || account.role !== record.role
126
137
  || !client
138
+ || client.trusted_account_id !== account.account_id
139
+ || client.trusted_account_version !== account.version
140
+ || client.trusted_role !== account.role
127
141
  ) {
128
142
  delete refreshStore.tokens[refreshKey];
129
143
  await options.storage.put(OAUTH_REFRESH_STORE_KEY, refreshStore);
130
144
  return json({ error: "invalid_grant" }, 400);
131
145
  }
132
146
 
147
+ if (dpop && !(await consumeDpopProof(options.storage, dpop))) {
148
+ return json({ error: "invalid_dpop_proof" }, 400);
149
+ }
133
150
  let issued: IssuedTokenPair;
134
151
  try {
135
- issued = await issueTokenPair(oauthStore, refreshStore, record, options.tokenVersion);
152
+ issued = await issueTokenPair(oauthStore, refreshStore, record, options.tokenVersion, dpop?.jkt);
136
153
  } catch (error) {
137
154
  if (error instanceof HttpError && error.code === "invalid_grant") {
138
155
  delete refreshStore.tokens[refreshKey];
@@ -143,6 +160,7 @@ async function exchangeRefreshToken(
143
160
  }
144
161
  delete refreshStore.tokens[refreshKey];
145
162
  recordConsumedRefreshToken(
163
+ oauthStore,
146
164
  refreshStore,
147
165
  refreshKey,
148
166
  record.family_id,
@@ -151,7 +169,7 @@ async function exchangeRefreshToken(
151
169
  );
152
170
  client.last_used_at = Math.floor(Date.now() / 1000);
153
171
  await saveOAuthStores(oauthStore, refreshStore, options.storage);
154
- return tokenResponse(issued, record.scope);
172
+ return tokenResponse(issued, record.scope, dpop?.jkt || record.dpop_jkt);
155
173
  });
156
174
  }
157
175
 
@@ -160,6 +178,7 @@ async function issueTokenPair(
160
178
  refreshStore: OAuthRefreshStore,
161
179
  source: OAuthCode | OAuthRefreshToken,
162
180
  tokenVersion: string,
181
+ dpopJkt?: string,
163
182
  ): Promise<IssuedTokenPair> {
164
183
  if (!tokenVersion) throw new HttpError(503, "server_error", "OAuth token version is not configured");
165
184
  const now = Math.floor(Date.now() / 1000);
@@ -179,6 +198,7 @@ async function issueTokenPair(
179
198
  resource: source.resource,
180
199
  version: tokenVersion,
181
200
  family_id: familyId,
201
+ ...(dpopJkt ? { dpop_jkt: dpopJkt } : {}),
182
202
  };
183
203
  oauthStore.tokens[`sha256:${await sha256Hex(accessToken)}`] = {
184
204
  ...common,
@@ -198,11 +218,11 @@ async function issueTokenPair(
198
218
  return { accessToken, refreshToken };
199
219
  }
200
220
 
201
- function tokenResponse(issued: IssuedTokenPair, scope: string): Response {
221
+ function tokenResponse(issued: IssuedTokenPair, scope: string, dpopJkt?: string): Response {
202
222
  return json({
203
223
  access_token: issued.accessToken,
204
224
  refresh_token: issued.refreshToken,
205
- token_type: "Bearer",
225
+ token_type: dpopJkt ? "DPoP" : "Bearer",
206
226
  expires_in: ACCESS_TOKEN_TTL_SECONDS,
207
227
  scope,
208
228
  });
@@ -7,14 +7,20 @@ export function rejectDaemonMessage(ws: WebSocket, error: string, closeCode: num
7
7
  closeWebSocketQuietly(ws, closeCode, closeReason);
8
8
  }
9
9
 
10
- export function sendWebSocketQuietly(ws: WebSocket, value: unknown): void {
10
+ export function trySendWebSocket(ws: WebSocket, value: unknown): boolean {
11
11
  try {
12
12
  ws.send(typeof value === "string" ? value : JSON.stringify(value));
13
+ return true;
13
14
  } catch {
14
- // Best-effort protocol cleanup must not replace the primary timeout or rejection.
15
+ return false;
15
16
  }
16
17
  }
17
18
 
19
+ export function sendWebSocketQuietly(ws: WebSocket, value: unknown): void {
20
+ // Best-effort protocol cleanup must not replace the primary timeout or rejection.
21
+ trySendWebSocket(ws, value);
22
+ }
23
+
18
24
  export function closeWebSocketQuietly(ws: WebSocket, code?: number, reason?: string): void {
19
25
  try {
20
26
  ws.close(code, reason);