machine-bridge-mcp 1.0.8 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +22 -10
- package/SECURITY.md +4 -4
- package/browser-extension/manifest.json +2 -2
- package/docs/ARCHITECTURE.md +17 -9
- package/docs/AUDIT.md +18 -0
- package/docs/CLIENTS.md +15 -7
- package/docs/ENGINEERING.md +2 -1
- package/docs/GETTING_STARTED.md +38 -5
- package/docs/LOGGING.md +7 -1
- package/docs/MULTI_ACCOUNT.md +1 -1
- package/docs/OPERATIONS.md +24 -0
- package/docs/TESTING.md +4 -2
- package/package.json +4 -3
- package/scripts/coverage-check.mjs +6 -2
- package/src/local/cli-options.mjs +1 -1
- package/src/local/cli.mjs +17 -150
- package/src/local/network-proxy.mjs +27 -9
- package/src/local/state-inventory.mjs +8 -4
- package/src/local/state.mjs +20 -1
- package/src/local/worker-deployment.mjs +170 -0
- package/src/local/worker-health.mjs +246 -0
- package/src/worker/index.ts +23 -53
- package/src/worker/oauth-state.ts +37 -2
- package/src/worker/oauth-tokens.ts +208 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import https from "node:https";
|
|
3
|
+
import { appName } from "./state.mjs";
|
|
4
|
+
import { proxyAgentForHttp } from "./network-proxy.mjs";
|
|
5
|
+
|
|
6
|
+
const MAX_HEALTH_BODY_BYTES = 64 * 1024;
|
|
7
|
+
const DEFAULT_HEALTH_TIMEOUT_MS = 5_000;
|
|
8
|
+
const NON_RETRYABLE_HEALTH_ERRORS = new Set(["missing_worker_url", "invalid_worker_url", "proxy_configuration"]);
|
|
9
|
+
const WORKER_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
10
|
+
const WORKERS_DEV_HOST = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.workers\.dev$/;
|
|
11
|
+
|
|
12
|
+
export async function workerHealth(workerUrl, expectedVersion, options = {}) {
|
|
13
|
+
if (!workerUrl) return { ok: false, error: "missing_worker_url" };
|
|
14
|
+
let healthUrl;
|
|
15
|
+
try {
|
|
16
|
+
healthUrl = workerHealthUrl(workerUrl, options.expectedWorkerName);
|
|
17
|
+
} catch {
|
|
18
|
+
return { ok: false, error: "invalid_worker_url" };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const probe = typeof options.probe === "function" ? options.probe : requestAllowedWorkerHealthJson;
|
|
23
|
+
const result = await probe(healthUrl, {
|
|
24
|
+
timeoutMs: options.timeoutMs,
|
|
25
|
+
proxyResolver: options.proxyResolver,
|
|
26
|
+
proxyAgentForUrl: options.proxyAgentForUrl,
|
|
27
|
+
});
|
|
28
|
+
const networkRoute = result.networkRoute || "direct";
|
|
29
|
+
if (result.statusCode !== 200) return { ok: false, error: `HTTP ${result.statusCode}`, networkRoute };
|
|
30
|
+
const body = result.body;
|
|
31
|
+
if (body?.ok !== true || body?.server !== appName) return { ok: false, error: "unexpected_health_response", networkRoute };
|
|
32
|
+
if (body?.version !== expectedVersion) return { ok: false, error: `version_mismatch:${body?.version || "unknown"}!=${expectedVersion}`, networkRoute };
|
|
33
|
+
return { ok: true, version: body.version, networkRoute };
|
|
34
|
+
} catch (error) {
|
|
35
|
+
return {
|
|
36
|
+
ok: false,
|
|
37
|
+
error: workerHealthError(error),
|
|
38
|
+
networkRoute: error?.networkRoute || (hasErrorCode(error, "http_proxy_configuration") ? "invalid-proxy-configuration" : "unknown"),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function retryWorkerHealth(workerUrl, expectedVersion, attempts, options = {}) {
|
|
44
|
+
const count = Math.max(1, Number.parseInt(String(attempts), 10) || 1);
|
|
45
|
+
const wait = typeof options.wait === "function" ? options.wait : sleep;
|
|
46
|
+
let last = { ok: false, error: "not_checked" };
|
|
47
|
+
for (let index = 0; index < count; index += 1) {
|
|
48
|
+
last = await workerHealth(workerUrl, expectedVersion, options);
|
|
49
|
+
if (last.ok) return last;
|
|
50
|
+
if (!isRetryableWorkerHealthError(last.error)) return last;
|
|
51
|
+
if (index + 1 < count) await wait(1_000 + index * 500);
|
|
52
|
+
}
|
|
53
|
+
return last;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function workerHealthUrl(workerUrl, expectedWorkerName = "") {
|
|
57
|
+
const base = new URL(`${String(workerUrl).replace(/\/+$/, "")}/`);
|
|
58
|
+
const hostname = base.hostname.toLowerCase();
|
|
59
|
+
const expectedName = String(expectedWorkerName || "").toLowerCase();
|
|
60
|
+
if (base.protocol !== "https:" || base.port || base.username || base.password || base.search || base.hash || base.pathname !== "/") {
|
|
61
|
+
throw new Error("Worker URL must be an HTTPS workers.dev origin");
|
|
62
|
+
}
|
|
63
|
+
if (!WORKERS_DEV_HOST.test(hostname)) throw new Error("Worker URL must use a workers.dev hostname");
|
|
64
|
+
if (expectedName && (!WORKER_NAME.test(expectedName) || hostname.split(".")[0] !== expectedName)) {
|
|
65
|
+
throw new Error("Worker URL hostname does not match the recorded Worker name");
|
|
66
|
+
}
|
|
67
|
+
return `https://${hostname}/healthz`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function isRetryableWorkerHealthError(value) {
|
|
71
|
+
return !NON_RETRYABLE_HEALTH_ERRORS.has(String(value || ""));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function workerHealthRequiresRedeploy(value) {
|
|
75
|
+
const reason = String(value || "");
|
|
76
|
+
return reason.startsWith("version_mismatch:")
|
|
77
|
+
|| reason === "unexpected_health_response"
|
|
78
|
+
|| reason === "HTTP 404"
|
|
79
|
+
|| reason === "HTTP 410";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function workerHealthUserReason(value) {
|
|
83
|
+
const reason = String(value || "");
|
|
84
|
+
if (reason.startsWith("version_mismatch:")) return "deployed version does not match the local package";
|
|
85
|
+
if (/^HTTP \d+$/.test(reason)) return "health endpoint returned an HTTP error";
|
|
86
|
+
if (reason === "unexpected_health_response") return "health endpoint returned an unexpected response";
|
|
87
|
+
if (reason === "timeout") return "health check timed out";
|
|
88
|
+
if (reason === "tls_error") return "TLS validation failed";
|
|
89
|
+
if (reason === "network_error") return "network request failed";
|
|
90
|
+
if (reason === "proxy_configuration") return "HTTP proxy configuration is invalid";
|
|
91
|
+
if (reason === "missing_worker_url") return "Worker URL is missing";
|
|
92
|
+
if (reason === "invalid_worker_url") return "Worker URL is invalid";
|
|
93
|
+
return "health check failed";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function workerHealthError(error) {
|
|
97
|
+
if (hasErrorCode(error, "http_proxy_configuration")) return "proxy_configuration";
|
|
98
|
+
if (hasErrorCode(error, "ETIMEDOUT") || hasErrorCode(error, "ABORT_ERR") || /timeout|timed out|aborted/i.test(errorMessages(error))) return "timeout";
|
|
99
|
+
if (hasTlsError(error)) return "tls_error";
|
|
100
|
+
if (hasNetworkError(error)) return "network_error";
|
|
101
|
+
return "request_failed";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function requestWorkerHealthJson(url, options = {}) {
|
|
105
|
+
return requestJson(new URL(String(url)), {
|
|
106
|
+
timeoutMs: boundedTimeout(options.timeoutMs),
|
|
107
|
+
proxyResolver: options.proxyResolver,
|
|
108
|
+
proxyAgentForUrl: options.proxyAgentForUrl,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function requestAllowedWorkerHealthJson(url, options = {}) {
|
|
113
|
+
const target = new URL(String(url));
|
|
114
|
+
if (target.protocol !== "https:" || target.port || target.pathname !== "/healthz" || target.search || target.hash || !WORKERS_DEV_HOST.test(target.hostname)) {
|
|
115
|
+
const error = new Error("health request target is not an allowed workers.dev endpoint");
|
|
116
|
+
error.code = "ERR_INVALID_WORKER_HEALTH_URL";
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
return requestJson(target, {
|
|
120
|
+
timeoutMs: boundedTimeout(options.timeoutMs),
|
|
121
|
+
proxyResolver: options.proxyResolver,
|
|
122
|
+
proxyAgentForUrl: options.proxyAgentForUrl,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function requestJson(target, options) {
|
|
127
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
128
|
+
const selectProxy = typeof options.proxyAgentForUrl === "function" ? options.proxyAgentForUrl : proxyAgentForHttp;
|
|
129
|
+
let proxy;
|
|
130
|
+
try {
|
|
131
|
+
proxy = selectProxy(target.href, options.proxyResolver);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
rejectPromise(withNetworkRoute(error, "invalid-proxy-configuration"));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const networkRoute = proxy?.agent ? "proxy" : "direct";
|
|
137
|
+
const transport = target.protocol === "https:" ? https : http;
|
|
138
|
+
const request = transport.request({
|
|
139
|
+
protocol: target.protocol,
|
|
140
|
+
hostname: target.hostname,
|
|
141
|
+
port: target.port || undefined,
|
|
142
|
+
method: "GET",
|
|
143
|
+
path: `${target.pathname}${target.search}`,
|
|
144
|
+
headers: {
|
|
145
|
+
Accept: "application/json",
|
|
146
|
+
"User-Agent": "machine-bridge-mcp-health",
|
|
147
|
+
},
|
|
148
|
+
...(proxy?.agent ? { agent: proxy.agent } : {}),
|
|
149
|
+
});
|
|
150
|
+
let settled = false;
|
|
151
|
+
const finish = (callback) => {
|
|
152
|
+
if (settled) return;
|
|
153
|
+
settled = true;
|
|
154
|
+
clearTimeout(timer);
|
|
155
|
+
callback();
|
|
156
|
+
};
|
|
157
|
+
const rejectWithRoute = (error) => finish(() => rejectPromise(withNetworkRoute(error, networkRoute)));
|
|
158
|
+
const timer = setTimeout(() => {
|
|
159
|
+
const error = new Error(`health request timed out after ${options.timeoutMs}ms`);
|
|
160
|
+
error.code = "ETIMEDOUT";
|
|
161
|
+
request.destroy(error);
|
|
162
|
+
}, options.timeoutMs);
|
|
163
|
+
timer.unref?.();
|
|
164
|
+
|
|
165
|
+
request.on("response", (response) => {
|
|
166
|
+
const statusCode = Number(response.statusCode || 0);
|
|
167
|
+
const chunks = [];
|
|
168
|
+
let bytes = 0;
|
|
169
|
+
response.on("data", (chunk) => {
|
|
170
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
171
|
+
bytes += buffer.length;
|
|
172
|
+
if (bytes > MAX_HEALTH_BODY_BYTES) {
|
|
173
|
+
const error = new Error("health response exceeded the size limit");
|
|
174
|
+
error.code = "ERR_RESPONSE_TOO_LARGE";
|
|
175
|
+
response.destroy(error);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
chunks.push(buffer);
|
|
179
|
+
});
|
|
180
|
+
response.on("error", rejectWithRoute);
|
|
181
|
+
response.on("end", () => {
|
|
182
|
+
if (settled) return;
|
|
183
|
+
let body = null;
|
|
184
|
+
try {
|
|
185
|
+
body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
186
|
+
} catch {
|
|
187
|
+
// Invalid JSON is reported through the normal unexpected-health-response classification.
|
|
188
|
+
}
|
|
189
|
+
finish(() => resolvePromise({ statusCode, body, networkRoute }));
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
request.on("error", rejectWithRoute);
|
|
193
|
+
request.end();
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function withNetworkRoute(error, networkRoute) {
|
|
198
|
+
const value = error instanceof Error ? error : new Error(String(error));
|
|
199
|
+
if (!value.networkRoute) value.networkRoute = networkRoute;
|
|
200
|
+
return value;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function hasTlsError(error) {
|
|
204
|
+
const codes = errorCodes(error);
|
|
205
|
+
if (codes.some((code) => code.startsWith("ERR_TLS_") || code.startsWith("CERT_") || code.includes("CERTIFICATE") || code === "UNABLE_TO_VERIFY_LEAF_SIGNATURE" || code === "DEPTH_ZERO_SELF_SIGNED_CERT")) return true;
|
|
206
|
+
return /certificate|\bTLS\b|\bSSL\b/i.test(errorMessages(error));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function hasNetworkError(error) {
|
|
210
|
+
const networkCodes = new Set([
|
|
211
|
+
"ECONNABORTED", "ECONNREFUSED", "ECONNRESET", "EHOSTUNREACH", "ENETDOWN", "ENETRESET",
|
|
212
|
+
"ENETUNREACH", "ENOTFOUND", "EAI_AGAIN", "EPIPE", "UND_ERR_CONNECT_TIMEOUT",
|
|
213
|
+
]);
|
|
214
|
+
if (errorCodes(error).some((code) => networkCodes.has(code))) return true;
|
|
215
|
+
return /fetch failed|network|socket hang up|connection reset|connection refused/i.test(errorMessages(error));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function hasErrorCode(error, expected) {
|
|
219
|
+
return errorCodes(error).includes(expected);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function errorCodes(error) {
|
|
223
|
+
const codes = [];
|
|
224
|
+
for (let current = error; current && typeof current === "object"; current = current.cause) {
|
|
225
|
+
if (typeof current.code === "string") codes.push(current.code);
|
|
226
|
+
}
|
|
227
|
+
return codes;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function errorMessages(error) {
|
|
231
|
+
const messages = [];
|
|
232
|
+
for (let current = error; current; current = current?.cause) {
|
|
233
|
+
messages.push(String(current?.message || current));
|
|
234
|
+
if (typeof current !== "object") break;
|
|
235
|
+
}
|
|
236
|
+
return messages.join(" ");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function boundedTimeout(value) {
|
|
240
|
+
const numeric = Number(value);
|
|
241
|
+
return Number.isFinite(numeric) && numeric > 0 ? Math.min(60_000, Math.floor(numeric)) : DEFAULT_HEALTH_TIMEOUT_MS;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function sleep(ms) {
|
|
245
|
+
return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
|
|
246
|
+
}
|
package/src/worker/index.ts
CHANGED
|
@@ -9,10 +9,11 @@ import { sanitizeDaemonPolicy, sanitizeDaemonTools, type DaemonPolicy } from "./
|
|
|
9
9
|
import { accountRoleAllowsTool, accountRoleToolNames, type AccountRole } from "./access";
|
|
10
10
|
import { accountAuthoritySnapshot, decorateProjectOverview, describeDaemonCeiling } from "./authority";
|
|
11
11
|
import { accountAdminAuthorized, handleAccountAdminOperation } from "./account-admin";
|
|
12
|
+
import { exchangeOAuthToken } from "./oauth-tokens";
|
|
12
13
|
import { serverInfoTool, workspaceTools } from "./tool-catalog";
|
|
13
14
|
import {
|
|
14
|
-
AUTH_BLOCK_SECONDS, accountByName, authorizationIdentity, emptyOAuthStore,
|
|
15
|
-
|
|
15
|
+
AUTH_BLOCK_SECONDS, OFFLINE_ACCESS_SCOPE, accountByName, authorizationIdentity, emptyOAuthStore,
|
|
16
|
+
isCurrentOAuthStore, pruneAuthFailures, pruneClientRecordByExpiry, pruneRecordByExpiry, randomToken,
|
|
16
17
|
recordAuthorizationFailure, safeEqual, sha256Hex, validateAuthorizationRequest, verifyAccountPassword,
|
|
17
18
|
type OAuthClient, type OAuthStore, type ValidatedAuthorization,
|
|
18
19
|
} from "./oauth-state";
|
|
@@ -23,13 +24,12 @@ import {
|
|
|
23
24
|
} from "./http";
|
|
24
25
|
|
|
25
26
|
const SERVER_NAME = String(serverMetadata.name);
|
|
26
|
-
const SERVER_VERSION = "1.
|
|
27
|
+
const SERVER_VERSION = "1.1.1";
|
|
27
28
|
const MCP_PROTOCOL_VERSION = String(serverMetadata.protocolVersion);
|
|
28
29
|
const MCP_SUPPORTED_PROTOCOL_VERSIONS = serverMetadata.supportedProtocolVersions.map((value) => String(value));
|
|
29
30
|
const JSONRPC_VERSION = "2.0";
|
|
30
31
|
const DEFAULT_MAX_BODY_BYTES = 8 * 1024 * 1024;
|
|
31
32
|
const MAX_BODY_BYTES = 16 * 1024 * 1024;
|
|
32
|
-
const TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30;
|
|
33
33
|
const OAUTH_BODY_LIMIT_BYTES = 64 * 1024;
|
|
34
34
|
const MAX_PENDING_CALLS = 32;
|
|
35
35
|
const MAX_DAEMON_MESSAGE_BYTES = 8 * 1024 * 1024;
|
|
@@ -38,10 +38,8 @@ const OAUTH_UNUSED_CLIENT_TTL_SECONDS = 60 * 60;
|
|
|
38
38
|
const MAX_OAUTH_CLIENTS = 50;
|
|
39
39
|
const MAX_OAUTH_CLIENTS_PER_IDENTITY = 5;
|
|
40
40
|
const OAUTH_CLIENT_IDLE_TTL_SECONDS = 60 * 60 * 24 * 90;
|
|
41
|
-
const MAX_TOKENS_PER_CLIENT = 20;
|
|
42
41
|
const MAX_CODES_PER_CLIENT = 10;
|
|
43
42
|
const MAX_OAUTH_CODES = 200;
|
|
44
|
-
const MAX_OAUTH_TOKENS = 500;
|
|
45
43
|
const MAX_AUTH_FAILURE_IDENTITIES = 200;
|
|
46
44
|
const AUTHORIZATION_FIELDS = new Set(["response_type", "client_id", "redirect_uri", "code_challenge", "code_challenge_method", "scope", "resource", "state"]);
|
|
47
45
|
|
|
@@ -591,10 +589,10 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
591
589
|
token_endpoint: `${base}/oauth/token`,
|
|
592
590
|
registration_endpoint: `${base}/oauth/register`,
|
|
593
591
|
response_types_supported: ["code"],
|
|
594
|
-
grant_types_supported: ["authorization_code"],
|
|
592
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
595
593
|
token_endpoint_auth_methods_supported: ["none"],
|
|
596
594
|
code_challenge_methods_supported: ["S256"],
|
|
597
|
-
scopes_supported: [SERVER_NAME],
|
|
595
|
+
scopes_supported: [SERVER_NAME, OFFLINE_ACCESS_SCOPE],
|
|
598
596
|
};
|
|
599
597
|
}
|
|
600
598
|
|
|
@@ -602,7 +600,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
602
600
|
return {
|
|
603
601
|
resource: `${base}/mcp`,
|
|
604
602
|
authorization_servers: [base],
|
|
605
|
-
scopes_supported: [SERVER_NAME],
|
|
603
|
+
scopes_supported: [SERVER_NAME, OFFLINE_ACCESS_SCOPE],
|
|
606
604
|
bearer_methods_supported: ["header"],
|
|
607
605
|
resource_name: SERVER_NAME,
|
|
608
606
|
};
|
|
@@ -646,7 +644,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
646
644
|
delete client.registration_identity;
|
|
647
645
|
changed = true;
|
|
648
646
|
}
|
|
649
|
-
const ttl = client.
|
|
647
|
+
const ttl = client.has_been_authorized === false ? OAUTH_UNUSED_CLIENT_TTL_SECONDS : OAUTH_CLIENT_IDLE_TTL_SECONDS;
|
|
650
648
|
if (!activeClientIds.has(clientId) && client.last_used_at + ttl <= now) {
|
|
651
649
|
delete store.clients[clientId];
|
|
652
650
|
changed = true;
|
|
@@ -689,9 +687,11 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
689
687
|
return this.withOAuthLock(async () => {
|
|
690
688
|
const store = await this.oauthStore();
|
|
691
689
|
const registrationIdentity = await authorizationIdentity(request, this.identityKey());
|
|
692
|
-
const
|
|
693
|
-
|
|
694
|
-
|
|
690
|
+
const pendingIdentityClientCount = Object.values(store.clients).filter((client) => (
|
|
691
|
+
client.registration_identity === registrationIdentity && client.has_been_authorized === false
|
|
692
|
+
)).length;
|
|
693
|
+
if (pendingIdentityClientCount >= MAX_OAUTH_CLIENTS_PER_IDENTITY) {
|
|
694
|
+
return json({ error: "too_many_requests", error_description: "pending client registration limit reached for this source" }, 429);
|
|
695
695
|
}
|
|
696
696
|
if (Object.keys(store.clients).length >= MAX_OAUTH_CLIENTS) {
|
|
697
697
|
return json({ error: "temporarily_unavailable", error_description: "client registry is full; remove stale state or retry after inactive clients expire" }, 503);
|
|
@@ -703,6 +703,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
703
703
|
redirect_uris: normalized,
|
|
704
704
|
created_at: now,
|
|
705
705
|
last_used_at: now,
|
|
706
|
+
has_been_authorized: false,
|
|
706
707
|
registration_identity: registrationIdentity,
|
|
707
708
|
};
|
|
708
709
|
store.clients[client.client_id] = client;
|
|
@@ -711,7 +712,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
711
712
|
client_id: client.client_id,
|
|
712
713
|
client_name: client.client_name,
|
|
713
714
|
redirect_uris: client.redirect_uris,
|
|
714
|
-
grant_types: ["authorization_code"],
|
|
715
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
715
716
|
response_types: ["code"],
|
|
716
717
|
token_endpoint_auth_method: "none",
|
|
717
718
|
client_id_issued_at: client.created_at,
|
|
@@ -807,6 +808,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
807
808
|
}
|
|
808
809
|
delete store.auth_failures[identity];
|
|
809
810
|
client.last_used_at = now;
|
|
811
|
+
client.has_been_authorized = true;
|
|
810
812
|
|
|
811
813
|
const code = randomToken("mcp_code");
|
|
812
814
|
const redirectLocation = authorizationRedirectLocation(redirectUri, code, state);
|
|
@@ -829,45 +831,13 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
829
831
|
});
|
|
830
832
|
}
|
|
831
833
|
|
|
832
|
-
private
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
return this.withOAuthLock(async () => {
|
|
840
|
-
const store = await this.oauthStore();
|
|
841
|
-
const record = store.codes[code];
|
|
842
|
-
if (!record) return json({ error: "invalid_grant" }, 400);
|
|
843
|
-
if (String(body.client_id ?? "") !== record.client_id || String(body.redirect_uri ?? "") !== record.redirect_uri) {
|
|
844
|
-
return json({ error: "invalid_grant", error_description: "client or redirect mismatch" }, 400);
|
|
845
|
-
}
|
|
846
|
-
if (String(body.resource ?? record.resource) !== record.resource) {
|
|
847
|
-
return json({ error: "invalid_target", error_description: "resource mismatch" }, 400);
|
|
848
|
-
}
|
|
849
|
-
if (!(await safeEqual(await pkceS256(verifier), record.code_challenge))) {
|
|
850
|
-
return json({ error: "invalid_grant", error_description: "invalid code_verifier" }, 400);
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
const tokenVersion = this.env.OAUTH_TOKEN_VERSION ?? "";
|
|
854
|
-
if (!tokenVersion) return json({ error: "server_error", error_description: "OAuth token version is not configured" }, 503);
|
|
855
|
-
delete store.codes[code];
|
|
856
|
-
const accessToken = randomToken("mcp_at");
|
|
857
|
-
store.tokens[`sha256:${await sha256Hex(accessToken)}`] = {
|
|
858
|
-
client_id: record.client_id,
|
|
859
|
-
account_id: record.account_id,
|
|
860
|
-
account_version: record.account_version,
|
|
861
|
-
role: record.role,
|
|
862
|
-
scope: record.scope,
|
|
863
|
-
resource: `${base}/mcp`,
|
|
864
|
-
version: tokenVersion,
|
|
865
|
-
expires_at: Math.floor(Date.now() / 1000) + TOKEN_TTL_SECONDS,
|
|
866
|
-
};
|
|
867
|
-
pruneClientRecordByExpiry(store.tokens, record.client_id, MAX_TOKENS_PER_CLIENT);
|
|
868
|
-
pruneRecordByExpiry(store.tokens, MAX_OAUTH_TOKENS);
|
|
869
|
-
await this.ctx.storage.put("oauth", store);
|
|
870
|
-
return json({ access_token: accessToken, token_type: "Bearer", expires_in: TOKEN_TTL_SECONDS, scope: record.scope });
|
|
834
|
+
private exchangeToken(request: Request, base: string): Promise<Response> {
|
|
835
|
+
return exchangeOAuthToken(request, base, {
|
|
836
|
+
storage: this.ctx.storage,
|
|
837
|
+
tokenVersion: this.env.OAUTH_TOKEN_VERSION ?? "",
|
|
838
|
+
serverName: SERVER_NAME,
|
|
839
|
+
loadOAuthStore: () => this.oauthStore(),
|
|
840
|
+
withLock: (callback) => this.withOAuthLock(callback),
|
|
871
841
|
});
|
|
872
842
|
}
|
|
873
843
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { normalizeAccountRole, type AccountRole } from "./access";
|
|
2
2
|
|
|
3
3
|
const OAUTH_STORE_SCHEMA_VERSION = 1;
|
|
4
|
+
const OAUTH_REFRESH_STORE_SCHEMA_VERSION = 1;
|
|
5
|
+
export const OFFLINE_ACCESS_SCOPE = "offline_access";
|
|
4
6
|
const PASSWORD_TOKEN_PATTERN = /^[a-z][a-z0-9_]{2,31}_[A-Za-z0-9_-]{43}$/;
|
|
5
7
|
|
|
6
8
|
export interface AccountRecord {
|
|
@@ -22,6 +24,7 @@ export interface OAuthClient {
|
|
|
22
24
|
redirect_uris: string[];
|
|
23
25
|
created_at: number;
|
|
24
26
|
last_used_at: number;
|
|
27
|
+
has_been_authorized?: boolean;
|
|
25
28
|
registration_identity?: string;
|
|
26
29
|
}
|
|
27
30
|
|
|
@@ -48,6 +51,13 @@ export interface OAuthToken {
|
|
|
48
51
|
expires_at: number;
|
|
49
52
|
}
|
|
50
53
|
|
|
54
|
+
export type OAuthRefreshToken = OAuthToken;
|
|
55
|
+
|
|
56
|
+
export interface OAuthRefreshStore {
|
|
57
|
+
schema_version: number;
|
|
58
|
+
tokens: Record<string, OAuthRefreshToken>;
|
|
59
|
+
}
|
|
60
|
+
|
|
51
61
|
export interface OAuthFailure {
|
|
52
62
|
count: number;
|
|
53
63
|
window_started: number;
|
|
@@ -89,6 +99,13 @@ export function emptyOAuthStore(): OAuthStore {
|
|
|
89
99
|
};
|
|
90
100
|
}
|
|
91
101
|
|
|
102
|
+
export function emptyOAuthRefreshStore(): OAuthRefreshStore {
|
|
103
|
+
return {
|
|
104
|
+
schema_version: OAUTH_REFRESH_STORE_SCHEMA_VERSION,
|
|
105
|
+
tokens: {},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
92
109
|
export function isCurrentOAuthStore(value: unknown): value is OAuthStore {
|
|
93
110
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
94
111
|
const store = value as Partial<OAuthStore>;
|
|
@@ -100,6 +117,24 @@ export function isCurrentOAuthStore(value: unknown): value is OAuthStore {
|
|
|
100
117
|
&& isRecord(store.auth_failures);
|
|
101
118
|
}
|
|
102
119
|
|
|
120
|
+
export function isCurrentOAuthRefreshStore(value: unknown): value is OAuthRefreshStore {
|
|
121
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
122
|
+
const store = value as Partial<OAuthRefreshStore>;
|
|
123
|
+
return store.schema_version === OAUTH_REFRESH_STORE_SCHEMA_VERSION && isRecord(store.tokens);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function normalizeOAuthScope(value: unknown, serverName: string): string | null {
|
|
127
|
+
const raw = value === undefined ? serverName : String(value).trim();
|
|
128
|
+
if (!raw) return null;
|
|
129
|
+
const requested = raw.split(/\s+/);
|
|
130
|
+
const scopes = new Set(requested);
|
|
131
|
+
if (scopes.size !== requested.length || !scopes.has(serverName)) return null;
|
|
132
|
+
for (const scope of scopes) {
|
|
133
|
+
if (scope !== serverName && scope !== OFFLINE_ACCESS_SCOPE) return null;
|
|
134
|
+
}
|
|
135
|
+
return scopes.has(OFFLINE_ACCESS_SCOPE) ? `${serverName} ${OFFLINE_ACCESS_SCOPE}` : serverName;
|
|
136
|
+
}
|
|
137
|
+
|
|
103
138
|
export function validateAuthorizationRequest(
|
|
104
139
|
body: Record<string, unknown>,
|
|
105
140
|
base: string,
|
|
@@ -112,12 +147,12 @@ export function validateAuthorizationRequest(
|
|
|
112
147
|
const codeChallenge = String(body.code_challenge ?? "");
|
|
113
148
|
const codeChallengeMethod = String(body.code_challenge_method ?? "");
|
|
114
149
|
const requestedResource = String(body.resource ?? `${base}/mcp`);
|
|
115
|
-
const scope =
|
|
150
|
+
const scope = normalizeOAuthScope(body.scope, serverName);
|
|
116
151
|
const state = body.state === undefined ? "" : typeof body.state === "string" ? body.state : "";
|
|
117
152
|
|
|
118
153
|
if (responseType !== "code") return { error: "response_type must be code.", status: 400 };
|
|
119
154
|
if (requestedResource !== `${base}/mcp`) return { error: "resource mismatch.", status: 400 };
|
|
120
|
-
if (scope
|
|
155
|
+
if (!scope) return { error: "unsupported scope.", status: 400 };
|
|
121
156
|
if (body.state !== undefined && typeof body.state !== "string") return { error: "state must be a string.", status: 400 };
|
|
122
157
|
if (state.length > 1024) return { error: "state is too long.", status: 400 };
|
|
123
158
|
if (codeChallengeMethod !== "S256" || !/^[A-Za-z0-9_-]{43}$/.test(codeChallenge)) {
|