@flink-app/oidc-plugin 2.0.0-alpha.117 → 2.0.0-alpha.119

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 (42) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +296 -17
  3. package/dist/OidcPlugin.d.ts.map +1 -1
  4. package/dist/OidcPlugin.js +35 -2
  5. package/dist/OidcPluginContext.d.ts +17 -0
  6. package/dist/OidcPluginContext.d.ts.map +1 -1
  7. package/dist/OidcPluginOptions.d.ts +123 -3
  8. package/dist/OidcPluginOptions.d.ts.map +1 -1
  9. package/dist/handlers/CallbackOidc.d.ts.map +1 -1
  10. package/dist/handlers/CallbackOidc.js +97 -20
  11. package/dist/handlers/InitiateOidc.d.ts.map +1 -1
  12. package/dist/handlers/InitiateOidc.js +12 -3
  13. package/dist/index.d.ts +2 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +4 -1
  16. package/dist/providers/ProviderRegistry.d.ts +27 -4
  17. package/dist/providers/ProviderRegistry.d.ts.map +1 -1
  18. package/dist/providers/ProviderRegistry.js +77 -21
  19. package/dist/utils/error-utils.d.ts +1 -0
  20. package/dist/utils/error-utils.d.ts.map +1 -1
  21. package/dist/utils/error-utils.js +1 -0
  22. package/dist/utils/redirect-uri-utils.d.ts +38 -0
  23. package/dist/utils/redirect-uri-utils.d.ts.map +1 -0
  24. package/dist/utils/redirect-uri-utils.js +92 -0
  25. package/examples/basic-oidc.ts +4 -0
  26. package/examples/multi-provider.ts +4 -0
  27. package/package.json +7 -7
  28. package/spec/handlers/CallbackOidc.spec.ts +330 -0
  29. package/spec/handlers/InitiateOidc.spec.ts +95 -1
  30. package/spec/helpers/test-helpers.ts +68 -0
  31. package/spec/plugin/OidcPlugin.spec.ts +165 -0
  32. package/spec/providers/ProviderRegistry.spec.ts +142 -0
  33. package/spec/utils/redirect-uri-utils.spec.ts +98 -0
  34. package/src/OidcPlugin.ts +38 -1
  35. package/src/OidcPluginContext.ts +18 -0
  36. package/src/OidcPluginOptions.ts +132 -3
  37. package/src/handlers/CallbackOidc.ts +109 -20
  38. package/src/handlers/InitiateOidc.ts +14 -3
  39. package/src/index.ts +10 -1
  40. package/src/providers/ProviderRegistry.ts +95 -22
  41. package/src/utils/error-utils.ts +1 -0
  42. package/src/utils/redirect-uri-utils.ts +116 -0
@@ -5,6 +5,7 @@
5
5
  * For full integration tests, use the test-utils package in a demo app.
6
6
  */
7
7
 
8
+ import { log } from "@flink-app/flink";
8
9
  import { oidcPlugin } from "../../src/OidcPlugin";
9
10
  import { createTestProviderConfig, createPublicClientProviderConfig } from "../helpers/test-helpers";
10
11
 
@@ -212,6 +213,18 @@ describe("OidcPlugin", () => {
212
213
  expect(plugin.ctx.options).toBeDefined();
213
214
  });
214
215
 
216
+ it("should expose allowedRedirectUris on the plugin context options", () => {
217
+ const plugin = oidcPlugin({
218
+ providers: {
219
+ test: createTestProviderConfig(),
220
+ },
221
+ allowedRedirectUris: ["https://app.example.com"],
222
+ onAuthSuccess: async () => ({ user: {}, token: "", redirectUrl: "" }),
223
+ });
224
+
225
+ expect(plugin.ctx.options.allowedRedirectUris).toEqual(["https://app.example.com"]);
226
+ });
227
+
215
228
  it("should provide init function", () => {
216
229
  const plugin = oidcPlugin({
217
230
  providers: {
@@ -224,4 +237,156 @@ describe("OidcPlugin", () => {
224
237
  expect(typeof plugin.init).toBe("function");
225
238
  });
226
239
  });
240
+
241
+ describe("providerCacheTtlSec option", () => {
242
+ it("should accept a non-negative number", () => {
243
+ expect(() => {
244
+ oidcPlugin({
245
+ providers: { test: createTestProviderConfig() },
246
+ providerCacheTtlSec: 300,
247
+ onAuthSuccess: async () => ({ user: {}, token: "" }),
248
+ });
249
+ }).not.toThrow();
250
+
251
+ expect(() => {
252
+ oidcPlugin({
253
+ providers: { test: createTestProviderConfig() },
254
+ providerCacheTtlSec: 0,
255
+ onAuthSuccess: async () => ({ user: {}, token: "" }),
256
+ });
257
+ }).not.toThrow();
258
+ });
259
+
260
+ it("should throw for a negative or non-numeric value", () => {
261
+ expect(() => {
262
+ oidcPlugin({
263
+ providers: { test: createTestProviderConfig() },
264
+ providerCacheTtlSec: -1,
265
+ onAuthSuccess: async () => ({ user: {}, token: "" }),
266
+ });
267
+ }).toThrowError(/providerCacheTtlSec must be a non-negative number of seconds/);
268
+
269
+ expect(() => {
270
+ oidcPlugin({
271
+ providers: { test: createTestProviderConfig() },
272
+ providerCacheTtlSec: "300" as any,
273
+ onAuthSuccess: async () => ({ user: {}, token: "" }),
274
+ });
275
+ }).toThrowError(/providerCacheTtlSec must be a non-negative number of seconds/);
276
+ });
277
+ });
278
+
279
+ describe("invalidateProvider", () => {
280
+ /**
281
+ * Minimal app/db doubles so init() can run without MongoDB
282
+ */
283
+ function createFakeApp() {
284
+ return {
285
+ ctx: {} as any,
286
+ addRepo: () => undefined,
287
+ addHandler: () => undefined,
288
+ };
289
+ }
290
+
291
+ const fakeDb = {
292
+ collection: () => ({ createIndex: async () => undefined }),
293
+ };
294
+
295
+ it("should be exposed on the plugin context", () => {
296
+ const plugin = oidcPlugin({
297
+ providers: { test: createTestProviderConfig() },
298
+ onAuthSuccess: async () => ({ user: {}, token: "" }),
299
+ });
300
+
301
+ expect(typeof plugin.ctx.invalidateProvider).toBe("function");
302
+ });
303
+
304
+ it("should not throw when called before init", () => {
305
+ const plugin = oidcPlugin({
306
+ providers: { test: createTestProviderConfig() },
307
+ onAuthSuccess: async () => ({ user: {}, token: "" }),
308
+ });
309
+
310
+ expect(() => plugin.ctx.invalidateProvider("test")).not.toThrow();
311
+ });
312
+
313
+ it("should clear the provider cache after init", async () => {
314
+ const plugin = oidcPlugin({
315
+ providers: { test: createTestProviderConfig() },
316
+ providerCacheTtlSec: 300,
317
+ onAuthSuccess: async () => ({ user: {}, token: "" }),
318
+ });
319
+
320
+ const app = createFakeApp();
321
+ await plugin.init!(app as any, fakeDb as any);
322
+
323
+ const registry = app.ctx.oidcProviderRegistry;
324
+ expect(registry).toBeDefined();
325
+
326
+ const clearCacheSpy = spyOn(registry, "clearCache");
327
+
328
+ plugin.ctx.invalidateProvider("test");
329
+ expect(clearCacheSpy).toHaveBeenCalledWith("test");
330
+
331
+ plugin.ctx.invalidateProvider();
332
+ expect(clearCacheSpy).toHaveBeenCalledWith(undefined);
333
+ });
334
+ });
335
+
336
+ describe("allowedRedirectUris option", () => {
337
+ function warnedAboutAllowList(warnSpy: jasmine.Spy): boolean {
338
+ return warnSpy.calls.allArgs().some((args: any[]) => args.some((arg) => typeof arg === "string" && arg.includes("allowedRedirectUris is not set")));
339
+ }
340
+
341
+ it("should warn once at startup when allowedRedirectUris is not set", () => {
342
+ const warnSpy = spyOn(log, "warn");
343
+
344
+ oidcPlugin({
345
+ providers: { test: createTestProviderConfig() },
346
+ onAuthSuccess: async () => ({ user: {}, token: "" }),
347
+ });
348
+
349
+ expect(warnedAboutAllowList(warnSpy)).toBe(true);
350
+ });
351
+
352
+ it("should not warn when allowedRedirectUris is set", () => {
353
+ const warnSpy = spyOn(log, "warn");
354
+
355
+ oidcPlugin({
356
+ providers: { test: createTestProviderConfig() },
357
+ allowedRedirectUris: ["https://app.example.com"],
358
+ onAuthSuccess: async () => ({ user: {}, token: "" }),
359
+ });
360
+
361
+ expect(warnedAboutAllowList(warnSpy)).toBe(false);
362
+ });
363
+
364
+ it("should accept a predicate", () => {
365
+ expect(() => {
366
+ oidcPlugin({
367
+ providers: { test: createTestProviderConfig() },
368
+ allowedRedirectUris: (uri) => uri.startsWith("https://app.example.com"),
369
+ onAuthSuccess: async () => ({ user: {}, token: "" }),
370
+ });
371
+ }).not.toThrow();
372
+ });
373
+
374
+ it("should throw if allowedRedirectUris is not a string array or function", () => {
375
+ expect(() => {
376
+ oidcPlugin({
377
+ providers: { test: createTestProviderConfig() },
378
+ allowedRedirectUris: ["https://app.example.com", ""] as any,
379
+ onAuthSuccess: async () => ({ user: {}, token: "" }),
380
+ });
381
+ }).toThrowError(/allowedRedirectUris must be an array of non-empty strings or a predicate function/);
382
+
383
+ expect(() => {
384
+ oidcPlugin({
385
+ providers: { test: createTestProviderConfig() },
386
+ allowedRedirectUris: "https://app.example.com" as any,
387
+ onAuthSuccess: async () => ({ user: {}, token: "" }),
388
+ });
389
+ }).toThrowError(/allowedRedirectUris must be an array of non-empty strings or a predicate function/);
390
+ });
391
+ });
227
392
  });
@@ -6,6 +6,20 @@ import { ProviderRegistry } from "../../src/providers/ProviderRegistry";
6
6
  import { OidcProviderConfig } from "../../src/OidcProviderConfig";
7
7
  import { createTestProviderConfig } from "../helpers/test-helpers";
8
8
 
9
+ /**
10
+ * Provider config with manual endpoints — initializing it performs no network calls,
11
+ * so the registry's caching behaviour can be tested offline.
12
+ */
13
+ function createOfflineProviderConfig(overrides?: Partial<OidcProviderConfig>): OidcProviderConfig {
14
+ return createTestProviderConfig({
15
+ discoveryUrl: undefined,
16
+ authorizationEndpoint: "https://test-idp.example.com/authorize",
17
+ tokenEndpoint: "https://test-idp.example.com/token",
18
+ jwksUri: "https://test-idp.example.com/.well-known/jwks.json",
19
+ ...overrides,
20
+ });
21
+ }
22
+
9
23
  describe("ProviderRegistry", () => {
10
24
  let staticProviders: Record<string, OidcProviderConfig>;
11
25
 
@@ -195,4 +209,132 @@ describe("ProviderRegistry", () => {
195
209
  expect(loader).toHaveBeenCalledTimes(2);
196
210
  });
197
211
  });
212
+
213
+ describe("provider cache TTL", () => {
214
+ it("should not re-run the loader while the entry is cached (no TTL)", async () => {
215
+ const loader = jasmine.createSpy("loader").and.callFake(async () => createOfflineProviderConfig());
216
+ const registry = new ProviderRegistry({}, loader, {});
217
+
218
+ await registry.getProvider("test");
219
+ await registry.getProvider("test");
220
+
221
+ expect(loader).toHaveBeenCalledTimes(1);
222
+ });
223
+
224
+ it("should not re-run the loader before the TTL has elapsed", async () => {
225
+ const loader = jasmine.createSpy("loader").and.callFake(async () => createOfflineProviderConfig());
226
+ const registry = new ProviderRegistry({}, loader, {}, 60);
227
+
228
+ await registry.getProvider("test");
229
+ await registry.getProvider("test");
230
+
231
+ expect(loader).toHaveBeenCalledTimes(1);
232
+ });
233
+
234
+ it("should re-run the loader and use the new config once the TTL has elapsed", async () => {
235
+ let clientId = "client-v1";
236
+ const loader = jasmine.createSpy("loader").and.callFake(async () => createOfflineProviderConfig({ clientId }));
237
+
238
+ // 0.02s TTL keeps the test fast without faking timers
239
+ const registry = new ProviderRegistry({}, loader, {}, 0.02);
240
+
241
+ const first = await registry.getProvider("test");
242
+ expect(first.config.clientId).toBe("client-v1");
243
+
244
+ clientId = "client-v2";
245
+ await new Promise((resolve) => setTimeout(resolve, 30));
246
+
247
+ const second = await registry.getProvider("test");
248
+
249
+ expect(loader).toHaveBeenCalledTimes(2);
250
+ expect(second).not.toBe(first);
251
+ expect(second.config.clientId).toBe("client-v2");
252
+ });
253
+
254
+ it("should re-run discovery for static providers once the TTL has elapsed", async () => {
255
+ const staticConfig = createOfflineProviderConfig();
256
+ const registry = new ProviderRegistry({ test: staticConfig }, undefined, {}, 0.02);
257
+
258
+ const first = await registry.getProvider("test");
259
+ await new Promise((resolve) => setTimeout(resolve, 30));
260
+ const second = await registry.getProvider("test");
261
+
262
+ expect(second).not.toBe(first);
263
+ });
264
+
265
+ it("should keep the previous instance when the reload fails", async () => {
266
+ let shouldFail = false;
267
+ const loader = jasmine.createSpy("loader").and.callFake(async () => {
268
+ if (shouldFail) {
269
+ throw new Error("database unavailable");
270
+ }
271
+ return createOfflineProviderConfig();
272
+ });
273
+ const registry = new ProviderRegistry({}, loader, {}, 0.02);
274
+
275
+ const first = await registry.getProvider("test");
276
+
277
+ shouldFail = true;
278
+ await new Promise((resolve) => setTimeout(resolve, 30));
279
+
280
+ const second = await registry.getProvider("test");
281
+
282
+ expect(loader).toHaveBeenCalledTimes(2);
283
+ expect(second).toBe(first);
284
+ });
285
+
286
+ it("should throw when the first load fails and nothing is cached", async () => {
287
+ const loader = jasmine.createSpy("loader").and.callFake(async () => {
288
+ throw new Error("database unavailable");
289
+ });
290
+ const registry = new ProviderRegistry({}, loader, {}, 0.02);
291
+
292
+ await expectAsync(registry.getProvider("test")).toBeRejectedWithError(/database unavailable/);
293
+ });
294
+
295
+ it("should drop the cached instance when the provider is removed", async () => {
296
+ let exists = true;
297
+ const loader = jasmine.createSpy("loader").and.callFake(async () => (exists ? createOfflineProviderConfig() : null));
298
+ const registry = new ProviderRegistry({}, loader, {}, 0.02);
299
+
300
+ await registry.getProvider("test");
301
+
302
+ exists = false;
303
+ await new Promise((resolve) => setTimeout(resolve, 30));
304
+
305
+ try {
306
+ await registry.getProvider("test");
307
+ fail("Should have thrown error");
308
+ } catch (error: any) {
309
+ expect(error.code).toBe("provider_not_configured");
310
+ }
311
+ });
312
+ });
313
+
314
+ describe("clearCache forces a reload", () => {
315
+ it("should re-run the loader for a named provider", async () => {
316
+ const loader = jasmine.createSpy("loader").and.callFake(async () => createOfflineProviderConfig());
317
+ const registry = new ProviderRegistry({}, loader, {});
318
+
319
+ const first = await registry.getProvider("test");
320
+ registry.clearCache("test");
321
+ const second = await registry.getProvider("test");
322
+
323
+ expect(loader).toHaveBeenCalledTimes(2);
324
+ expect(second).not.toBe(first);
325
+ });
326
+
327
+ it("should re-run the loader for all providers", async () => {
328
+ const loader = jasmine.createSpy("loader").and.callFake(async () => createOfflineProviderConfig());
329
+ const registry = new ProviderRegistry({}, loader, {});
330
+
331
+ await registry.getProvider("a");
332
+ await registry.getProvider("b");
333
+ registry.clearCache();
334
+ await registry.getProvider("a");
335
+ await registry.getProvider("b");
336
+
337
+ expect(loader).toHaveBeenCalledTimes(4);
338
+ });
339
+ });
198
340
  });
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Tests for the redirectUri allow-list
3
+ */
4
+
5
+ import { isRedirectUriAllowed, isRelativeRedirect } from "../../src/utils/redirect-uri-utils";
6
+
7
+ describe("redirect-uri-utils", () => {
8
+ describe("isRelativeRedirect", () => {
9
+ it("should treat single-slash paths as relative", () => {
10
+ expect(isRelativeRedirect("/dashboard")).toBe(true);
11
+ expect(isRelativeRedirect("/login?error=x")).toBe(true);
12
+ expect(isRelativeRedirect("/")).toBe(true);
13
+ });
14
+
15
+ it("should not treat protocol-relative or backslash URLs as relative", () => {
16
+ expect(isRelativeRedirect("//evil.example/cb")).toBe(false);
17
+ expect(isRelativeRedirect("/\\evil.example/cb")).toBe(false);
18
+ });
19
+
20
+ it("should not treat absolute URLs as relative", () => {
21
+ expect(isRelativeRedirect("https://app.example.com/cb")).toBe(false);
22
+ expect(isRelativeRedirect("myapp://cb")).toBe(false);
23
+ });
24
+ });
25
+
26
+ describe("isRedirectUriAllowed", () => {
27
+ it("should allow anything when no allow-list is configured", () => {
28
+ expect(isRedirectUriAllowed("https://evil.example/cb", "acme", undefined)).toBe(true);
29
+ });
30
+
31
+ it("should match exact URIs", () => {
32
+ const allowed = ["https://app.example.com/login"];
33
+
34
+ expect(isRedirectUriAllowed("https://app.example.com/login", "acme", allowed)).toBe(true);
35
+ expect(isRedirectUriAllowed("https://app.example.com/login?next=1", "acme", allowed)).toBe(false);
36
+ expect(isRedirectUriAllowed("https://app.example.com/other", "acme", allowed)).toBe(false);
37
+ });
38
+
39
+ it("should match any path below an origin-only entry", () => {
40
+ const allowed = ["https://app.example.com"];
41
+
42
+ expect(isRedirectUriAllowed("https://app.example.com", "acme", allowed)).toBe(true);
43
+ expect(isRedirectUriAllowed("https://app.example.com/login?x=1", "acme", allowed)).toBe(true);
44
+ expect(isRedirectUriAllowed("https://app.example.com/a/b#frag", "acme", allowed)).toBe(true);
45
+ });
46
+
47
+ it("should treat a trailing slash entry as origin-only", () => {
48
+ expect(isRedirectUriAllowed("https://app.example.com/login", "acme", ["https://app.example.com/"])).toBe(true);
49
+ });
50
+
51
+ it("should not match a different origin, scheme or port", () => {
52
+ const allowed = ["https://app.example.com"];
53
+
54
+ expect(isRedirectUriAllowed("https://evil.example/cb", "acme", allowed)).toBe(false);
55
+ expect(isRedirectUriAllowed("http://app.example.com/cb", "acme", allowed)).toBe(false);
56
+ expect(isRedirectUriAllowed("https://app.example.com:8443/cb", "acme", allowed)).toBe(false);
57
+ expect(isRedirectUriAllowed("https://app.example.com.evil.example/cb", "acme", allowed)).toBe(false);
58
+ });
59
+
60
+ it("should support custom app schemes", () => {
61
+ const allowed = ["myapp://cb"];
62
+
63
+ expect(isRedirectUriAllowed("myapp://cb", "acme", allowed)).toBe(true);
64
+ expect(isRedirectUriAllowed("myapp://cb/done", "acme", allowed)).toBe(true);
65
+ expect(isRedirectUriAllowed("otherapp://cb", "acme", allowed)).toBe(false);
66
+ });
67
+
68
+ it("should support a predicate", () => {
69
+ const predicate = (redirectUri: string, provider: string) => provider === "acme" && redirectUri.endsWith(".example.com/cb");
70
+
71
+ expect(isRedirectUriAllowed("https://a.example.com/cb", "acme", predicate)).toBe(true);
72
+ expect(isRedirectUriAllowed("https://a.example.com/cb", "contoso", predicate)).toBe(false);
73
+ expect(isRedirectUriAllowed("https://evil.example/cb", "acme", predicate)).toBe(false);
74
+ });
75
+
76
+ it("should always allow relative paths", () => {
77
+ expect(isRedirectUriAllowed("/dashboard", "acme", [])).toBe(true);
78
+ expect(isRedirectUriAllowed("/dashboard", "acme", () => false)).toBe(true);
79
+ expect(isRedirectUriAllowed("//evil.example/cb", "acme", [])).toBe(false);
80
+ });
81
+
82
+ it("should always allow the always-allowed entries (e.g. provider callbackUrl)", () => {
83
+ const allowed: string[] = [];
84
+ const callbackUrl = "https://api.example.com/oidc/acme/callback";
85
+
86
+ expect(isRedirectUriAllowed(callbackUrl, "acme", allowed, [callbackUrl])).toBe(true);
87
+ expect(isRedirectUriAllowed("https://evil.example/cb", "acme", allowed, [callbackUrl])).toBe(false);
88
+ });
89
+
90
+ it("should reject an empty allow-list", () => {
91
+ expect(isRedirectUriAllowed("https://app.example.com/cb", "acme", [])).toBe(false);
92
+ });
93
+
94
+ it("should reject unparsable values", () => {
95
+ expect(isRedirectUriAllowed("not a url", "acme", ["https://app.example.com"])).toBe(false);
96
+ });
97
+ });
98
+ });
package/src/OidcPlugin.ts CHANGED
@@ -135,6 +135,26 @@ export function oidcPlugin<TCtx = any>(options: OidcPluginOptions<TCtx>): FlinkP
135
135
  throw new Error("OIDC Plugin: onAuthSuccess callback is required");
136
136
  }
137
137
 
138
+ // Redirect URI allow-list. Absent = every redirectUri is accepted (legacy behaviour),
139
+ // which lets a crafted initiate link deliver the app's JWT to any URL.
140
+ if (options.allowedRedirectUris === undefined) {
141
+ log.warn("OIDC Plugin: allowedRedirectUris is not set — any redirectUri passed to /initiate is accepted. Set it to the URLs your clients use.");
142
+ } else if (typeof options.allowedRedirectUris !== "function") {
143
+ if (!Array.isArray(options.allowedRedirectUris) || options.allowedRedirectUris.some((entry) => typeof entry !== "string" || !entry)) {
144
+ throw new Error("OIDC Plugin: allowedRedirectUris must be an array of non-empty strings or a predicate function");
145
+ }
146
+ oidcLog.debug(`Redirect URI allow-list: [${options.allowedRedirectUris.join(", ")}]`);
147
+ } else {
148
+ oidcLog.debug("Redirect URI allow-list: predicate function");
149
+ }
150
+
151
+ // Provider cache TTL (0 = cache for the life of the process, unchanged behaviour)
152
+ const providerCacheTtlSec = options.providerCacheTtlSec ?? 0;
153
+
154
+ if (typeof providerCacheTtlSec !== "number" || !Number.isFinite(providerCacheTtlSec) || providerCacheTtlSec < 0) {
155
+ throw new Error("OIDC Plugin: providerCacheTtlSec must be a non-negative number of seconds");
156
+ }
157
+
138
158
  // Determine encryption key
139
159
  let encryptionKey = options.encryptionKey;
140
160
  if (!encryptionKey) {
@@ -195,6 +215,7 @@ export function oidcPlugin<TCtx = any>(options: OidcPluginOptions<TCtx>): FlinkP
195
215
  `connectionsCollection=${connectionsCollectionName}`,
196
216
  `storeTokens=${!!options.storeTokens}`,
197
217
  `sessionTTL=${options.sessionTTL ?? 600}s`,
218
+ `providerCacheTtl=${providerCacheTtlSec > 0 ? `${providerCacheTtlSec}s` : "forever"}`,
198
219
  `registerRoutes=${options.registerRoutes !== false}`,
199
220
  configuredProviders.length > 0 ? `staticProviders=[${configuredProviders.join(", ")}]` : "staticProviders=[] (dynamic only)",
200
221
  options.providerLoader ? "providerLoader=yes" : "providerLoader=no"
@@ -213,7 +234,7 @@ export function oidcPlugin<TCtx = any>(options: OidcPluginOptions<TCtx>): FlinkP
213
234
  log.info(`OIDC Plugin: Created TTL index on ${sessionsCollectionName} with ${sessionTTL}s expiration`);
214
235
 
215
236
  // Initialize provider registry
216
- providerRegistry = new ProviderRegistry(providers, options.providerLoader, flinkApp.ctx);
237
+ providerRegistry = new ProviderRegistry(providers, options.providerLoader, flinkApp.ctx, providerCacheTtlSec);
217
238
 
218
239
  // Store provider registry in app context for handlers to access
219
240
  (flinkApp.ctx as any).oidcProviderRegistry = providerRegistry;
@@ -302,6 +323,21 @@ export function oidcPlugin<TCtx = any>(options: OidcPluginOptions<TCtx>): FlinkP
302
323
  log.info(`OIDC Plugin: Deleted ${provider} connection for user ${userId}`);
303
324
  }
304
325
 
326
+ /**
327
+ * Drop cached provider instance(s) so the next request re-runs providerLoader
328
+ * and OIDC discovery
329
+ */
330
+ function invalidateProvider(providerName?: string): void {
331
+ if (!providerRegistry) {
332
+ // Nothing is cached before init, so there is nothing to invalidate
333
+ oidcLog.debug("invalidateProvider called before plugin init, nothing to do");
334
+ return;
335
+ }
336
+
337
+ providerRegistry.clearCache(providerName);
338
+ log.info(`OIDC Plugin: invalidated provider cache for ${providerName ? `"${providerName}"` : "all providers"}`);
339
+ }
340
+
305
341
  /**
306
342
  * Plugin context exposed via ctx.plugins.oidc
307
343
  */
@@ -309,6 +345,7 @@ export function oidcPlugin<TCtx = any>(options: OidcPluginOptions<TCtx>): FlinkP
309
345
  getConnection,
310
346
  getConnections,
311
347
  deleteConnection,
348
+ invalidateProvider,
312
349
  options: Object.freeze({ ...options }),
313
350
  };
314
351
 
@@ -67,6 +67,24 @@ export interface OidcPluginContext {
67
67
  */
68
68
  deleteConnection: (userId: string, provider: string) => Promise<void>;
69
69
 
70
+ /**
71
+ * Drop cached provider instance(s) so the next request re-runs `providerLoader`
72
+ * and OIDC discovery for them.
73
+ *
74
+ * Call this when provider configuration changes at runtime, e.g. from the handler
75
+ * where an admin saves IdP settings. Only affects the instance it runs on — with
76
+ * `providerCacheTtlSec` set, other instances converge within the TTL.
77
+ *
78
+ * @param providerName - Provider to invalidate (all providers if omitted)
79
+ *
80
+ * Example:
81
+ * ```typescript
82
+ * await ctx.repos.oidcProviderRepo.updateById(id, { clientSecret });
83
+ * ctx.plugins.oidc.invalidateProvider(providerName);
84
+ * ```
85
+ */
86
+ invalidateProvider: (providerName?: string) => void;
87
+
70
88
  /**
71
89
  * Plugin configuration options (read-only)
72
90
  * Useful for checking plugin settings at runtime