machine-bridge-mcp 1.2.10 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -1
- package/CONTRIBUTING.md +1 -1
- package/README.md +11 -6
- package/browser-extension/manifest.json +2 -2
- package/docs/ARCHITECTURE.md +23 -11
- package/docs/AUDIT.md +37 -3
- package/docs/CLIENTS.md +2 -2
- package/docs/ENGINEERING.md +2 -2
- package/docs/GETTING_STARTED.md +1 -1
- package/docs/LOCAL_AUTHORIZATION.md +111 -0
- package/docs/LOGGING.md +7 -5
- package/docs/MULTI_ACCOUNT.md +5 -5
- package/docs/OPERATIONS.md +33 -7
- package/docs/OVERVIEW.md +12 -8
- package/docs/PROJECT_STANDARDS.md +1 -1
- package/docs/RELEASING.md +4 -4
- package/docs/TESTING.md +9 -2
- package/docs/THREAT_MODEL.md +7 -6
- package/docs/TOOL_REFERENCE.md +4 -4
- package/docs/UPGRADING.md +10 -6
- package/package.json +8 -2
- package/scripts/check-plan.mjs +5 -0
- package/scripts/check-runner.mjs +75 -0
- package/scripts/coverage-check.mjs +1 -1
- package/scripts/github-backlog.mjs +116 -0
- package/scripts/github-push.mjs +4 -0
- package/scripts/local-release-acceptance.mjs +2 -2
- package/scripts/release-acceptance.mjs +36 -17
- package/scripts/run-checks.mjs +11 -21
- package/src/local/account-admin.mjs +28 -3
- package/src/local/bounded-output.mjs +20 -3
- package/src/local/cli-approval.mjs +117 -0
- package/src/local/cli-options.mjs +8 -2
- package/src/local/cli.mjs +13 -2
- package/src/local/device-identity.mjs +108 -0
- package/src/local/errors.mjs +5 -1
- package/src/local/execution-limits.mjs +6 -1
- package/src/local/operation-authorization.mjs +366 -0
- package/src/local/operation-risk.mjs +221 -0
- package/src/local/operation-state-lock.mjs +92 -0
- package/src/local/process-execution.mjs +94 -14
- package/src/local/process-output-stream.mjs +82 -0
- package/src/local/process-result-projection.mjs +25 -0
- package/src/local/process-sessions.mjs +37 -51
- package/src/local/relay-connection.mjs +12 -5
- package/src/local/runtime-relay.mjs +13 -5
- package/src/local/runtime.mjs +14 -7
- package/src/local/secure-file.mjs +24 -4
- package/src/local/state.mjs +9 -3
- package/src/local/tool-executor.mjs +4 -2
- package/src/local/tools.mjs +4 -9
- package/src/local/worker-deployment.mjs +4 -3
- package/src/local/worker-secret-file.mjs +2 -1
- package/src/shared/admin-auth.d.mts +10 -0
- package/src/shared/admin-auth.mjs +36 -0
- package/src/shared/daemon-auth.d.mts +20 -0
- package/src/shared/daemon-auth.mjs +55 -0
- package/src/shared/result-projection.d.mts +6 -0
- package/src/shared/result-projection.json +4 -0
- package/src/shared/result-projection.mjs +50 -0
- package/src/shared/server-metadata.json +1 -0
- package/src/shared/tool-catalog.json +4 -4
- package/src/worker/account-admin.ts +73 -4
- package/src/worker/daemon-auth.ts +205 -0
- package/src/worker/daemon-sockets.ts +15 -2
- package/src/worker/errors.ts +18 -4
- package/src/worker/index.ts +59 -10
- package/src/worker/mcp-jsonrpc.ts +4 -2
- package/src/worker/nonce-store.ts +79 -0
- package/src/worker/oauth-controller.ts +11 -6
- package/src/worker/oauth-refresh-families.ts +172 -0
- package/src/worker/oauth-state.ts +48 -3
- package/src/worker/oauth-tokens.ts +49 -40
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
interface NonceStorage {
|
|
2
|
+
get<T = unknown>(key: string): Promise<T | undefined>;
|
|
3
|
+
put(key: string, value: unknown): Promise<void>;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
interface TransactionalNonceStorage extends NonceStorage {
|
|
7
|
+
transaction<T>(callback: (transaction: NonceStorage) => Promise<T>): Promise<T>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function consumeBoundedNonce(
|
|
11
|
+
storage: DurableObjectStorage | NonceStorage,
|
|
12
|
+
options: {
|
|
13
|
+
key: string;
|
|
14
|
+
nonce: string;
|
|
15
|
+
expiresAt: number;
|
|
16
|
+
now: number;
|
|
17
|
+
noncePattern: RegExp;
|
|
18
|
+
maximum: number;
|
|
19
|
+
},
|
|
20
|
+
): Promise<boolean> {
|
|
21
|
+
validateOptions(options);
|
|
22
|
+
const consume = (target: NonceStorage) => consumeInStorage(target, options);
|
|
23
|
+
if (hasTransactions(storage)) return storage.transaction(consume);
|
|
24
|
+
return consume(storage);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function consumeInStorage(
|
|
28
|
+
storage: NonceStorage,
|
|
29
|
+
options: {
|
|
30
|
+
key: string;
|
|
31
|
+
nonce: string;
|
|
32
|
+
expiresAt: number;
|
|
33
|
+
now: number;
|
|
34
|
+
noncePattern: RegExp;
|
|
35
|
+
maximum: number;
|
|
36
|
+
},
|
|
37
|
+
): Promise<boolean> {
|
|
38
|
+
const raw = await storage.get<unknown>(options.key);
|
|
39
|
+
if (raw !== undefined && !validNonceRecord(raw, options.noncePattern)) return false;
|
|
40
|
+
const nonces: Record<string, number> = raw ? { ...raw as Record<string, number> } : {};
|
|
41
|
+
for (const [nonce, expiresAt] of Object.entries(nonces)) {
|
|
42
|
+
if (expiresAt <= options.now) delete nonces[nonce];
|
|
43
|
+
}
|
|
44
|
+
if (nonces[options.nonce]) return false;
|
|
45
|
+
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
|
+
await storage.put(options.key, nonces);
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function validateOptions(options: {
|
|
56
|
+
key: string;
|
|
57
|
+
nonce: string;
|
|
58
|
+
expiresAt: number;
|
|
59
|
+
now: number;
|
|
60
|
+
noncePattern: RegExp;
|
|
61
|
+
maximum: number;
|
|
62
|
+
}): void {
|
|
63
|
+
if (!/^[a-z][a-z0-9-]{1,127}$/.test(options.key)) throw new Error("nonce-store key is invalid");
|
|
64
|
+
if (!options.noncePattern.test(options.nonce)) throw new Error("nonce-store nonce is invalid");
|
|
65
|
+
if (!Number.isSafeInteger(options.now) || options.now <= 0) throw new Error("nonce-store current time is invalid");
|
|
66
|
+
if (!Number.isSafeInteger(options.expiresAt) || options.expiresAt <= options.now) throw new Error("nonce-store expiration is invalid");
|
|
67
|
+
if (!Number.isSafeInteger(options.maximum) || options.maximum < 1 || options.maximum > 4096) throw new Error("nonce-store maximum is invalid");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function validNonceRecord(value: unknown, noncePattern: RegExp): value is Record<string, number> {
|
|
71
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
72
|
+
return Object.entries(value).every(([nonce, expiresAt]) => (
|
|
73
|
+
noncePattern.test(nonce) && Number.isSafeInteger(expiresAt) && expiresAt > 0
|
|
74
|
+
));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function hasTransactions(storage: DurableObjectStorage | NonceStorage): storage is TransactionalNonceStorage {
|
|
78
|
+
return typeof (storage as Partial<TransactionalNonceStorage>).transaction === "function";
|
|
79
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DEFAULT_ACCOUNT_ROLE, normalizeAccountRole, type AccountRole } from "./access.ts";
|
|
2
|
-
import { accountAdminAuthorized, handleAccountAdminOperation } from "./account-admin.ts";
|
|
2
|
+
import { accountAdminAuthorized, consumeAccountAdminNonce, handleAccountAdminOperation } from "./account-admin.ts";
|
|
3
3
|
import { exchangeOAuthToken } from "./oauth-tokens.ts";
|
|
4
4
|
import {
|
|
5
5
|
AUTH_BLOCK_SECONDS, accountByName, authorizationIdentity, emptyOAuthStore,
|
|
@@ -24,7 +24,7 @@ const MAX_AUTH_FAILURE_IDENTITIES = 200;
|
|
|
24
24
|
|
|
25
25
|
export interface OAuthControllerEnv {
|
|
26
26
|
ACCOUNT_ADMIN_SECRET: string;
|
|
27
|
-
|
|
27
|
+
DAEMON_DEVICE_PUBLIC_KEY: string;
|
|
28
28
|
OAUTH_TOKEN_VERSION: string;
|
|
29
29
|
}
|
|
30
30
|
|
|
@@ -32,6 +32,7 @@ export interface AuthorizedToken {
|
|
|
32
32
|
tokenKey: string;
|
|
33
33
|
accountId: string;
|
|
34
34
|
accountVersion: number;
|
|
35
|
+
clientId: string;
|
|
35
36
|
role: AccountRole;
|
|
36
37
|
}
|
|
37
38
|
|
|
@@ -105,11 +106,15 @@ export class OAuthController {
|
|
|
105
106
|
}
|
|
106
107
|
|
|
107
108
|
async handleAccountAdmin(request: Request, operation: "accounts" | "rotate-password"): Promise<Response> {
|
|
108
|
-
if (!(await accountAdminAuthorized(request, this.env.ACCOUNT_ADMIN_SECRET ?? ""))) return json({ error: "unauthorized" }, 401);
|
|
109
109
|
return this.withOAuthLock(async () => {
|
|
110
|
+
const now = Math.floor(Date.now() / 1000);
|
|
111
|
+
const authorization = await accountAdminAuthorized(request, this.env.ACCOUNT_ADMIN_SECRET ?? "", now);
|
|
112
|
+
if (!authorization || !(await consumeAccountAdminNonce(this.ctx.storage, authorization, now))) {
|
|
113
|
+
return json({ error: "unauthorized" }, 401);
|
|
114
|
+
}
|
|
110
115
|
const store = await this.oauthStore();
|
|
111
116
|
return handleAccountAdminOperation({
|
|
112
|
-
request, operation, store, now
|
|
117
|
+
request, operation, store, now,
|
|
113
118
|
save: () => this.ctx.storage.put("oauth", store),
|
|
114
119
|
});
|
|
115
120
|
});
|
|
@@ -265,7 +270,7 @@ export class OAuthController {
|
|
|
265
270
|
}
|
|
266
271
|
return {
|
|
267
272
|
tokenKey: key, accountId: account.account_id,
|
|
268
|
-
accountVersion: account.version, role: account.role,
|
|
273
|
+
accountVersion: account.version, clientId: record.client_id, role: account.role,
|
|
269
274
|
};
|
|
270
275
|
});
|
|
271
276
|
}
|
|
@@ -283,7 +288,7 @@ export class OAuthController {
|
|
|
283
288
|
}
|
|
284
289
|
|
|
285
290
|
identityKey(): string {
|
|
286
|
-
const key = this.env.OAUTH_TOKEN_VERSION || this.env.
|
|
291
|
+
const key = this.env.OAUTH_TOKEN_VERSION || this.env.ACCOUNT_ADMIN_SECRET;
|
|
287
292
|
if (!key) throw new HttpError(503, "server_not_configured", "OAuth identity key is not configured");
|
|
288
293
|
return key;
|
|
289
294
|
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import {
|
|
2
|
+
emptyOAuthRefreshStore,
|
|
3
|
+
isCurrentOAuthRefreshStore,
|
|
4
|
+
upgradeOAuthRefreshStore,
|
|
5
|
+
type OAuthRefreshStore,
|
|
6
|
+
type OAuthStore,
|
|
7
|
+
} from "./oauth-state.ts";
|
|
8
|
+
import { HttpError } from "./http.ts";
|
|
9
|
+
|
|
10
|
+
export const OAUTH_REFRESH_STORE_KEY = "oauth-refresh";
|
|
11
|
+
const MAX_CONSUMED_REFRESH_TOKENS = 4096;
|
|
12
|
+
const MAX_REVOKED_REFRESH_FAMILIES = 1024;
|
|
13
|
+
const TOKEN_HASH_PATTERN = /^sha256:[a-f0-9]{64}$/;
|
|
14
|
+
const FAMILY_ID_PATTERN = /^mcp_family_[A-Za-z0-9_-]{43}$/;
|
|
15
|
+
|
|
16
|
+
export async function loadOAuthRefreshStore(
|
|
17
|
+
oauthStore: OAuthStore,
|
|
18
|
+
storage: DurableObjectStorage,
|
|
19
|
+
): Promise<OAuthRefreshStore> {
|
|
20
|
+
const raw = await storage.get<unknown>(OAUTH_REFRESH_STORE_KEY);
|
|
21
|
+
const store = raw === undefined ? emptyOAuthRefreshStore() : upgradeOAuthRefreshStore(raw);
|
|
22
|
+
if (!store || !validRefreshStoreRecords(store)) {
|
|
23
|
+
throw new HttpError(503, "oauth_refresh_state_schema_mismatch", "OAuth refresh-token state requires operator repair");
|
|
24
|
+
}
|
|
25
|
+
const migrated = raw !== undefined && !isCurrentOAuthRefreshStore(raw);
|
|
26
|
+
let changed = false;
|
|
27
|
+
const now = Math.floor(Date.now() / 1000);
|
|
28
|
+
for (const [token, value] of Object.entries(store.tokens)) {
|
|
29
|
+
const account = oauthStore.accounts[value.account_id];
|
|
30
|
+
const client = oauthStore.clients[value.client_id];
|
|
31
|
+
if (
|
|
32
|
+
value.expires_at <= now
|
|
33
|
+
|| value.family_expires_at <= now
|
|
34
|
+
|| Boolean(store.revoked_families[value.family_id])
|
|
35
|
+
|| !account
|
|
36
|
+
|| !account.active
|
|
37
|
+
|| account.version !== value.account_version
|
|
38
|
+
|| account.role !== value.role
|
|
39
|
+
|| !client
|
|
40
|
+
) {
|
|
41
|
+
delete store.tokens[token];
|
|
42
|
+
changed = true;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
for (const [token, value] of Object.entries(store.consumed)) {
|
|
46
|
+
if (value.expires_at <= now) {
|
|
47
|
+
delete store.consumed[token];
|
|
48
|
+
changed = true;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
for (const [familyId, value] of Object.entries(store.revoked_families)) {
|
|
52
|
+
if (value.expires_at <= now) {
|
|
53
|
+
delete store.revoked_families[familyId];
|
|
54
|
+
changed = true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (pruneOAuthRefreshReplayState(store)) changed = true;
|
|
58
|
+
if (changed || migrated) await storage.put(OAUTH_REFRESH_STORE_KEY, store);
|
|
59
|
+
return store;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
export function recordConsumedRefreshToken(
|
|
64
|
+
store: OAuthRefreshStore,
|
|
65
|
+
tokenHash: string,
|
|
66
|
+
familyId: string,
|
|
67
|
+
expiresAt: number,
|
|
68
|
+
consumedAt = Math.floor(Date.now() / 1000),
|
|
69
|
+
): void {
|
|
70
|
+
if (!TOKEN_HASH_PATTERN.test(tokenHash) || !FAMILY_ID_PATTERN.test(familyId)) {
|
|
71
|
+
throw new Error("consumed refresh-token identity is invalid");
|
|
72
|
+
}
|
|
73
|
+
if (!Number.isSafeInteger(consumedAt) || !Number.isSafeInteger(expiresAt) || consumedAt <= 0 || expiresAt <= consumedAt) {
|
|
74
|
+
throw new Error("consumed refresh-token lifetime is invalid");
|
|
75
|
+
}
|
|
76
|
+
store.consumed[tokenHash] = { family_id: familyId, consumed_at: consumedAt, expires_at: expiresAt };
|
|
77
|
+
pruneOAuthRefreshReplayState(store);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function pruneOAuthRefreshReplayState(store: OAuthRefreshStore): boolean {
|
|
81
|
+
let changed = false;
|
|
82
|
+
const consumed = Object.entries(store.consumed).sort((left, right) => (
|
|
83
|
+
left[1].consumed_at - right[1].consumed_at || left[0].localeCompare(right[0])
|
|
84
|
+
));
|
|
85
|
+
while (consumed.length > MAX_CONSUMED_REFRESH_TOKENS) {
|
|
86
|
+
const [tokenHash] = consumed.shift()!;
|
|
87
|
+
delete store.consumed[tokenHash];
|
|
88
|
+
changed = true;
|
|
89
|
+
}
|
|
90
|
+
const revoked = Object.entries(store.revoked_families).sort((left, right) => (
|
|
91
|
+
left[1].expires_at - right[1].expires_at || left[0].localeCompare(right[0])
|
|
92
|
+
));
|
|
93
|
+
while (revoked.length > MAX_REVOKED_REFRESH_FAMILIES) {
|
|
94
|
+
const [familyId] = revoked.shift()!;
|
|
95
|
+
delete store.revoked_families[familyId];
|
|
96
|
+
changed = true;
|
|
97
|
+
}
|
|
98
|
+
return changed;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function validRefreshStoreRecords(store: OAuthRefreshStore): boolean {
|
|
102
|
+
return Object.entries(store.tokens).every(([key, token]) => (
|
|
103
|
+
TOKEN_HASH_PATTERN.test(key)
|
|
104
|
+
&& plainRecord(token)
|
|
105
|
+
&& /^mcp_client_[A-Za-z0-9_-]{43}$/.test(token.client_id)
|
|
106
|
+
&& /^acct_[A-Za-z0-9_-]{20,96}$/.test(token.account_id)
|
|
107
|
+
&& Number.isSafeInteger(token.account_version)
|
|
108
|
+
&& token.account_version > 0
|
|
109
|
+
&& ["reviewer", "editor", "operator", "owner"].includes(token.role)
|
|
110
|
+
&& typeof token.scope === "string"
|
|
111
|
+
&& token.scope.length > 0
|
|
112
|
+
&& token.scope.length <= 256
|
|
113
|
+
&& validHttpsResource(token.resource)
|
|
114
|
+
&& typeof token.version === "string"
|
|
115
|
+
&& token.version.length > 0
|
|
116
|
+
&& token.version.length <= 256
|
|
117
|
+
&& FAMILY_ID_PATTERN.test(token.family_id)
|
|
118
|
+
&& validTimestamp(token.issued_at)
|
|
119
|
+
&& validTimestamp(token.expires_at)
|
|
120
|
+
&& validTimestamp(token.family_expires_at)
|
|
121
|
+
&& token.issued_at < token.expires_at
|
|
122
|
+
&& token.expires_at <= token.family_expires_at
|
|
123
|
+
)) && Object.entries(store.consumed).every(([key, token]) => (
|
|
124
|
+
TOKEN_HASH_PATTERN.test(key)
|
|
125
|
+
&& plainRecord(token)
|
|
126
|
+
&& FAMILY_ID_PATTERN.test(token.family_id)
|
|
127
|
+
&& validTimestamp(token.consumed_at)
|
|
128
|
+
&& validTimestamp(token.expires_at)
|
|
129
|
+
&& token.consumed_at < token.expires_at
|
|
130
|
+
)) && Object.entries(store.revoked_families).every(([familyId, value]) => (
|
|
131
|
+
FAMILY_ID_PATTERN.test(familyId)
|
|
132
|
+
&& plainRecord(value)
|
|
133
|
+
&& value.reason === "replay"
|
|
134
|
+
&& validTimestamp(value.expires_at)
|
|
135
|
+
));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function validTimestamp(value: unknown): value is number {
|
|
139
|
+
return Number.isSafeInteger(value) && Number(value) > 0;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function validHttpsResource(value: unknown): boolean {
|
|
143
|
+
if (typeof value !== "string" || value.length > 2048) return false;
|
|
144
|
+
try {
|
|
145
|
+
const url = new URL(value);
|
|
146
|
+
const secure = url.protocol === "https:";
|
|
147
|
+
const loopback = url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname);
|
|
148
|
+
return (secure || loopback) && !url.username && !url.password && !url.hash;
|
|
149
|
+
} catch {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function plainRecord(value: unknown): value is Record<string, unknown> {
|
|
155
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function revokeOAuthRefreshFamily(
|
|
159
|
+
oauthStore: OAuthStore,
|
|
160
|
+
refreshStore: OAuthRefreshStore,
|
|
161
|
+
familyId: string,
|
|
162
|
+
expiresAt: number,
|
|
163
|
+
): void {
|
|
164
|
+
for (const [key, token] of Object.entries(refreshStore.tokens)) {
|
|
165
|
+
if (token.family_id === familyId) delete refreshStore.tokens[key];
|
|
166
|
+
}
|
|
167
|
+
for (const [key, token] of Object.entries(oauthStore.tokens)) {
|
|
168
|
+
if (token.family_id === familyId) delete oauthStore.tokens[key];
|
|
169
|
+
}
|
|
170
|
+
refreshStore.revoked_families[familyId] = { expires_at: expiresAt, reason: "replay" };
|
|
171
|
+
pruneOAuthRefreshReplayState(refreshStore);
|
|
172
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { normalizeAccountRole, type AccountRole } from "./access.ts";
|
|
2
2
|
|
|
3
3
|
const OAUTH_STORE_SCHEMA_VERSION = 1;
|
|
4
|
-
const OAUTH_REFRESH_STORE_SCHEMA_VERSION =
|
|
4
|
+
const OAUTH_REFRESH_STORE_SCHEMA_VERSION = 2;
|
|
5
5
|
export const OFFLINE_ACCESS_SCOPE = "offline_access";
|
|
6
6
|
const PASSWORD_TOKEN_PATTERN = /^[a-z][a-z0-9_]{2,31}_[A-Za-z0-9_-]{43}$/;
|
|
7
7
|
const ACCOUNT_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$/;
|
|
@@ -51,13 +51,31 @@ export interface OAuthToken {
|
|
|
51
51
|
resource: string;
|
|
52
52
|
version: string;
|
|
53
53
|
expires_at: number;
|
|
54
|
+
family_id?: string;
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
export
|
|
57
|
+
export interface OAuthRefreshToken extends OAuthToken {
|
|
58
|
+
family_id: string;
|
|
59
|
+
family_expires_at: number;
|
|
60
|
+
issued_at: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ConsumedOAuthRefreshToken {
|
|
64
|
+
family_id: string;
|
|
65
|
+
consumed_at: number;
|
|
66
|
+
expires_at: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface RevokedOAuthRefreshFamily {
|
|
70
|
+
expires_at: number;
|
|
71
|
+
reason: "replay";
|
|
72
|
+
}
|
|
57
73
|
|
|
58
74
|
export interface OAuthRefreshStore {
|
|
59
75
|
schema_version: number;
|
|
60
76
|
tokens: Record<string, OAuthRefreshToken>;
|
|
77
|
+
consumed: Record<string, ConsumedOAuthRefreshToken>;
|
|
78
|
+
revoked_families: Record<string, RevokedOAuthRefreshFamily>;
|
|
61
79
|
}
|
|
62
80
|
|
|
63
81
|
export interface OAuthFailure {
|
|
@@ -105,6 +123,8 @@ export function emptyOAuthRefreshStore(): OAuthRefreshStore {
|
|
|
105
123
|
return {
|
|
106
124
|
schema_version: OAUTH_REFRESH_STORE_SCHEMA_VERSION,
|
|
107
125
|
tokens: {},
|
|
126
|
+
consumed: {},
|
|
127
|
+
revoked_families: {},
|
|
108
128
|
};
|
|
109
129
|
}
|
|
110
130
|
|
|
@@ -122,7 +142,32 @@ export function isCurrentOAuthStore(value: unknown): value is OAuthStore {
|
|
|
122
142
|
export function isCurrentOAuthRefreshStore(value: unknown): value is OAuthRefreshStore {
|
|
123
143
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
124
144
|
const store = value as Partial<OAuthRefreshStore>;
|
|
125
|
-
return store.schema_version === OAUTH_REFRESH_STORE_SCHEMA_VERSION
|
|
145
|
+
return store.schema_version === OAUTH_REFRESH_STORE_SCHEMA_VERSION
|
|
146
|
+
&& isRecord(store.tokens)
|
|
147
|
+
&& isRecord(store.consumed)
|
|
148
|
+
&& isRecord(store.revoked_families);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function upgradeOAuthRefreshStore(value: unknown): OAuthRefreshStore | null {
|
|
152
|
+
if (isCurrentOAuthRefreshStore(value)) return value;
|
|
153
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
154
|
+
const legacy = value as { schema_version?: unknown; tokens?: unknown };
|
|
155
|
+
if (legacy.schema_version !== 1 || !isRecord(legacy.tokens)) return null;
|
|
156
|
+
const upgraded = emptyOAuthRefreshStore();
|
|
157
|
+
const now = Math.floor(Date.now() / 1000);
|
|
158
|
+
for (const [key, raw] of Object.entries(legacy.tokens)) {
|
|
159
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
|
|
160
|
+
const token = raw as OAuthToken;
|
|
161
|
+
if (!Number.isSafeInteger(token.expires_at) || token.expires_at <= now) continue;
|
|
162
|
+
const familyId = randomToken("mcp_family");
|
|
163
|
+
upgraded.tokens[key] = {
|
|
164
|
+
...token,
|
|
165
|
+
family_id: familyId,
|
|
166
|
+
family_expires_at: token.expires_at,
|
|
167
|
+
issued_at: now,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
return upgraded;
|
|
126
171
|
}
|
|
127
172
|
|
|
128
173
|
export function normalizeOAuthScope(value: unknown, serverName: string): string | null {
|
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
normalizeOAuthScope, pkceS256, pruneClientRecordByExpiry, pruneRecordByExpiry,
|
|
3
|
+
randomToken, safeEqual, sha256Hex,
|
|
4
4
|
type OAuthCode, type OAuthRefreshStore, type OAuthRefreshToken, type OAuthStore,
|
|
5
5
|
} from "./oauth-state.ts";
|
|
6
6
|
import { HttpError, json, parseRequestBody } from "./http.ts";
|
|
7
|
+
import { loadOAuthRefreshStore, OAUTH_REFRESH_STORE_KEY, recordConsumedRefreshToken, revokeOAuthRefreshFamily } from "./oauth-refresh-families.ts";
|
|
7
8
|
|
|
8
|
-
const ACCESS_TOKEN_TTL_SECONDS =
|
|
9
|
-
const
|
|
9
|
+
const ACCESS_TOKEN_TTL_SECONDS = 15 * 60;
|
|
10
|
+
const REFRESH_TOKEN_IDLE_TTL_SECONDS = 60 * 60 * 24 * 14;
|
|
11
|
+
const REFRESH_TOKEN_FAMILY_TTL_SECONDS = 60 * 60 * 24 * 30;
|
|
10
12
|
const OAUTH_BODY_LIMIT_BYTES = 64 * 1024;
|
|
11
13
|
const MAX_ACCESS_TOKENS = 500;
|
|
12
14
|
const MAX_ACCESS_TOKENS_PER_CLIENT = 20;
|
|
13
15
|
const MAX_REFRESH_TOKENS = 500;
|
|
14
16
|
const MAX_REFRESH_TOKENS_PER_CLIENT = 20;
|
|
15
|
-
const OAUTH_REFRESH_STORE_KEY = "oauth-refresh";
|
|
16
17
|
|
|
17
18
|
type OAuthLock = <T>(callback: () => Promise<T>) => Promise<T>;
|
|
18
19
|
|
|
@@ -53,7 +54,7 @@ async function exchangeAuthorizationCode(
|
|
|
53
54
|
}
|
|
54
55
|
return options.withLock(async () => {
|
|
55
56
|
const oauthStore = await options.loadOAuthStore();
|
|
56
|
-
const refreshStore = await
|
|
57
|
+
const refreshStore = await loadOAuthRefreshStore(oauthStore, options.storage);
|
|
57
58
|
const record = oauthStore.codes[code];
|
|
58
59
|
if (!record) return json({ error: "invalid_grant" }, 400);
|
|
59
60
|
if (String(body.client_id ?? "") !== record.client_id || String(body.redirect_uri ?? "") !== record.redirect_uri) {
|
|
@@ -85,10 +86,22 @@ async function exchangeRefreshToken(
|
|
|
85
86
|
if (!/^mcp_rt_[A-Za-z0-9_-]{43}$/.test(refreshToken)) return json({ error: "invalid_grant" }, 400);
|
|
86
87
|
return options.withLock(async () => {
|
|
87
88
|
const oauthStore = await options.loadOAuthStore();
|
|
88
|
-
const refreshStore = await
|
|
89
|
+
const refreshStore = await loadOAuthRefreshStore(oauthStore, options.storage);
|
|
89
90
|
const refreshKey = `sha256:${await sha256Hex(refreshToken)}`;
|
|
90
91
|
const record = refreshStore.tokens[refreshKey];
|
|
91
|
-
if (!record)
|
|
92
|
+
if (!record) {
|
|
93
|
+
const consumed = refreshStore.consumed[refreshKey];
|
|
94
|
+
if (consumed && consumed.expires_at > Math.floor(Date.now() / 1000)) {
|
|
95
|
+
revokeOAuthRefreshFamily(oauthStore, refreshStore, consumed.family_id, consumed.expires_at);
|
|
96
|
+
await saveOAuthStores(oauthStore, refreshStore, options.storage);
|
|
97
|
+
}
|
|
98
|
+
return json({ error: "invalid_grant" }, 400);
|
|
99
|
+
}
|
|
100
|
+
if (refreshStore.revoked_families[record.family_id]) {
|
|
101
|
+
delete refreshStore.tokens[refreshKey];
|
|
102
|
+
await options.storage.put(OAUTH_REFRESH_STORE_KEY, refreshStore);
|
|
103
|
+
return json({ error: "invalid_grant" }, 400);
|
|
104
|
+
}
|
|
92
105
|
if (String(body.client_id ?? "") !== record.client_id) {
|
|
93
106
|
return json({ error: "invalid_grant", error_description: "client mismatch" }, 400);
|
|
94
107
|
}
|
|
@@ -117,44 +130,31 @@ async function exchangeRefreshToken(
|
|
|
117
130
|
return json({ error: "invalid_grant" }, 400);
|
|
118
131
|
}
|
|
119
132
|
|
|
120
|
-
|
|
133
|
+
let issued: IssuedTokenPair;
|
|
134
|
+
try {
|
|
135
|
+
issued = await issueTokenPair(oauthStore, refreshStore, record, options.tokenVersion);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if (error instanceof HttpError && error.code === "invalid_grant") {
|
|
138
|
+
delete refreshStore.tokens[refreshKey];
|
|
139
|
+
await options.storage.put(OAUTH_REFRESH_STORE_KEY, refreshStore);
|
|
140
|
+
return json({ error: "invalid_grant" }, 400);
|
|
141
|
+
}
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
121
144
|
delete refreshStore.tokens[refreshKey];
|
|
145
|
+
recordConsumedRefreshToken(
|
|
146
|
+
refreshStore,
|
|
147
|
+
refreshKey,
|
|
148
|
+
record.family_id,
|
|
149
|
+
record.family_expires_at,
|
|
150
|
+
Math.floor(Date.now() / 1000),
|
|
151
|
+
);
|
|
122
152
|
client.last_used_at = Math.floor(Date.now() / 1000);
|
|
123
153
|
await saveOAuthStores(oauthStore, refreshStore, options.storage);
|
|
124
154
|
return tokenResponse(issued, record.scope);
|
|
125
155
|
});
|
|
126
156
|
}
|
|
127
157
|
|
|
128
|
-
async function loadRefreshStore(
|
|
129
|
-
oauthStore: OAuthStore,
|
|
130
|
-
options: OAuthTokenExchangeOptions,
|
|
131
|
-
): Promise<OAuthRefreshStore> {
|
|
132
|
-
const raw = await options.storage.get<unknown>(OAUTH_REFRESH_STORE_KEY);
|
|
133
|
-
if (raw !== undefined && !isCurrentOAuthRefreshStore(raw)) {
|
|
134
|
-
throw new HttpError(503, "oauth_refresh_state_schema_mismatch", "OAuth refresh-token state requires operator repair");
|
|
135
|
-
}
|
|
136
|
-
const store = isCurrentOAuthRefreshStore(raw) ? raw : emptyOAuthRefreshStore();
|
|
137
|
-
let changed = false;
|
|
138
|
-
const now = Math.floor(Date.now() / 1000);
|
|
139
|
-
for (const [token, value] of Object.entries(store.tokens)) {
|
|
140
|
-
const account = oauthStore.accounts[value.account_id];
|
|
141
|
-
const client = oauthStore.clients[value.client_id];
|
|
142
|
-
if (
|
|
143
|
-
value.expires_at <= now
|
|
144
|
-
|| !account
|
|
145
|
-
|| !account.active
|
|
146
|
-
|| account.version !== value.account_version
|
|
147
|
-
|| account.role !== value.role
|
|
148
|
-
|| !client
|
|
149
|
-
) {
|
|
150
|
-
delete store.tokens[token];
|
|
151
|
-
changed = true;
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
if (changed) await options.storage.put(OAUTH_REFRESH_STORE_KEY, store);
|
|
155
|
-
return store;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
158
|
async function issueTokenPair(
|
|
159
159
|
oauthStore: OAuthStore,
|
|
160
160
|
refreshStore: OAuthRefreshStore,
|
|
@@ -163,6 +163,11 @@ async function issueTokenPair(
|
|
|
163
163
|
): Promise<IssuedTokenPair> {
|
|
164
164
|
if (!tokenVersion) throw new HttpError(503, "server_error", "OAuth token version is not configured");
|
|
165
165
|
const now = Math.floor(Date.now() / 1000);
|
|
166
|
+
const familyId = "family_id" in source && source.family_id ? source.family_id : randomToken("mcp_family");
|
|
167
|
+
const familyExpiresAt = "family_expires_at" in source && source.family_expires_at
|
|
168
|
+
? source.family_expires_at
|
|
169
|
+
: now + REFRESH_TOKEN_FAMILY_TTL_SECONDS;
|
|
170
|
+
if (familyExpiresAt <= now) throw new HttpError(400, "invalid_grant", "refresh-token family expired");
|
|
166
171
|
const accessToken = randomToken("mcp_at");
|
|
167
172
|
const refreshToken = randomToken("mcp_rt");
|
|
168
173
|
const common = {
|
|
@@ -173,6 +178,7 @@ async function issueTokenPair(
|
|
|
173
178
|
scope: source.scope,
|
|
174
179
|
resource: source.resource,
|
|
175
180
|
version: tokenVersion,
|
|
181
|
+
family_id: familyId,
|
|
176
182
|
};
|
|
177
183
|
oauthStore.tokens[`sha256:${await sha256Hex(accessToken)}`] = {
|
|
178
184
|
...common,
|
|
@@ -180,7 +186,10 @@ async function issueTokenPair(
|
|
|
180
186
|
};
|
|
181
187
|
refreshStore.tokens[`sha256:${await sha256Hex(refreshToken)}`] = {
|
|
182
188
|
...common,
|
|
183
|
-
|
|
189
|
+
family_id: familyId,
|
|
190
|
+
family_expires_at: familyExpiresAt,
|
|
191
|
+
issued_at: now,
|
|
192
|
+
expires_at: Math.min(now + REFRESH_TOKEN_IDLE_TTL_SECONDS, familyExpiresAt),
|
|
184
193
|
};
|
|
185
194
|
pruneClientRecordByExpiry(oauthStore.tokens, source.client_id, MAX_ACCESS_TOKENS_PER_CLIENT);
|
|
186
195
|
pruneRecordByExpiry(oauthStore.tokens, MAX_ACCESS_TOKENS);
|