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.
Files changed (73) hide show
  1. package/CHANGELOG.md +23 -1
  2. package/CONTRIBUTING.md +1 -1
  3. package/README.md +11 -6
  4. package/browser-extension/manifest.json +2 -2
  5. package/docs/ARCHITECTURE.md +23 -11
  6. package/docs/AUDIT.md +37 -3
  7. package/docs/CLIENTS.md +2 -2
  8. package/docs/ENGINEERING.md +2 -2
  9. package/docs/GETTING_STARTED.md +1 -1
  10. package/docs/LOCAL_AUTHORIZATION.md +111 -0
  11. package/docs/LOGGING.md +7 -5
  12. package/docs/MULTI_ACCOUNT.md +5 -5
  13. package/docs/OPERATIONS.md +33 -7
  14. package/docs/OVERVIEW.md +12 -8
  15. package/docs/PROJECT_STANDARDS.md +1 -1
  16. package/docs/RELEASING.md +4 -4
  17. package/docs/TESTING.md +9 -2
  18. package/docs/THREAT_MODEL.md +7 -6
  19. package/docs/TOOL_REFERENCE.md +4 -4
  20. package/docs/UPGRADING.md +10 -6
  21. package/package.json +8 -2
  22. package/scripts/check-plan.mjs +5 -0
  23. package/scripts/check-runner.mjs +75 -0
  24. package/scripts/coverage-check.mjs +1 -1
  25. package/scripts/github-backlog.mjs +116 -0
  26. package/scripts/github-push.mjs +4 -0
  27. package/scripts/local-release-acceptance.mjs +2 -2
  28. package/scripts/release-acceptance.mjs +36 -17
  29. package/scripts/run-checks.mjs +11 -21
  30. package/src/local/account-admin.mjs +28 -3
  31. package/src/local/bounded-output.mjs +20 -3
  32. package/src/local/cli-approval.mjs +117 -0
  33. package/src/local/cli-options.mjs +8 -2
  34. package/src/local/cli.mjs +13 -2
  35. package/src/local/device-identity.mjs +108 -0
  36. package/src/local/errors.mjs +5 -1
  37. package/src/local/execution-limits.mjs +6 -1
  38. package/src/local/operation-authorization.mjs +366 -0
  39. package/src/local/operation-risk.mjs +221 -0
  40. package/src/local/operation-state-lock.mjs +92 -0
  41. package/src/local/process-execution.mjs +94 -14
  42. package/src/local/process-output-stream.mjs +82 -0
  43. package/src/local/process-result-projection.mjs +25 -0
  44. package/src/local/process-sessions.mjs +37 -51
  45. package/src/local/relay-connection.mjs +12 -5
  46. package/src/local/runtime-relay.mjs +13 -5
  47. package/src/local/runtime.mjs +14 -7
  48. package/src/local/secure-file.mjs +24 -4
  49. package/src/local/state.mjs +9 -3
  50. package/src/local/tool-executor.mjs +4 -2
  51. package/src/local/tools.mjs +4 -9
  52. package/src/local/worker-deployment.mjs +4 -3
  53. package/src/local/worker-secret-file.mjs +2 -1
  54. package/src/shared/admin-auth.d.mts +10 -0
  55. package/src/shared/admin-auth.mjs +36 -0
  56. package/src/shared/daemon-auth.d.mts +20 -0
  57. package/src/shared/daemon-auth.mjs +55 -0
  58. package/src/shared/result-projection.d.mts +6 -0
  59. package/src/shared/result-projection.json +4 -0
  60. package/src/shared/result-projection.mjs +50 -0
  61. package/src/shared/server-metadata.json +1 -0
  62. package/src/shared/tool-catalog.json +4 -4
  63. package/src/worker/account-admin.ts +73 -4
  64. package/src/worker/daemon-auth.ts +205 -0
  65. package/src/worker/daemon-sockets.ts +15 -2
  66. package/src/worker/errors.ts +18 -4
  67. package/src/worker/index.ts +59 -10
  68. package/src/worker/mcp-jsonrpc.ts +4 -2
  69. package/src/worker/nonce-store.ts +79 -0
  70. package/src/worker/oauth-controller.ts +11 -6
  71. package/src/worker/oauth-refresh-families.ts +172 -0
  72. package/src/worker/oauth-state.ts +48 -3
  73. 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
- DAEMON_SHARED_SECRET: string;
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: Math.floor(Date.now() / 1000),
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.DAEMON_SHARED_SECRET || this.env.ACCOUNT_ADMIN_SECRET;
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 = 1;
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 type OAuthRefreshToken = OAuthToken;
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 && isRecord(store.tokens);
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
- emptyOAuthRefreshStore, isCurrentOAuthRefreshStore, normalizeOAuthScope, pkceS256,
3
- pruneClientRecordByExpiry, pruneRecordByExpiry, randomToken, safeEqual, sha256Hex,
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 = 60 * 60 * 24 * 30;
9
- const REFRESH_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 90;
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 loadRefreshStore(oauthStore, options);
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 loadRefreshStore(oauthStore, options);
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) return json({ error: "invalid_grant" }, 400);
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
- const issued = await issueTokenPair(oauthStore, refreshStore, record, options.tokenVersion);
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
- expires_at: now + REFRESH_TOKEN_TTL_SECONDS,
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);