@assistant-ui/react-mcp 0.1.16 → 0.1.17
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/dist/auth/createOAuthProvider.d.ts +13 -2
- package/dist/auth/createOAuthProvider.d.ts.map +1 -1
- package/dist/auth/createOAuthProvider.js +109 -31
- package/dist/auth/createOAuthProvider.js.map +1 -1
- package/dist/auth/types.d.ts +5 -1
- package/dist/auth/types.d.ts.map +1 -1
- package/dist/resources/McpManagerResource.d.ts.map +1 -1
- package/dist/resources/McpManagerResource.js.map +1 -1
- package/dist/resources/McpServerResource.d.ts.map +1 -1
- package/dist/resources/McpServerResource.js +36 -15
- package/dist/resources/McpServerResource.js.map +1 -1
- package/dist/resources/storage/McpLocalStorage.d.ts.map +1 -1
- package/dist/resources/storage/McpLocalStorage.js +14 -0
- package/dist/resources/storage/McpLocalStorage.js.map +1 -1
- package/dist/utils/serverUrl.d.ts +8 -0
- package/dist/utils/serverUrl.d.ts.map +1 -0
- package/dist/utils/serverUrl.js +15 -0
- package/dist/utils/serverUrl.js.map +1 -0
- package/package.json +6 -6
- package/src/auth/createOAuthProvider.test.ts +544 -35
- package/src/auth/createOAuthProvider.ts +201 -46
- package/src/auth/types.ts +5 -1
- package/src/resources/McpManagerResource.ts +3 -0
- package/src/resources/McpServerResource.test.ts +252 -17
- package/src/resources/McpServerResource.ts +35 -14
- package/src/resources/storage/McpLocalStorage.test.ts +26 -0
- package/src/resources/storage/McpLocalStorage.ts +21 -0
- package/src/utils/serverUrl.test.ts +66 -0
- package/src/utils/serverUrl.ts +23 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { auth, type OAuthDiscoveryState } from "@modelcontextprotocol/client";
|
|
2
2
|
import { describe, expect, it, vi } from "vitest";
|
|
3
3
|
import type { MCPStorage } from "../resources/storage/types";
|
|
4
4
|
import type { MCPPersistedAuthState } from "./types";
|
|
@@ -23,6 +23,8 @@ const discoveryState: OAuthDiscoveryState = {
|
|
|
23
23
|
},
|
|
24
24
|
};
|
|
25
25
|
|
|
26
|
+
const serverUrl = "https://mcp.example.com/docs";
|
|
27
|
+
|
|
26
28
|
const createStorage = (initial: MCPPersistedAuthState | null = null) => {
|
|
27
29
|
let state = initial;
|
|
28
30
|
const storage: MCPStorage = {
|
|
@@ -59,22 +61,69 @@ const createSharedStorages = (scopeId: string) => {
|
|
|
59
61
|
const createProvider = (storage: MCPStorage) =>
|
|
60
62
|
createOAuthProvider({
|
|
61
63
|
serverId: "docs",
|
|
64
|
+
serverUrl,
|
|
62
65
|
config: { type: "oauth" },
|
|
63
66
|
storage,
|
|
64
67
|
redirectUri: "http://localhost/callback",
|
|
65
68
|
onAuthorizationUrl: () => {},
|
|
66
69
|
});
|
|
67
70
|
|
|
71
|
+
const createStaticProvider = (storage: MCPStorage, clientSecret?: string) =>
|
|
72
|
+
createOAuthProvider({
|
|
73
|
+
serverId: "docs",
|
|
74
|
+
serverUrl,
|
|
75
|
+
config: {
|
|
76
|
+
type: "oauth",
|
|
77
|
+
clientId: "client-a",
|
|
78
|
+
...(clientSecret ? { clientSecret } : {}),
|
|
79
|
+
},
|
|
80
|
+
storage,
|
|
81
|
+
redirectUri: "http://localhost/callback",
|
|
82
|
+
onAuthorizationUrl: () => {},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const createStaticProviderForUrl = (storage: MCPStorage, url: string) =>
|
|
86
|
+
createOAuthProvider({
|
|
87
|
+
serverId: "docs",
|
|
88
|
+
serverUrl: url,
|
|
89
|
+
config: { type: "oauth", clientId: "client-a" },
|
|
90
|
+
storage,
|
|
91
|
+
redirectUri: "http://localhost/callback",
|
|
92
|
+
onAuthorizationUrl: () => {},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const discoveryStateFor = (issuer: string): OAuthDiscoveryState => ({
|
|
96
|
+
...discoveryState,
|
|
97
|
+
authorizationServerUrl: issuer,
|
|
98
|
+
authorizationServerMetadata: {
|
|
99
|
+
issuer,
|
|
100
|
+
authorization_endpoint: `${issuer}/authorize`,
|
|
101
|
+
token_endpoint: `${issuer}/token`,
|
|
102
|
+
registration_endpoint: `${issuer}/register`,
|
|
103
|
+
response_types_supported: ["code"],
|
|
104
|
+
code_challenge_methods_supported: ["S256"],
|
|
105
|
+
},
|
|
106
|
+
resourceMetadata: {
|
|
107
|
+
resource: "https://mcp.example.com",
|
|
108
|
+
authorization_servers: [issuer],
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const rejectFetch = async () => {
|
|
113
|
+
throw new Error("Unexpected OAuth request");
|
|
114
|
+
};
|
|
115
|
+
|
|
68
116
|
describe("createOAuthProvider callback state", () => {
|
|
69
117
|
it("persists the generated state with the PKCE verifier", async () => {
|
|
70
118
|
const { storage, getState } = createStorage();
|
|
71
|
-
const provider =
|
|
119
|
+
const provider = createStaticProvider(storage);
|
|
72
120
|
|
|
73
121
|
const state = await provider.state?.();
|
|
74
122
|
await provider.saveCodeVerifier("pkce-verifier");
|
|
75
123
|
|
|
76
124
|
expect(state).toMatch(/^aui-mcp:ZG9jcw\./);
|
|
77
125
|
expect(getState()).toEqual({
|
|
126
|
+
serverUrl,
|
|
78
127
|
codeVerifier: "pkce-verifier",
|
|
79
128
|
state,
|
|
80
129
|
});
|
|
@@ -82,10 +131,11 @@ describe("createOAuthProvider callback state", () => {
|
|
|
82
131
|
|
|
83
132
|
it("consumes callback state when tokens are saved", async () => {
|
|
84
133
|
const { storage, getState } = createStorage({
|
|
134
|
+
serverUrl,
|
|
85
135
|
codeVerifier: "pkce-verifier",
|
|
86
136
|
state: "aui-mcp:ZG9jcw.nonce",
|
|
87
137
|
});
|
|
88
|
-
const provider =
|
|
138
|
+
const provider = createStaticProvider(storage);
|
|
89
139
|
|
|
90
140
|
await provider.saveTokens({
|
|
91
141
|
access_token: "access-token",
|
|
@@ -93,7 +143,9 @@ describe("createOAuthProvider callback state", () => {
|
|
|
93
143
|
});
|
|
94
144
|
|
|
95
145
|
expect(getState()).toEqual({
|
|
146
|
+
serverUrl,
|
|
96
147
|
tokens: { access_token: "access-token", token_type: "bearer" },
|
|
148
|
+
tokensClientId: "client-a",
|
|
97
149
|
codeVerifier: "pkce-verifier",
|
|
98
150
|
});
|
|
99
151
|
});
|
|
@@ -102,6 +154,7 @@ describe("createOAuthProvider callback state", () => {
|
|
|
102
154
|
"clears callback state through the %s invalidation scope",
|
|
103
155
|
async (scope) => {
|
|
104
156
|
const { storage, getState } = createStorage({
|
|
157
|
+
serverUrl,
|
|
105
158
|
codeVerifier: "pkce-verifier",
|
|
106
159
|
state: "aui-mcp:ZG9jcw.nonce",
|
|
107
160
|
});
|
|
@@ -109,7 +162,7 @@ describe("createOAuthProvider callback state", () => {
|
|
|
109
162
|
|
|
110
163
|
await provider.invalidateCredentials?.(scope);
|
|
111
164
|
|
|
112
|
-
expect(getState()).toEqual({});
|
|
165
|
+
expect(getState()).toEqual({ serverUrl });
|
|
113
166
|
},
|
|
114
167
|
);
|
|
115
168
|
});
|
|
@@ -117,6 +170,7 @@ describe("createOAuthProvider callback state", () => {
|
|
|
117
170
|
describe("createOAuthProvider discovery state", () => {
|
|
118
171
|
it("persists discovery state alongside the PKCE verifier", async () => {
|
|
119
172
|
const { storage, getState } = createStorage({
|
|
173
|
+
serverUrl,
|
|
120
174
|
codeVerifier: "pkce-verifier",
|
|
121
175
|
});
|
|
122
176
|
const provider = createProvider(storage);
|
|
@@ -124,13 +178,14 @@ describe("createOAuthProvider discovery state", () => {
|
|
|
124
178
|
await provider.saveDiscoveryState?.(discoveryState);
|
|
125
179
|
|
|
126
180
|
expect(getState()).toEqual({
|
|
181
|
+
serverUrl,
|
|
127
182
|
codeVerifier: "pkce-verifier",
|
|
128
183
|
discoveryState,
|
|
129
184
|
});
|
|
130
185
|
});
|
|
131
186
|
|
|
132
187
|
it("restores discovery state on the OAuth callback leg", async () => {
|
|
133
|
-
const { storage } = createStorage({ discoveryState });
|
|
188
|
+
const { storage } = createStorage({ serverUrl, discoveryState });
|
|
134
189
|
const provider = createProvider(storage);
|
|
135
190
|
|
|
136
191
|
await expect(provider.discoveryState?.()).resolves.toEqual(discoveryState);
|
|
@@ -140,6 +195,7 @@ describe("createOAuthProvider discovery state", () => {
|
|
|
140
195
|
"clears discovery state through the %s invalidation scope",
|
|
141
196
|
async (scope) => {
|
|
142
197
|
const { storage, getState } = createStorage({
|
|
198
|
+
serverUrl,
|
|
143
199
|
codeVerifier: "pkce-verifier",
|
|
144
200
|
discoveryState,
|
|
145
201
|
});
|
|
@@ -148,13 +204,376 @@ describe("createOAuthProvider discovery state", () => {
|
|
|
148
204
|
await provider.invalidateCredentials?.(scope);
|
|
149
205
|
|
|
150
206
|
expect(getState()).toEqual(
|
|
151
|
-
scope === "all"
|
|
207
|
+
scope === "all"
|
|
208
|
+
? { serverUrl }
|
|
209
|
+
: { serverUrl, codeVerifier: "pkce-verifier" },
|
|
152
210
|
);
|
|
153
211
|
},
|
|
154
212
|
);
|
|
155
213
|
});
|
|
156
214
|
|
|
157
215
|
describe("createOAuthProvider persistence", () => {
|
|
216
|
+
it("migrates unmarked OAuth credentials without losing callback state", async () => {
|
|
217
|
+
const { storage, getState } = createStorage({
|
|
218
|
+
serverUrl,
|
|
219
|
+
tokens: {
|
|
220
|
+
access_token: "legacy-access",
|
|
221
|
+
token_type: "bearer",
|
|
222
|
+
refresh_token: "legacy-refresh",
|
|
223
|
+
},
|
|
224
|
+
clientInformation: {
|
|
225
|
+
client_id: "legacy-client",
|
|
226
|
+
redirect_uris: ["http://localhost/callback"],
|
|
227
|
+
},
|
|
228
|
+
codeVerifier: "pkce-verifier",
|
|
229
|
+
state: "aui-mcp:ZG9jcw.nonce",
|
|
230
|
+
discoveryState,
|
|
231
|
+
token: "bearer-token",
|
|
232
|
+
});
|
|
233
|
+
const provider = createProvider(storage);
|
|
234
|
+
|
|
235
|
+
await expect(provider.clientInformation()).resolves.toBeUndefined();
|
|
236
|
+
await expect(provider.tokens()).resolves.toBeUndefined();
|
|
237
|
+
|
|
238
|
+
expect(getState()).toEqual({
|
|
239
|
+
serverUrl,
|
|
240
|
+
codeVerifier: "pkce-verifier",
|
|
241
|
+
state: "aui-mcp:ZG9jcw.nonce",
|
|
242
|
+
discoveryState,
|
|
243
|
+
token: "bearer-token",
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("reuses only marked dynamic credentials for the same client", async () => {
|
|
248
|
+
const clientInformation = {
|
|
249
|
+
client_id: "registered-client",
|
|
250
|
+
redirect_uris: ["http://localhost/callback"],
|
|
251
|
+
};
|
|
252
|
+
const { storage } = createStorage({
|
|
253
|
+
serverUrl,
|
|
254
|
+
clientInformation,
|
|
255
|
+
clientInformationSource: "registered",
|
|
256
|
+
tokens: { access_token: "access-token", token_type: "bearer" },
|
|
257
|
+
tokensClientId: "registered-client",
|
|
258
|
+
});
|
|
259
|
+
const provider = createProvider(storage);
|
|
260
|
+
|
|
261
|
+
await expect(provider.clientInformation()).resolves.toEqual(
|
|
262
|
+
clientInformation,
|
|
263
|
+
);
|
|
264
|
+
await expect(provider.tokens()).resolves.toEqual({
|
|
265
|
+
access_token: "access-token",
|
|
266
|
+
token_type: "bearer",
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("drops credentials when a configured client changes", async () => {
|
|
271
|
+
const { storage, getState } = createStorage({
|
|
272
|
+
serverUrl,
|
|
273
|
+
clientInformation: {
|
|
274
|
+
client_id: "client-a",
|
|
275
|
+
redirect_uris: ["http://localhost/callback"],
|
|
276
|
+
},
|
|
277
|
+
clientInformationSource: "registered",
|
|
278
|
+
tokens: { access_token: "access-token", token_type: "bearer" },
|
|
279
|
+
tokensClientId: "client-a",
|
|
280
|
+
});
|
|
281
|
+
const provider = createOAuthProvider({
|
|
282
|
+
serverId: "docs",
|
|
283
|
+
serverUrl,
|
|
284
|
+
config: { type: "oauth", clientId: "client-b" },
|
|
285
|
+
storage,
|
|
286
|
+
redirectUri: "http://localhost/callback",
|
|
287
|
+
onAuthorizationUrl: () => {},
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
await expect(provider.clientInformation()).resolves.toEqual({
|
|
291
|
+
client_id: "client-b",
|
|
292
|
+
redirect_uris: ["http://localhost/callback"],
|
|
293
|
+
});
|
|
294
|
+
await expect(provider.tokens()).resolves.toBeUndefined();
|
|
295
|
+
expect(getState()).toEqual({ serverUrl });
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("keeps a registered client when static config uses the same client", async () => {
|
|
299
|
+
const clientInformation = {
|
|
300
|
+
client_id: "client-a",
|
|
301
|
+
redirect_uris: ["http://localhost/callback"],
|
|
302
|
+
};
|
|
303
|
+
const { storage, getState } = createStorage({
|
|
304
|
+
serverUrl,
|
|
305
|
+
clientInformation,
|
|
306
|
+
clientInformationSource: "registered",
|
|
307
|
+
tokens: { access_token: "access-token", token_type: "bearer" },
|
|
308
|
+
tokensClientId: "client-a",
|
|
309
|
+
});
|
|
310
|
+
const provider = createStaticProvider(storage);
|
|
311
|
+
|
|
312
|
+
await provider.discoveryState?.();
|
|
313
|
+
|
|
314
|
+
expect(getState()).toEqual({
|
|
315
|
+
serverUrl,
|
|
316
|
+
clientInformation,
|
|
317
|
+
clientInformationSource: "registered",
|
|
318
|
+
tokens: { access_token: "access-token", token_type: "bearer" },
|
|
319
|
+
tokensClientId: "client-a",
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("keeps a retained registration when only the tokens are migrated away", async () => {
|
|
324
|
+
const clientInformation = {
|
|
325
|
+
client_id: "client-a",
|
|
326
|
+
redirect_uris: ["http://localhost/callback"],
|
|
327
|
+
};
|
|
328
|
+
const { storage, getState } = createStorage({
|
|
329
|
+
serverUrl,
|
|
330
|
+
clientInformation,
|
|
331
|
+
clientInformationSource: "registered",
|
|
332
|
+
tokens: { access_token: "unbound", token_type: "bearer" },
|
|
333
|
+
});
|
|
334
|
+
const provider = createStaticProvider(storage);
|
|
335
|
+
|
|
336
|
+
await provider.discoveryState?.();
|
|
337
|
+
|
|
338
|
+
expect(getState()).toEqual({
|
|
339
|
+
serverUrl,
|
|
340
|
+
clientInformation,
|
|
341
|
+
clientInformationSource: "registered",
|
|
342
|
+
});
|
|
343
|
+
expect(await provider.tokens()).toBeUndefined();
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("does not let the migration write overwrite a concurrent save", async () => {
|
|
347
|
+
let state: MCPPersistedAuthState | null = {
|
|
348
|
+
serverUrl,
|
|
349
|
+
clientInformation: {
|
|
350
|
+
client_id: "legacy",
|
|
351
|
+
redirect_uris: ["http://localhost/callback"],
|
|
352
|
+
},
|
|
353
|
+
tokens: { access_token: "legacy", token_type: "bearer" },
|
|
354
|
+
};
|
|
355
|
+
let releaseFirstWrite = () => {};
|
|
356
|
+
const firstWriteGate = new Promise<void>((resolve) => {
|
|
357
|
+
releaseFirstWrite = resolve;
|
|
358
|
+
});
|
|
359
|
+
let firstWriteSeen = false;
|
|
360
|
+
const create = (): MCPStorage => ({
|
|
361
|
+
scopeId: "migration-race",
|
|
362
|
+
loadCustomServers: async () => [],
|
|
363
|
+
saveCustomServers: async () => {},
|
|
364
|
+
loadAuthState: async () => state,
|
|
365
|
+
saveAuthState: async (_serverId, next) => {
|
|
366
|
+
if (!firstWriteSeen) {
|
|
367
|
+
firstWriteSeen = true;
|
|
368
|
+
await firstWriteGate;
|
|
369
|
+
}
|
|
370
|
+
state = next;
|
|
371
|
+
},
|
|
372
|
+
clearAuthState: async () => {
|
|
373
|
+
state = null;
|
|
374
|
+
},
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
const migrating = createProvider(create()).tokens();
|
|
378
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
379
|
+
const saving = createProvider(create()).saveCodeVerifier("verifier-xyz");
|
|
380
|
+
setTimeout(releaseFirstWrite, 20);
|
|
381
|
+
await migrating;
|
|
382
|
+
await saving;
|
|
383
|
+
|
|
384
|
+
expect(state).toEqual({ serverUrl, codeVerifier: "verifier-xyz" });
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("keeps a non-persistable re-registration out of storage", async () => {
|
|
388
|
+
const { storage, getState } = createStorage({ serverUrl });
|
|
389
|
+
const provider = createStaticProvider(storage);
|
|
390
|
+
|
|
391
|
+
await provider.saveClientInformation?.({
|
|
392
|
+
client_id: "registered-client",
|
|
393
|
+
redirect_uris: ["http://localhost/callback"],
|
|
394
|
+
});
|
|
395
|
+
await provider.saveTokens({
|
|
396
|
+
access_token: "minted-for-registered",
|
|
397
|
+
token_type: "bearer",
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
expect(getState()).toEqual({ serverUrl });
|
|
401
|
+
await expect(provider.tokens()).resolves.toEqual({
|
|
402
|
+
access_token: "minted-for-registered",
|
|
403
|
+
token_type: "bearer",
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it("still reads a sanitized cache when the migration write fails", async () => {
|
|
408
|
+
const storage: MCPStorage = {
|
|
409
|
+
loadCustomServers: async () => [],
|
|
410
|
+
saveCustomServers: async () => {},
|
|
411
|
+
loadAuthState: async () => ({
|
|
412
|
+
serverUrl,
|
|
413
|
+
clientInformation: {
|
|
414
|
+
client_id: "legacy",
|
|
415
|
+
redirect_uris: ["http://localhost/callback"],
|
|
416
|
+
},
|
|
417
|
+
tokens: { access_token: "legacy", token_type: "bearer" },
|
|
418
|
+
}),
|
|
419
|
+
saveAuthState: async () => {
|
|
420
|
+
throw new Error("storage unavailable");
|
|
421
|
+
},
|
|
422
|
+
clearAuthState: async () => {},
|
|
423
|
+
};
|
|
424
|
+
const provider = createProvider(storage);
|
|
425
|
+
|
|
426
|
+
await expect(provider.tokens()).resolves.toBeUndefined();
|
|
427
|
+
await expect(provider.clientInformation()).resolves.toBeUndefined();
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("drops tokens when dynamic registration replaces the client", async () => {
|
|
431
|
+
const { storage, getState } = createStorage({
|
|
432
|
+
serverUrl,
|
|
433
|
+
clientInformation: {
|
|
434
|
+
client_id: "client-a",
|
|
435
|
+
redirect_uris: ["http://localhost/callback"],
|
|
436
|
+
},
|
|
437
|
+
clientInformationSource: "registered",
|
|
438
|
+
tokens: { access_token: "access-token", token_type: "bearer" },
|
|
439
|
+
tokensClientId: "client-a",
|
|
440
|
+
});
|
|
441
|
+
const provider = createProvider(storage);
|
|
442
|
+
|
|
443
|
+
await provider.clientInformation();
|
|
444
|
+
await provider.saveClientInformation?.({
|
|
445
|
+
client_id: "client-b",
|
|
446
|
+
redirect_uris: ["http://localhost/callback"],
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
await expect(provider.tokens()).resolves.toBeUndefined();
|
|
450
|
+
expect(getState()).toEqual({
|
|
451
|
+
serverUrl,
|
|
452
|
+
clientInformation: {
|
|
453
|
+
client_id: "client-b",
|
|
454
|
+
redirect_uris: ["http://localhost/callback"],
|
|
455
|
+
},
|
|
456
|
+
clientInformationSource: "registered",
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("binds newly saved tokens to the effective client", async () => {
|
|
461
|
+
const { storage, getState } = createStorage();
|
|
462
|
+
const provider = createProvider(storage);
|
|
463
|
+
|
|
464
|
+
await provider.saveClientInformation?.({
|
|
465
|
+
client_id: "registered-client",
|
|
466
|
+
redirect_uris: ["http://localhost/callback"],
|
|
467
|
+
});
|
|
468
|
+
await provider.saveTokens({
|
|
469
|
+
access_token: "access-token",
|
|
470
|
+
token_type: "bearer",
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
expect(getState()).toMatchObject({
|
|
474
|
+
clientInformationSource: "registered",
|
|
475
|
+
tokensClientId: "registered-client",
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it("does not reuse authentication saved for a different server URL", async () => {
|
|
480
|
+
const { storage } = createStorage({
|
|
481
|
+
serverUrl: "https://endpoint-a.example.com/mcp",
|
|
482
|
+
tokens: { access_token: "endpoint-a-token", token_type: "bearer" },
|
|
483
|
+
});
|
|
484
|
+
const provider = createOAuthProvider({
|
|
485
|
+
serverId: "docs",
|
|
486
|
+
serverUrl: "https://endpoint-b.example.com/mcp",
|
|
487
|
+
config: { type: "oauth" },
|
|
488
|
+
storage,
|
|
489
|
+
redirectUri: "http://localhost/callback",
|
|
490
|
+
onAuthorizationUrl: () => {},
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
await expect(provider.tokens()).resolves.toBeUndefined();
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it("keeps in-memory authentication scoped to its server URL", async () => {
|
|
497
|
+
const { storage } = createStorage();
|
|
498
|
+
const endpointA = createStaticProviderForUrl(
|
|
499
|
+
storage,
|
|
500
|
+
"https://endpoint-a.example.com/mcp",
|
|
501
|
+
);
|
|
502
|
+
await endpointA.saveTokens({
|
|
503
|
+
access_token: "endpoint-a-token",
|
|
504
|
+
token_type: "bearer",
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
const endpointB = createStaticProviderForUrl(
|
|
508
|
+
storage,
|
|
509
|
+
"https://endpoint-b.example.com/mcp",
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
await expect(endpointB.tokens()).resolves.toBeUndefined();
|
|
513
|
+
await endpointB.saveTokens({
|
|
514
|
+
access_token: "endpoint-b-token",
|
|
515
|
+
token_type: "bearer",
|
|
516
|
+
});
|
|
517
|
+
await expect(endpointA.tokens()).resolves.toBeUndefined();
|
|
518
|
+
await expect(endpointB.tokens()).resolves.toEqual({
|
|
519
|
+
access_token: "endpoint-b-token",
|
|
520
|
+
token_type: "bearer",
|
|
521
|
+
});
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it("waits for pending writes before reloading a previous endpoint", async () => {
|
|
525
|
+
const { storage } = createStorage();
|
|
526
|
+
const loadAuthState = vi.spyOn(storage, "loadAuthState");
|
|
527
|
+
const saveAuthState = storage.saveAuthState;
|
|
528
|
+
let releaseWrite!: () => void;
|
|
529
|
+
storage.saveAuthState = async (serverId, next) => {
|
|
530
|
+
await new Promise<void>((resolve) => {
|
|
531
|
+
releaseWrite = resolve;
|
|
532
|
+
});
|
|
533
|
+
await saveAuthState(serverId, next);
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
const endpointA = createStaticProviderForUrl(
|
|
537
|
+
storage,
|
|
538
|
+
"https://endpoint-a.example.com/mcp",
|
|
539
|
+
);
|
|
540
|
+
await endpointA.tokens();
|
|
541
|
+
const pendingSave = endpointA.saveTokens({
|
|
542
|
+
access_token: "endpoint-a-token",
|
|
543
|
+
token_type: "bearer",
|
|
544
|
+
});
|
|
545
|
+
await vi.waitFor(() => expect(releaseWrite).toBeDefined());
|
|
546
|
+
|
|
547
|
+
createStaticProviderForUrl(storage, "https://endpoint-b.example.com/mcp");
|
|
548
|
+
const replacementA = createStaticProviderForUrl(
|
|
549
|
+
storage,
|
|
550
|
+
"https://endpoint-a.example.com/mcp",
|
|
551
|
+
);
|
|
552
|
+
const tokens = replacementA.tokens();
|
|
553
|
+
|
|
554
|
+
await Promise.resolve();
|
|
555
|
+
expect(loadAuthState).toHaveBeenCalledTimes(1);
|
|
556
|
+
|
|
557
|
+
releaseWrite();
|
|
558
|
+
await pendingSave;
|
|
559
|
+
await expect(tokens).resolves.toEqual({
|
|
560
|
+
access_token: "endpoint-a-token",
|
|
561
|
+
token_type: "bearer",
|
|
562
|
+
});
|
|
563
|
+
expect(loadAuthState).toHaveBeenCalledTimes(2);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it("does not reuse unbound legacy OAuth authentication", async () => {
|
|
567
|
+
const { storage } = createStorage({
|
|
568
|
+
tokens: { access_token: "legacy-token", token_type: "bearer" },
|
|
569
|
+
});
|
|
570
|
+
const saveAuthState = vi.spyOn(storage, "saveAuthState");
|
|
571
|
+
const provider = createStaticProvider(storage);
|
|
572
|
+
|
|
573
|
+
await expect(provider.tokens()).resolves.toBeUndefined();
|
|
574
|
+
expect(saveAuthState).not.toHaveBeenCalled();
|
|
575
|
+
});
|
|
576
|
+
|
|
158
577
|
it("loads persisted auth state once for concurrent reads", async () => {
|
|
159
578
|
let resolveLoad!: (value: MCPPersistedAuthState | null) => void;
|
|
160
579
|
const loadAuthState = vi.fn(
|
|
@@ -165,12 +584,12 @@ describe("createOAuthProvider persistence", () => {
|
|
|
165
584
|
);
|
|
166
585
|
const { storage } = createStorage();
|
|
167
586
|
storage.loadAuthState = loadAuthState;
|
|
168
|
-
const provider =
|
|
587
|
+
const provider = createStaticProvider(storage);
|
|
169
588
|
|
|
170
589
|
const tokens = provider.tokens();
|
|
171
590
|
const clientInformation = provider.clientInformation();
|
|
172
591
|
|
|
173
|
-
expect(loadAuthState).toHaveBeenCalledTimes(1);
|
|
592
|
+
await vi.waitFor(() => expect(loadAuthState).toHaveBeenCalledTimes(1));
|
|
174
593
|
resolveLoad(null);
|
|
175
594
|
await Promise.all([tokens, clientInformation]);
|
|
176
595
|
});
|
|
@@ -180,7 +599,7 @@ describe("createOAuthProvider persistence", () => {
|
|
|
180
599
|
const loadAuthState = vi
|
|
181
600
|
.fn<() => Promise<MCPPersistedAuthState | null>>()
|
|
182
601
|
.mockRejectedValueOnce(failure)
|
|
183
|
-
.mockResolvedValueOnce({ codeVerifier: "pkce-verifier" });
|
|
602
|
+
.mockResolvedValueOnce({ serverUrl, codeVerifier: "pkce-verifier" });
|
|
184
603
|
const { storage } = createStorage();
|
|
185
604
|
storage.loadAuthState = loadAuthState;
|
|
186
605
|
const provider = createProvider(storage);
|
|
@@ -204,7 +623,7 @@ describe("createOAuthProvider persistence", () => {
|
|
|
204
623
|
await new Promise<void>((resolve) => pendingWrites.push(resolve));
|
|
205
624
|
persisted = next;
|
|
206
625
|
};
|
|
207
|
-
const provider =
|
|
626
|
+
const provider = createStaticProvider(storage);
|
|
208
627
|
await provider.tokens();
|
|
209
628
|
|
|
210
629
|
const tokenSave = provider.saveTokens({
|
|
@@ -223,7 +642,9 @@ describe("createOAuthProvider persistence", () => {
|
|
|
223
642
|
await Promise.all([tokenSave, verifierSave]);
|
|
224
643
|
|
|
225
644
|
expect(persisted).toEqual({
|
|
645
|
+
serverUrl,
|
|
226
646
|
tokens: { access_token: "access-token", token_type: "bearer" },
|
|
647
|
+
tokensClientId: "client-a",
|
|
227
648
|
codeVerifier: "pkce-verifier",
|
|
228
649
|
});
|
|
229
650
|
});
|
|
@@ -243,7 +664,7 @@ describe("createOAuthProvider persistence", () => {
|
|
|
243
664
|
}
|
|
244
665
|
persisted = next;
|
|
245
666
|
};
|
|
246
|
-
const provider =
|
|
667
|
+
const provider = createStaticProvider(storage);
|
|
247
668
|
await provider.tokens();
|
|
248
669
|
|
|
249
670
|
const tokenSave = provider.saveTokens({
|
|
@@ -262,7 +683,9 @@ describe("createOAuthProvider persistence", () => {
|
|
|
262
683
|
await expect(verifierSave).resolves.toBeUndefined();
|
|
263
684
|
expect(saveCount).toBe(2);
|
|
264
685
|
expect(persisted).toEqual({
|
|
686
|
+
serverUrl,
|
|
265
687
|
tokens: { access_token: "access-token", token_type: "bearer" },
|
|
688
|
+
tokensClientId: "client-a",
|
|
266
689
|
codeVerifier: "pkce-verifier",
|
|
267
690
|
});
|
|
268
691
|
});
|
|
@@ -279,13 +702,13 @@ describe("createOAuthProvider persistence across provider instances", () => {
|
|
|
279
702
|
);
|
|
280
703
|
const { storage } = createStorage();
|
|
281
704
|
storage.loadAuthState = loadAuthState;
|
|
282
|
-
const provider =
|
|
283
|
-
const replacementProvider =
|
|
705
|
+
const provider = createStaticProvider(storage);
|
|
706
|
+
const replacementProvider = createStaticProvider(storage);
|
|
284
707
|
|
|
285
708
|
const tokens = provider.tokens();
|
|
286
709
|
const clientInformation = replacementProvider.clientInformation();
|
|
287
710
|
|
|
288
|
-
expect(loadAuthState).toHaveBeenCalledTimes(1);
|
|
711
|
+
await vi.waitFor(() => expect(loadAuthState).toHaveBeenCalledTimes(1));
|
|
289
712
|
resolveLoad(null);
|
|
290
713
|
await Promise.all([tokens, clientInformation]);
|
|
291
714
|
});
|
|
@@ -298,8 +721,8 @@ describe("createOAuthProvider persistence across provider instances", () => {
|
|
|
298
721
|
await new Promise<void>((resolve) => pendingWrites.push(resolve));
|
|
299
722
|
persisted = next;
|
|
300
723
|
};
|
|
301
|
-
const provider =
|
|
302
|
-
const replacementProvider =
|
|
724
|
+
const provider = createStaticProvider(storage);
|
|
725
|
+
const replacementProvider = createStaticProvider(storage);
|
|
303
726
|
await Promise.all([provider.tokens(), replacementProvider.tokens()]);
|
|
304
727
|
|
|
305
728
|
const tokenSave = provider.saveTokens({
|
|
@@ -318,7 +741,9 @@ describe("createOAuthProvider persistence across provider instances", () => {
|
|
|
318
741
|
await Promise.all([tokenSave, verifierSave]);
|
|
319
742
|
|
|
320
743
|
expect(persisted).toEqual({
|
|
744
|
+
serverUrl,
|
|
321
745
|
tokens: { access_token: "access-token", token_type: "bearer" },
|
|
746
|
+
tokensClientId: "client-a",
|
|
322
747
|
codeVerifier: "pkce-verifier",
|
|
323
748
|
});
|
|
324
749
|
});
|
|
@@ -332,7 +757,7 @@ describe("createOAuthProvider persistence across provider instances", () => {
|
|
|
332
757
|
await saveAuthState(serverId, next);
|
|
333
758
|
};
|
|
334
759
|
const clearAuthState = vi.spyOn(storage, "clearAuthState");
|
|
335
|
-
const provider =
|
|
760
|
+
const provider = createStaticProvider(storage);
|
|
336
761
|
await provider.tokens();
|
|
337
762
|
|
|
338
763
|
const save = provider.saveTokens({
|
|
@@ -367,7 +792,7 @@ describe("createOAuthProvider persistence across provider instances", () => {
|
|
|
367
792
|
await saveAuthState(serverId, next);
|
|
368
793
|
};
|
|
369
794
|
const clearAuthState = vi.spyOn(replacement, "clearAuthState");
|
|
370
|
-
const provider =
|
|
795
|
+
const provider = createStaticProvider(storage);
|
|
371
796
|
await provider.tokens();
|
|
372
797
|
|
|
373
798
|
const save = provider.saveTokens({
|
|
@@ -394,8 +819,8 @@ describe("createOAuthProvider persistence across provider instances", () => {
|
|
|
394
819
|
it("keeps differently-scoped storages on separate persistence", async () => {
|
|
395
820
|
const first = createSharedStorages("scope-a");
|
|
396
821
|
const second = createSharedStorages("scope-b");
|
|
397
|
-
const firstProvider =
|
|
398
|
-
const secondProvider =
|
|
822
|
+
const firstProvider = createStaticProvider(first.create());
|
|
823
|
+
const secondProvider = createStaticProvider(second.create());
|
|
399
824
|
|
|
400
825
|
await firstProvider.saveTokens({
|
|
401
826
|
access_token: "first-token",
|
|
@@ -409,7 +834,9 @@ describe("createOAuthProvider persistence across provider instances", () => {
|
|
|
409
834
|
await firstProvider.saveCodeVerifier("first-verifier");
|
|
410
835
|
|
|
411
836
|
expect(first.getState()).toEqual({
|
|
837
|
+
serverUrl,
|
|
412
838
|
tokens: { access_token: "first-token", token_type: "bearer" },
|
|
839
|
+
tokensClientId: "client-a",
|
|
413
840
|
codeVerifier: "first-verifier",
|
|
414
841
|
});
|
|
415
842
|
expect(second.getState()).toBeNull();
|
|
@@ -419,6 +846,7 @@ describe("createOAuthProvider persistence across provider instances", () => {
|
|
|
419
846
|
const { storage } = createStorage();
|
|
420
847
|
const provider = createOAuthProvider({
|
|
421
848
|
serverId: "docs",
|
|
849
|
+
serverUrl,
|
|
422
850
|
config: { type: "oauth", clientId: "client-a" },
|
|
423
851
|
storage,
|
|
424
852
|
redirectUri: "http://localhost/callback",
|
|
@@ -431,6 +859,7 @@ describe("createOAuthProvider persistence across provider instances", () => {
|
|
|
431
859
|
|
|
432
860
|
const replacementProvider = createOAuthProvider({
|
|
433
861
|
serverId: "docs",
|
|
862
|
+
serverUrl,
|
|
434
863
|
config: { type: "oauth", clientId: "client-b", clientSecret: "secret-b" },
|
|
435
864
|
storage,
|
|
436
865
|
redirectUri: "http://localhost/callback-2",
|
|
@@ -443,24 +872,104 @@ describe("createOAuthProvider persistence across provider instances", () => {
|
|
|
443
872
|
});
|
|
444
873
|
});
|
|
445
874
|
|
|
446
|
-
it("
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
875
|
+
it.each([undefined, "registered-client"])(
|
|
876
|
+
"keeps static SDK writeback separate from dynamic client %s",
|
|
877
|
+
async (clientId) => {
|
|
878
|
+
const { storage, getState } = createStorage({
|
|
879
|
+
serverUrl,
|
|
880
|
+
discoveryState: discoveryStateFor("https://auth.example.com"),
|
|
881
|
+
});
|
|
882
|
+
const dynamicProvider = createProvider(storage);
|
|
883
|
+
if (clientId) {
|
|
884
|
+
await dynamicProvider.saveClientInformation?.({
|
|
885
|
+
client_id: clientId,
|
|
886
|
+
redirect_uris: ["http://localhost/callback"],
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
const staticProvider = createStaticProvider(storage);
|
|
890
|
+
|
|
891
|
+
await expect(
|
|
892
|
+
auth(staticProvider, { serverUrl, fetchFn: rejectFetch }),
|
|
893
|
+
).resolves.toBe("REDIRECT");
|
|
894
|
+
|
|
895
|
+
expect(await dynamicProvider.clientInformation()).toEqual(
|
|
896
|
+
clientId
|
|
897
|
+
? {
|
|
898
|
+
client_id: "registered-client",
|
|
899
|
+
redirect_uris: ["http://localhost/callback"],
|
|
900
|
+
}
|
|
901
|
+
: undefined,
|
|
902
|
+
);
|
|
903
|
+
expect(
|
|
904
|
+
await createProvider(
|
|
905
|
+
createStorage(getState()).storage,
|
|
906
|
+
).clientInformation(),
|
|
907
|
+
).toEqual(await dynamicProvider.clientInformation());
|
|
908
|
+
expect(await staticProvider.clientInformation()).toMatchObject({
|
|
909
|
+
client_id: "client-a",
|
|
910
|
+
issuer: "https://auth.example.com",
|
|
911
|
+
});
|
|
912
|
+
},
|
|
913
|
+
);
|
|
914
|
+
|
|
915
|
+
it("drops the configured secret when the SDK re-registers at a new issuer", async () => {
|
|
916
|
+
const { storage, getState } = createStorage({
|
|
917
|
+
serverUrl,
|
|
918
|
+
discoveryState: discoveryStateFor("https://auth.example.com"),
|
|
454
919
|
});
|
|
455
|
-
|
|
456
|
-
|
|
920
|
+
const provider = createStaticProvider(storage, "client-secret");
|
|
921
|
+
|
|
922
|
+
await expect(
|
|
923
|
+
auth(provider, { serverUrl, fetchFn: rejectFetch }),
|
|
924
|
+
).resolves.toBe("REDIRECT");
|
|
925
|
+
await provider.saveDiscoveryState?.(
|
|
926
|
+
discoveryStateFor("https://moved.example.com"),
|
|
927
|
+
);
|
|
928
|
+
|
|
929
|
+
await expect(
|
|
930
|
+
auth(provider, {
|
|
931
|
+
serverUrl,
|
|
932
|
+
fetchFn: async () =>
|
|
933
|
+
new Response(
|
|
934
|
+
JSON.stringify({
|
|
935
|
+
client_id: "registered-client",
|
|
936
|
+
redirect_uris: ["http://localhost/callback"],
|
|
937
|
+
}),
|
|
938
|
+
{ status: 201, headers: { "content-type": "application/json" } },
|
|
939
|
+
),
|
|
940
|
+
}),
|
|
941
|
+
).resolves.toBe("REDIRECT");
|
|
942
|
+
|
|
943
|
+
expect(await provider.clientInformation()).toEqual({
|
|
944
|
+
client_id: "registered-client",
|
|
457
945
|
redirect_uris: ["http://localhost/callback"],
|
|
946
|
+
issuer: "https://moved.example.com",
|
|
458
947
|
});
|
|
459
|
-
|
|
460
|
-
const dynamicProvider = createProvider(storage);
|
|
461
|
-
await expect(dynamicProvider.clientInformation()).resolves.toBeUndefined();
|
|
948
|
+
expect(getState()?.clientInformation).toBeUndefined();
|
|
462
949
|
});
|
|
463
950
|
|
|
951
|
+
it.each(["client", "all"] as const)(
|
|
952
|
+
"restores the configured client through the %s invalidation scope",
|
|
953
|
+
async (scope) => {
|
|
954
|
+
const { storage } = createStorage({
|
|
955
|
+
serverUrl,
|
|
956
|
+
discoveryState: discoveryStateFor("https://auth.example.com"),
|
|
957
|
+
});
|
|
958
|
+
const provider = createStaticProvider(storage, "client-secret");
|
|
959
|
+
|
|
960
|
+
await expect(
|
|
961
|
+
auth(provider, { serverUrl, fetchFn: rejectFetch }),
|
|
962
|
+
).resolves.toBe("REDIRECT");
|
|
963
|
+
await provider.invalidateCredentials?.(scope);
|
|
964
|
+
|
|
965
|
+
expect(await provider.clientInformation()).toEqual({
|
|
966
|
+
client_id: "client-a",
|
|
967
|
+
client_secret: "client-secret",
|
|
968
|
+
redirect_uris: ["http://localhost/callback"],
|
|
969
|
+
});
|
|
970
|
+
},
|
|
971
|
+
);
|
|
972
|
+
|
|
464
973
|
it("keeps a provider built while the clear is in flight usable", async () => {
|
|
465
974
|
const { storage, getState } = createStorage();
|
|
466
975
|
let releaseClear: (() => void) | undefined;
|
|
@@ -471,7 +980,7 @@ describe("createOAuthProvider persistence across provider instances", () => {
|
|
|
471
980
|
});
|
|
472
981
|
await clearAuthState(serverId);
|
|
473
982
|
};
|
|
474
|
-
const provider =
|
|
983
|
+
const provider = createStaticProvider(storage);
|
|
475
984
|
await provider.saveTokens({
|
|
476
985
|
access_token: "access-token",
|
|
477
986
|
token_type: "bearer",
|
|
@@ -486,6 +995,6 @@ describe("createOAuthProvider persistence across provider instances", () => {
|
|
|
486
995
|
expect(getState()).toBeNull();
|
|
487
996
|
|
|
488
997
|
await replacementProvider.saveCodeVerifier("new-verifier");
|
|
489
|
-
expect(getState()).toEqual({ codeVerifier: "new-verifier" });
|
|
998
|
+
expect(getState()).toEqual({ serverUrl, codeVerifier: "new-verifier" });
|
|
490
999
|
});
|
|
491
1000
|
});
|