@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.
@@ -1,4 +1,4 @@
1
- import type { OAuthDiscoveryState } from "@modelcontextprotocol/client";
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 = createProvider(storage);
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 = createProvider(storage);
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" ? {} : { codeVerifier: "pkce-verifier" },
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 = createProvider(storage);
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 = createProvider(storage);
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 = createProvider(storage);
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 = createProvider(storage);
283
- const replacementProvider = createProvider(storage);
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 = createProvider(storage);
302
- const replacementProvider = createProvider(storage);
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 = createProvider(storage);
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 = createProvider(storage);
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 = createProvider(first.create());
398
- const secondProvider = createProvider(second.create());
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("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: () => {},
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
- await expect(staticProvider.clientInformation()).resolves.toEqual({
456
- client_id: "client-a",
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 = createProvider(storage);
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
  });