@assistant-ui/react-mcp 0.1.15 → 0.1.16

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 (39) hide show
  1. package/dist/auth/createOAuthProvider.d.ts +7 -1
  2. package/dist/auth/createOAuthProvider.d.ts.map +1 -1
  3. package/dist/auth/createOAuthProvider.js +115 -31
  4. package/dist/auth/createOAuthProvider.js.map +1 -1
  5. package/dist/auth/types.d.ts +1 -0
  6. package/dist/auth/types.d.ts.map +1 -1
  7. package/dist/hooks/useMcpOAuthCallback.d.ts.map +1 -1
  8. package/dist/hooks/useMcpOAuthCallback.js +5 -6
  9. package/dist/hooks/useMcpOAuthCallback.js.map +1 -1
  10. package/dist/resources/McpManagerResource.d.ts.map +1 -1
  11. package/dist/resources/McpManagerResource.js +2 -1
  12. package/dist/resources/McpManagerResource.js.map +1 -1
  13. package/dist/resources/McpServerResource.d.ts +2 -1
  14. package/dist/resources/McpServerResource.d.ts.map +1 -1
  15. package/dist/resources/McpServerResource.js +50 -11
  16. package/dist/resources/McpServerResource.js.map +1 -1
  17. package/dist/resources/storage/McpLocalStorage.d.ts +7 -0
  18. package/dist/resources/storage/McpLocalStorage.d.ts.map +1 -1
  19. package/dist/resources/storage/McpLocalStorage.js +132 -37
  20. package/dist/resources/storage/McpLocalStorage.js.map +1 -1
  21. package/dist/resources/storage/McpMemoryStorage.d.ts.map +1 -1
  22. package/dist/resources/storage/McpMemoryStorage.js +9 -6
  23. package/dist/resources/storage/McpMemoryStorage.js.map +1 -1
  24. package/dist/resources/storage/types.d.ts +12 -0
  25. package/dist/resources/storage/types.d.ts.map +1 -1
  26. package/package.json +7 -7
  27. package/src/auth/createOAuthProvider.test.ts +407 -2
  28. package/src/auth/createOAuthProvider.ts +171 -40
  29. package/src/auth/types.ts +1 -0
  30. package/src/hooks/useMcpOAuthCallback.test.ts +74 -1
  31. package/src/hooks/useMcpOAuthCallback.tsx +11 -8
  32. package/src/resources/McpManagerResource.ts +2 -1
  33. package/src/resources/McpServerResource.test.ts +377 -16
  34. package/src/resources/McpServerResource.ts +61 -10
  35. package/src/resources/storage/McpLocalStorage.test.ts +71 -1
  36. package/src/resources/storage/McpLocalStorage.ts +69 -47
  37. package/src/resources/storage/McpMemoryStorage.test.ts +99 -0
  38. package/src/resources/storage/McpMemoryStorage.ts +23 -17
  39. package/src/resources/storage/types.ts +12 -0
@@ -1 +1 @@
1
- {"version":3,"file":"McpMemoryStorage.d.ts","names":[],"sources":["../../../src/resources/storage/McpMemoryStorage.ts"],"mappings":";;cAuBa,8CAAgB,SAAA"}
1
+ {"version":3,"file":"McpMemoryStorage.d.ts","names":[],"sources":["../../../src/resources/storage/McpMemoryStorage.ts"],"mappings":";;cA6Ba,8CAAgB,SAAA"}
@@ -1,22 +1,25 @@
1
+ import { useMemo } from "@assistant-ui/tap/react-shim";
1
2
  import { resource } from "@assistant-ui/tap";
3
+ import { generateId } from "@assistant-ui/core";
2
4
  //#region src/resources/storage/McpMemoryStorage.ts
3
- const useMcpMemoryStorage = () => {
5
+ const useMcpMemoryStorage = () => useMemo(() => {
4
6
  let servers = [];
5
7
  const auth = /* @__PURE__ */ new Map();
6
8
  return {
9
+ scopeId: `memory:${generateId()}`,
7
10
  loadCustomServers: async () => [...servers],
8
11
  saveCustomServers: async (records) => {
9
12
  servers = [...records];
10
13
  },
11
14
  loadAuthState: async (id) => auth.get(id) ?? null,
12
- saveAuthState: async (id, state) => {
13
- auth.set(id, state);
15
+ saveAuthState: async (id_0, state) => {
16
+ auth.set(id_0, state);
14
17
  },
15
- clearAuthState: async (id) => {
16
- auth.delete(id);
18
+ clearAuthState: async (id_1) => {
19
+ auth.delete(id_1);
17
20
  }
18
21
  };
19
- };
22
+ }, []);
20
23
  const McpMemoryStorage = resource(useMcpMemoryStorage);
21
24
  //#endregion
22
25
  export { McpMemoryStorage };
@@ -1 +1 @@
1
- {"version":3,"file":"McpMemoryStorage.js","names":["resource","MCPCustomServerRecord","MCPPersistedAuthState","MCPStorage","useMcpMemoryStorage","servers","auth","Map","loadCustomServers","saveCustomServers","records","loadAuthState","id","get","saveAuthState","state","set","clearAuthState","delete","McpMemoryStorage"],"sources":["../../../src/resources/storage/McpMemoryStorage.ts"],"sourcesContent":["import { resource } from \"@assistant-ui/tap\";\nimport type { MCPCustomServerRecord } from \"../../mcp-scope\";\nimport type { MCPPersistedAuthState } from \"../../auth/types\";\nimport type { MCPStorage } from \"./types\";\n\nconst useMcpMemoryStorage = (): MCPStorage => {\n let servers: MCPCustomServerRecord[] = [];\n const auth = new Map<string, MCPPersistedAuthState>();\n return {\n loadCustomServers: async () => [...servers],\n saveCustomServers: async (records) => {\n servers = [...records];\n },\n loadAuthState: async (id) => auth.get(id) ?? null,\n saveAuthState: async (id, state) => {\n auth.set(id, state);\n },\n clearAuthState: async (id) => {\n auth.delete(id);\n },\n };\n};\n\nexport const McpMemoryStorage = resource(useMcpMemoryStorage);\n"],"mappings":";;AAKA,MAAMI,4BAAwC;CAC5C,IAAIC,UAAmC,CAAA;CACvC,MAAMC,uBAAO,IAAIC,IAAmC;CACpD,OAAO;EACLC,mBAAmB,YAAY,CAAC,GAAGH,OAAO;EAC1CI,mBAAmB,OAAOC,YAAY;GACpCL,UAAU,CAAC,GAAGK,OAAO;EACvB;EACAC,eAAe,OAAOC,OAAON,KAAKO,IAAID,EAAE,KAAK;EAC7CE,eAAe,OAAOF,IAAIG,UAAU;GAClCT,KAAKU,IAAIJ,IAAIG,KAAK;EACpB;EACAE,gBAAgB,OAAOL,OAAO;GAC5BN,KAAKY,OAAON,EAAE;EAChB;CACF;AACF;AAEA,MAAaO,mBAAmBnB,SAASI,mBAAmB"}
1
+ {"version":3,"file":"McpMemoryStorage.js","names":["resource","useMemo","generateId","MCPCustomServerRecord","MCPPersistedAuthState","MCPStorage","useMcpMemoryStorage","servers","auth","Map","scopeId","loadCustomServers","saveCustomServers","records","loadAuthState","id","get","saveAuthState","state","set","clearAuthState","delete","McpMemoryStorage"],"sources":["../../../src/resources/storage/McpMemoryStorage.ts"],"sourcesContent":["import { resource } from \"@assistant-ui/tap\";\nimport { useMemo } from \"react\";\nimport { generateId } from \"@assistant-ui/core\";\nimport type { MCPCustomServerRecord } from \"../../mcp-scope\";\nimport type { MCPPersistedAuthState } from \"../../auth/types\";\nimport type { MCPStorage } from \"./types\";\n\nconst useMcpMemoryStorage = (): MCPStorage =>\n useMemo(() => {\n let servers: MCPCustomServerRecord[] = [];\n const auth = new Map<string, MCPPersistedAuthState>();\n return {\n // Each memory store is its own private data: a distinct instance is a\n // distinct scope, so replacing one keys a reconnect.\n scopeId: `memory:${generateId()}`,\n loadCustomServers: async () => [...servers],\n saveCustomServers: async (records) => {\n servers = [...records];\n },\n loadAuthState: async (id) => auth.get(id) ?? null,\n saveAuthState: async (id, state) => {\n auth.set(id, state);\n },\n clearAuthState: async (id) => {\n auth.delete(id);\n },\n };\n }, []);\n\nexport const McpMemoryStorage = resource(useMcpMemoryStorage);\n"],"mappings":";;;;AAOA,MAAMM,4BACJL,cAAc;CACZ,IAAIM,UAAmC,CAAA;CACvC,MAAMC,uBAAO,IAAIC,IAAmC;CACpD,OAAO;EAGLC,SAAS,UAAUR,WAAW;EAC9BS,mBAAmB,YAAY,CAAC,GAAGJ,OAAO;EAC1CK,mBAAmB,OAAOC,YAAY;GACpCN,UAAU,CAAC,GAAGM,OAAO;EACvB;EACAC,eAAe,OAAOC,OAAOP,KAAKQ,IAAID,EAAE,KAAK;EAC7CE,eAAe,OAAOF,MAAIG,UAAU;GAClCV,KAAKW,IAAIJ,MAAIG,KAAK;EACpB;EACAE,gBAAgB,OAAOL,SAAO;GAC5BP,KAAKa,OAAON,IAAE;EAChB;CACF;AACF,GAAG,CAAA,CAAE;AAEP,MAAaO,mBAAmBtB,SAASM,mBAAmB"}
@@ -3,6 +3,18 @@ import { MCPPersistedAuthState } from "../../auth/types.js";
3
3
  import { ResourceElement } from "@assistant-ui/tap";
4
4
  //#region src/resources/storage/types.d.ts
5
5
  type MCPStorage = {
6
+ /**
7
+ * Stable identity of the backing store. Two storages with the same scopeId
8
+ * must read and write the same persisted data. When present, server
9
+ * connections and the OAuth write fence key on it, so swapping to a
10
+ * differently-scoped storage reconnects instead of leaving a live OAuth flow
11
+ * on the replaced store, and clearing through a same-scoped replacement still
12
+ * waits for writes queued against the storage it replaced. When absent, the
13
+ * fence falls back to object identity while connections never re-key at all,
14
+ * so a storage rebuilt on every render has to declare a scopeId; without one
15
+ * a clear runs unfenced against the writes queued by the object it replaced.
16
+ */
17
+ scopeId?: string;
6
18
  loadCustomServers: () => Promise<MCPCustomServerRecord[]>;
7
19
  saveCustomServers: (records: MCPCustomServerRecord[]) => Promise<void>;
8
20
  loadAuthState: (serverId: string) => Promise<MCPPersistedAuthState | null>;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","names":[],"sources":["../../../src/resources/storage/types.ts"],"mappings":";;;;KAIY;EACV,yBAAyB,QAAQ;EACjC,oBAAoB,SAAS,4BAA4B;EACzD,gBAAgB,qBAAqB,QAAQ;EAC7C,gBACE,kBACA,OAAO,0BACJ;EACL,iBAAiB,qBAAqB;;KAG5B,oBAAoB,gBAAgB"}
1
+ {"version":3,"file":"types.d.ts","names":[],"sources":["../../../src/resources/storage/types.ts"],"mappings":";;;;KAIY;;;;;;;;;;;;EAYV;EACA,yBAAyB,QAAQ;EACjC,oBAAoB,SAAS,4BAA4B;EACzD,gBAAgB,qBAAqB,QAAQ;EAC7C,gBACE,kBACA,OAAO,0BACJ;EACL,iBAAiB,qBAAqB;;KAG5B,oBAAoB,gBAAgB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assistant-ui/react-mcp",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "MCP server configuration and connection primitives for @assistant-ui",
5
5
  "keywords": [
6
6
  "mcp",
@@ -27,13 +27,13 @@
27
27
  ],
28
28
  "sideEffects": false,
29
29
  "dependencies": {
30
- "@assistant-ui/core": "0.3.16",
31
- "@assistant-ui/store": "0.3.11",
32
- "@assistant-ui/tap": "0.9.15",
30
+ "@assistant-ui/core": "^0.3.17",
31
+ "@assistant-ui/store": "^0.3.12",
32
+ "@assistant-ui/tap": "^0.9.16",
33
33
  "@modelcontextprotocol/client": "^2.0.0",
34
34
  "@modelcontextprotocol/core": "^2.0.0",
35
35
  "@radix-ui/react-primitive": "^2.1.10",
36
- "assistant-stream": "0.3.40"
36
+ "assistant-stream": "^0.3.41"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@types/react": "*",
@@ -45,9 +45,9 @@
45
45
  }
46
46
  },
47
47
  "devDependencies": {
48
- "@assistant-ui/x-buildutils": "0.0.24",
48
+ "@assistant-ui/x-buildutils": "0.0.25",
49
49
  "@testing-library/dom": "^10.4.1",
50
- "@testing-library/react": "^16.3.2",
50
+ "@testing-library/react": "^16.3.3",
51
51
  "@types/react": "^19.2.18",
52
52
  "jsdom": "^30.0.1",
53
53
  "react": "^19.2.8",
@@ -1,8 +1,11 @@
1
1
  import type { OAuthDiscoveryState } from "@modelcontextprotocol/client";
2
- import { describe, expect, it } from "vitest";
2
+ import { describe, expect, it, vi } from "vitest";
3
3
  import type { MCPStorage } from "../resources/storage/types";
4
4
  import type { MCPPersistedAuthState } from "./types";
5
- import { createOAuthProvider } from "./createOAuthProvider";
5
+ import {
6
+ clearOAuthProviderAuthState,
7
+ createOAuthProvider,
8
+ } from "./createOAuthProvider";
6
9
 
7
10
  const discoveryState: OAuthDiscoveryState = {
8
11
  authorizationServerUrl: "https://auth.example.com",
@@ -36,6 +39,23 @@ const createStorage = (initial: MCPPersistedAuthState | null = null) => {
36
39
  return { storage, getState: () => state };
37
40
  };
38
41
 
42
+ const createSharedStorages = (scopeId: string) => {
43
+ let state: MCPPersistedAuthState | null = null;
44
+ const create = (): MCPStorage => ({
45
+ scopeId,
46
+ loadCustomServers: async () => [],
47
+ saveCustomServers: async () => {},
48
+ loadAuthState: async () => state,
49
+ saveAuthState: async (_serverId, next) => {
50
+ state = next;
51
+ },
52
+ clearAuthState: async () => {
53
+ state = null;
54
+ },
55
+ });
56
+ return { create, getState: () => state };
57
+ };
58
+
39
59
  const createProvider = (storage: MCPStorage) =>
40
60
  createOAuthProvider({
41
61
  serverId: "docs",
@@ -45,6 +65,55 @@ const createProvider = (storage: MCPStorage) =>
45
65
  onAuthorizationUrl: () => {},
46
66
  });
47
67
 
68
+ describe("createOAuthProvider callback state", () => {
69
+ it("persists the generated state with the PKCE verifier", async () => {
70
+ const { storage, getState } = createStorage();
71
+ const provider = createProvider(storage);
72
+
73
+ const state = await provider.state?.();
74
+ await provider.saveCodeVerifier("pkce-verifier");
75
+
76
+ expect(state).toMatch(/^aui-mcp:ZG9jcw\./);
77
+ expect(getState()).toEqual({
78
+ codeVerifier: "pkce-verifier",
79
+ state,
80
+ });
81
+ });
82
+
83
+ it("consumes callback state when tokens are saved", async () => {
84
+ const { storage, getState } = createStorage({
85
+ codeVerifier: "pkce-verifier",
86
+ state: "aui-mcp:ZG9jcw.nonce",
87
+ });
88
+ const provider = createProvider(storage);
89
+
90
+ await provider.saveTokens({
91
+ access_token: "access-token",
92
+ token_type: "bearer",
93
+ });
94
+
95
+ expect(getState()).toEqual({
96
+ tokens: { access_token: "access-token", token_type: "bearer" },
97
+ codeVerifier: "pkce-verifier",
98
+ });
99
+ });
100
+
101
+ it.each(["verifier", "all"] as const)(
102
+ "clears callback state through the %s invalidation scope",
103
+ async (scope) => {
104
+ const { storage, getState } = createStorage({
105
+ codeVerifier: "pkce-verifier",
106
+ state: "aui-mcp:ZG9jcw.nonce",
107
+ });
108
+ const provider = createProvider(storage);
109
+
110
+ await provider.invalidateCredentials?.(scope);
111
+
112
+ expect(getState()).toEqual({});
113
+ },
114
+ );
115
+ });
116
+
48
117
  describe("createOAuthProvider discovery state", () => {
49
118
  it("persists discovery state alongside the PKCE verifier", async () => {
50
119
  const { storage, getState } = createStorage({
@@ -84,3 +153,339 @@ describe("createOAuthProvider discovery state", () => {
84
153
  },
85
154
  );
86
155
  });
156
+
157
+ describe("createOAuthProvider persistence", () => {
158
+ it("loads persisted auth state once for concurrent reads", async () => {
159
+ let resolveLoad!: (value: MCPPersistedAuthState | null) => void;
160
+ const loadAuthState = vi.fn(
161
+ () =>
162
+ new Promise<MCPPersistedAuthState | null>((resolve) => {
163
+ resolveLoad = resolve;
164
+ }),
165
+ );
166
+ const { storage } = createStorage();
167
+ storage.loadAuthState = loadAuthState;
168
+ const provider = createProvider(storage);
169
+
170
+ const tokens = provider.tokens();
171
+ const clientInformation = provider.clientInformation();
172
+
173
+ expect(loadAuthState).toHaveBeenCalledTimes(1);
174
+ resolveLoad(null);
175
+ await Promise.all([tokens, clientInformation]);
176
+ });
177
+
178
+ it("retries loading persisted auth state after a failure", async () => {
179
+ const failure = new Error("storage unavailable");
180
+ const loadAuthState = vi
181
+ .fn<() => Promise<MCPPersistedAuthState | null>>()
182
+ .mockRejectedValueOnce(failure)
183
+ .mockResolvedValueOnce({ codeVerifier: "pkce-verifier" });
184
+ const { storage } = createStorage();
185
+ storage.loadAuthState = loadAuthState;
186
+ const provider = createProvider(storage);
187
+
188
+ const tokens = provider.tokens();
189
+ const clientInformation = provider.clientInformation();
190
+
191
+ await expect(tokens).rejects.toBe(failure);
192
+ await expect(clientInformation).rejects.toBe(failure);
193
+ expect(loadAuthState).toHaveBeenCalledTimes(1);
194
+
195
+ await expect(provider.codeVerifier()).resolves.toBe("pkce-verifier");
196
+ expect(loadAuthState).toHaveBeenCalledTimes(2);
197
+ });
198
+
199
+ it("serializes writes so newer auth state is not overwritten", async () => {
200
+ const { storage } = createStorage();
201
+ const pendingWrites: Array<() => void> = [];
202
+ let persisted: MCPPersistedAuthState | null = null;
203
+ storage.saveAuthState = async (_serverId, next) => {
204
+ await new Promise<void>((resolve) => pendingWrites.push(resolve));
205
+ persisted = next;
206
+ };
207
+ const provider = createProvider(storage);
208
+ await provider.tokens();
209
+
210
+ const tokenSave = provider.saveTokens({
211
+ access_token: "access-token",
212
+ token_type: "bearer",
213
+ });
214
+ await vi.waitFor(() => expect(pendingWrites).toHaveLength(1));
215
+
216
+ const verifierSave = provider.saveCodeVerifier("pkce-verifier");
217
+ for (let i = 0; i < 10; i++) await Promise.resolve();
218
+ expect(pendingWrites).toHaveLength(1);
219
+
220
+ pendingWrites.shift()!();
221
+ await vi.waitFor(() => expect(pendingWrites).toHaveLength(1));
222
+ pendingWrites.shift()!();
223
+ await Promise.all([tokenSave, verifierSave]);
224
+
225
+ expect(persisted).toEqual({
226
+ tokens: { access_token: "access-token", token_type: "bearer" },
227
+ codeVerifier: "pkce-verifier",
228
+ });
229
+ });
230
+
231
+ it("continues persisting after a failed auth state write", async () => {
232
+ const { storage } = createStorage();
233
+ const failure = new Error("storage unavailable");
234
+ let saveCount = 0;
235
+ let persisted: MCPPersistedAuthState | null = null;
236
+ let rejectFirstSave!: (reason: unknown) => void;
237
+ storage.saveAuthState = async (_serverId, next) => {
238
+ saveCount += 1;
239
+ if (saveCount === 1) {
240
+ await new Promise<void>((_resolve, reject) => {
241
+ rejectFirstSave = reject;
242
+ });
243
+ }
244
+ persisted = next;
245
+ };
246
+ const provider = createProvider(storage);
247
+ await provider.tokens();
248
+
249
+ const tokenSave = provider.saveTokens({
250
+ access_token: "access-token",
251
+ token_type: "bearer",
252
+ });
253
+ const verifierSave = provider.saveCodeVerifier("pkce-verifier");
254
+
255
+ await vi.waitFor(() => expect(saveCount).toBe(1));
256
+ for (let i = 0; i < 10; i++) await Promise.resolve();
257
+ expect(saveCount).toBe(1);
258
+
259
+ const tokenSaveResult = expect(tokenSave).rejects.toBe(failure);
260
+ rejectFirstSave(failure);
261
+ await tokenSaveResult;
262
+ await expect(verifierSave).resolves.toBeUndefined();
263
+ expect(saveCount).toBe(2);
264
+ expect(persisted).toEqual({
265
+ tokens: { access_token: "access-token", token_type: "bearer" },
266
+ codeVerifier: "pkce-verifier",
267
+ });
268
+ });
269
+ });
270
+
271
+ describe("createOAuthProvider persistence across provider instances", () => {
272
+ it("shares one auth state load across provider instances", async () => {
273
+ let resolveLoad!: (value: MCPPersistedAuthState | null) => void;
274
+ const loadAuthState = vi.fn(
275
+ () =>
276
+ new Promise<MCPPersistedAuthState | null>((resolve) => {
277
+ resolveLoad = resolve;
278
+ }),
279
+ );
280
+ const { storage } = createStorage();
281
+ storage.loadAuthState = loadAuthState;
282
+ const provider = createProvider(storage);
283
+ const replacementProvider = createProvider(storage);
284
+
285
+ const tokens = provider.tokens();
286
+ const clientInformation = replacementProvider.clientInformation();
287
+
288
+ expect(loadAuthState).toHaveBeenCalledTimes(1);
289
+ resolveLoad(null);
290
+ await Promise.all([tokens, clientInformation]);
291
+ });
292
+
293
+ it("serializes writes across provider instances", async () => {
294
+ const { storage } = createStorage();
295
+ const pendingWrites: Array<() => void> = [];
296
+ let persisted: MCPPersistedAuthState | null = null;
297
+ storage.saveAuthState = async (_serverId, next) => {
298
+ await new Promise<void>((resolve) => pendingWrites.push(resolve));
299
+ persisted = next;
300
+ };
301
+ const provider = createProvider(storage);
302
+ const replacementProvider = createProvider(storage);
303
+ await Promise.all([provider.tokens(), replacementProvider.tokens()]);
304
+
305
+ const tokenSave = provider.saveTokens({
306
+ access_token: "access-token",
307
+ token_type: "bearer",
308
+ });
309
+ await vi.waitFor(() => expect(pendingWrites).toHaveLength(1));
310
+
311
+ const verifierSave = replacementProvider.saveCodeVerifier("pkce-verifier");
312
+ for (let i = 0; i < 10; i++) await Promise.resolve();
313
+ expect(pendingWrites).toHaveLength(1);
314
+
315
+ pendingWrites.shift()!();
316
+ await vi.waitFor(() => expect(pendingWrites).toHaveLength(1));
317
+ pendingWrites.shift()!();
318
+ await Promise.all([tokenSave, verifierSave]);
319
+
320
+ expect(persisted).toEqual({
321
+ tokens: { access_token: "access-token", token_type: "bearer" },
322
+ codeVerifier: "pkce-verifier",
323
+ });
324
+ });
325
+
326
+ it("clears after a pending write and fences the discarded provider", async () => {
327
+ const { storage, getState } = createStorage();
328
+ const pendingWrites: Array<() => void> = [];
329
+ const saveAuthState = storage.saveAuthState;
330
+ storage.saveAuthState = async (serverId, next) => {
331
+ await new Promise<void>((resolve) => pendingWrites.push(resolve));
332
+ await saveAuthState(serverId, next);
333
+ };
334
+ const clearAuthState = vi.spyOn(storage, "clearAuthState");
335
+ const provider = createProvider(storage);
336
+ await provider.tokens();
337
+
338
+ const save = provider.saveTokens({
339
+ access_token: "access-token",
340
+ token_type: "bearer",
341
+ });
342
+ await vi.waitFor(() => expect(pendingWrites).toHaveLength(1));
343
+
344
+ const clear = clearOAuthProviderAuthState(storage, "docs");
345
+ for (let i = 0; i < 10; i++) await Promise.resolve();
346
+ expect(clearAuthState).not.toHaveBeenCalled();
347
+
348
+ pendingWrites.shift()!();
349
+ await expect(save).resolves.toBeUndefined();
350
+ await clear;
351
+ expect(clearAuthState).toHaveBeenCalledTimes(1);
352
+ expect(getState()).toBeNull();
353
+
354
+ storage.saveAuthState = saveAuthState;
355
+ await provider.saveCodeVerifier("late-verifier");
356
+ expect(getState()).toBeNull();
357
+ });
358
+
359
+ it("fences a queued write when clearing through a same-scope storage", async () => {
360
+ const { create, getState } = createSharedStorages("same-scope-clear");
361
+ const storage = create();
362
+ const replacement = create();
363
+ const pendingWrites: Array<() => void> = [];
364
+ const saveAuthState = storage.saveAuthState;
365
+ storage.saveAuthState = async (serverId, next) => {
366
+ await new Promise<void>((resolve) => pendingWrites.push(resolve));
367
+ await saveAuthState(serverId, next);
368
+ };
369
+ const clearAuthState = vi.spyOn(replacement, "clearAuthState");
370
+ const provider = createProvider(storage);
371
+ await provider.tokens();
372
+
373
+ const save = provider.saveTokens({
374
+ access_token: "access-token",
375
+ token_type: "bearer",
376
+ });
377
+ await vi.waitFor(() => expect(pendingWrites).toHaveLength(1));
378
+
379
+ const clear = clearOAuthProviderAuthState(replacement, "docs");
380
+ for (let i = 0; i < 10; i++) await Promise.resolve();
381
+ expect(clearAuthState).not.toHaveBeenCalled();
382
+
383
+ pendingWrites.shift()!();
384
+ await expect(save).resolves.toBeUndefined();
385
+ await clear;
386
+ expect(clearAuthState).toHaveBeenCalledTimes(1);
387
+ expect(getState()).toBeNull();
388
+
389
+ storage.saveAuthState = saveAuthState;
390
+ await provider.saveCodeVerifier("late-verifier");
391
+ expect(getState()).toBeNull();
392
+ });
393
+
394
+ it("keeps differently-scoped storages on separate persistence", async () => {
395
+ const first = createSharedStorages("scope-a");
396
+ const second = createSharedStorages("scope-b");
397
+ const firstProvider = createProvider(first.create());
398
+ const secondProvider = createProvider(second.create());
399
+
400
+ await firstProvider.saveTokens({
401
+ access_token: "first-token",
402
+ token_type: "bearer",
403
+ });
404
+ await secondProvider.saveTokens({
405
+ access_token: "second-token",
406
+ token_type: "bearer",
407
+ });
408
+ await clearOAuthProviderAuthState(second.create(), "docs");
409
+ await firstProvider.saveCodeVerifier("first-verifier");
410
+
411
+ expect(first.getState()).toEqual({
412
+ tokens: { access_token: "first-token", token_type: "bearer" },
413
+ codeVerifier: "first-verifier",
414
+ });
415
+ expect(second.getState()).toBeNull();
416
+ });
417
+
418
+ it("re-derives static client information for a replacement provider", async () => {
419
+ const { storage } = createStorage();
420
+ const provider = createOAuthProvider({
421
+ serverId: "docs",
422
+ config: { type: "oauth", clientId: "client-a" },
423
+ storage,
424
+ redirectUri: "http://localhost/callback",
425
+ onAuthorizationUrl: () => {},
426
+ });
427
+ await expect(provider.clientInformation()).resolves.toEqual({
428
+ client_id: "client-a",
429
+ redirect_uris: ["http://localhost/callback"],
430
+ });
431
+
432
+ const replacementProvider = createOAuthProvider({
433
+ serverId: "docs",
434
+ config: { type: "oauth", clientId: "client-b", clientSecret: "secret-b" },
435
+ storage,
436
+ redirectUri: "http://localhost/callback-2",
437
+ onAuthorizationUrl: () => {},
438
+ });
439
+ await expect(replacementProvider.clientInformation()).resolves.toEqual({
440
+ client_id: "client-b",
441
+ client_secret: "secret-b",
442
+ redirect_uris: ["http://localhost/callback-2"],
443
+ });
444
+ });
445
+
446
+ it("does not leak static client information to a dynamic provider", async () => {
447
+ const { storage } = createStorage();
448
+ const staticProvider = createOAuthProvider({
449
+ serverId: "docs",
450
+ config: { type: "oauth", clientId: "client-a" },
451
+ storage,
452
+ redirectUri: "http://localhost/callback",
453
+ onAuthorizationUrl: () => {},
454
+ });
455
+ await expect(staticProvider.clientInformation()).resolves.toEqual({
456
+ client_id: "client-a",
457
+ redirect_uris: ["http://localhost/callback"],
458
+ });
459
+
460
+ const dynamicProvider = createProvider(storage);
461
+ await expect(dynamicProvider.clientInformation()).resolves.toBeUndefined();
462
+ });
463
+
464
+ it("keeps a provider built while the clear is in flight usable", async () => {
465
+ const { storage, getState } = createStorage();
466
+ let releaseClear: (() => void) | undefined;
467
+ const clearAuthState = storage.clearAuthState;
468
+ storage.clearAuthState = async (serverId) => {
469
+ await new Promise<void>((resolve) => {
470
+ releaseClear = resolve;
471
+ });
472
+ await clearAuthState(serverId);
473
+ };
474
+ const provider = createProvider(storage);
475
+ await provider.saveTokens({
476
+ access_token: "access-token",
477
+ token_type: "bearer",
478
+ });
479
+
480
+ const clear = clearOAuthProviderAuthState(storage, "docs");
481
+ await vi.waitFor(() => expect(releaseClear).toBeTypeOf("function"));
482
+
483
+ const replacementProvider = createProvider(storage);
484
+ releaseClear!();
485
+ await clear;
486
+ expect(getState()).toBeNull();
487
+
488
+ await replacementProvider.saveCodeVerifier("new-verifier");
489
+ expect(getState()).toEqual({ codeVerifier: "new-verifier" });
490
+ });
491
+ });