@flink-app/oidc-plugin 2.0.0-alpha.117 → 2.0.0-alpha.118
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/CHANGELOG.md +47 -0
- package/README.md +296 -17
- package/dist/OidcPlugin.d.ts.map +1 -1
- package/dist/OidcPlugin.js +35 -2
- package/dist/OidcPluginContext.d.ts +17 -0
- package/dist/OidcPluginContext.d.ts.map +1 -1
- package/dist/OidcPluginOptions.d.ts +123 -3
- package/dist/OidcPluginOptions.d.ts.map +1 -1
- package/dist/handlers/CallbackOidc.d.ts.map +1 -1
- package/dist/handlers/CallbackOidc.js +97 -20
- package/dist/handlers/InitiateOidc.d.ts.map +1 -1
- package/dist/handlers/InitiateOidc.js +12 -3
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/providers/ProviderRegistry.d.ts +27 -4
- package/dist/providers/ProviderRegistry.d.ts.map +1 -1
- package/dist/providers/ProviderRegistry.js +77 -21
- package/dist/utils/error-utils.d.ts +1 -0
- package/dist/utils/error-utils.d.ts.map +1 -1
- package/dist/utils/error-utils.js +1 -0
- package/dist/utils/redirect-uri-utils.d.ts +38 -0
- package/dist/utils/redirect-uri-utils.d.ts.map +1 -0
- package/dist/utils/redirect-uri-utils.js +92 -0
- package/examples/basic-oidc.ts +4 -0
- package/examples/multi-provider.ts +4 -0
- package/package.json +6 -6
- package/spec/handlers/CallbackOidc.spec.ts +330 -0
- package/spec/handlers/InitiateOidc.spec.ts +95 -1
- package/spec/helpers/test-helpers.ts +68 -0
- package/spec/plugin/OidcPlugin.spec.ts +165 -0
- package/spec/providers/ProviderRegistry.spec.ts +142 -0
- package/spec/utils/redirect-uri-utils.spec.ts +98 -0
- package/src/OidcPlugin.ts +38 -1
- package/src/OidcPluginContext.ts +18 -0
- package/src/OidcPluginOptions.ts +132 -3
- package/src/handlers/CallbackOidc.ts +109 -20
- package/src/handlers/InitiateOidc.ts +14 -3
- package/src/index.ts +10 -1
- package/src/providers/ProviderRegistry.ts +95 -22
- package/src/utils/error-utils.ts +1 -0
- 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
|
|
package/src/OidcPluginContext.ts
CHANGED
|
@@ -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
|