@flink-app/oidc-plugin 2.0.0-alpha.116 → 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.
Files changed (42) hide show
  1. package/CHANGELOG.md +55 -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 +6 -6
  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
@@ -4,31 +4,39 @@ exports.ProviderRegistry = void 0;
4
4
  const OidcProvider_1 = require("./OidcProvider");
5
5
  const error_utils_1 = require("../utils/error-utils");
6
6
  const log_1 = require("../log");
7
+ const flink_1 = require("@flink-app/flink");
7
8
  /**
8
9
  * Provider registry for managing OIDC provider instances
9
10
  *
10
11
  * Handles:
11
12
  * - Static provider configuration
12
13
  * - Dynamic provider loading from database
13
- * - Provider instance caching
14
+ * - Provider instance caching (optionally with a TTL)
14
15
  * - Lazy initialization
15
16
  */
16
17
  class ProviderRegistry {
17
- constructor(staticProviders, providerLoader, ctx) {
18
+ constructor(staticProviders, providerLoader, ctx, providerCacheTtlSec = 0) {
18
19
  this.providerInstances = new Map();
19
20
  this.staticProviders = staticProviders;
20
21
  this.providerLoader = providerLoader;
21
22
  this.ctx = ctx;
23
+ this.providerCacheTtlSec = providerCacheTtlSec;
22
24
  }
23
25
  /**
24
26
  * Get provider instance by name
25
27
  *
26
28
  * Looks up provider in the following order:
27
- * 1. Cached instance
29
+ * 1. Cached instance (unless it is older than `providerCacheTtlSec`)
28
30
  * 2. Static configuration
29
31
  * 3. Dynamic loader (if configured)
30
32
  *
31
- * Providers are lazy-initialized on first use and cached.
33
+ * Providers are lazy-initialized on first use and cached. With a cache TTL
34
+ * configured, an expired entry is reloaded on the next request — the config is
35
+ * resolved again (re-running `providerLoader`) and discovery is re-run.
36
+ *
37
+ * If reloading an expired entry fails, the previous instance keeps being used: a
38
+ * transient discovery or database hiccup should not break logins that would
39
+ * otherwise work. The next reload attempt happens after another TTL.
32
40
  *
33
41
  * @param providerName - Provider identifier
34
42
  * @returns Initialized OIDC provider instance
@@ -36,34 +44,80 @@ class ProviderRegistry {
36
44
  */
37
45
  async getProvider(providerName) {
38
46
  // Check cache first
39
- const cachedProvider = this.providerInstances.get(providerName);
40
- if (cachedProvider) {
47
+ const cached = this.providerInstances.get(providerName);
48
+ if (cached && !this.isExpired(cached)) {
41
49
  log_1.oidcLog.debug(`Provider "${providerName}": using cached instance`);
42
- return cachedProvider;
50
+ return cached.provider;
43
51
  }
44
- // Try static configuration
45
- let config = this.staticProviders[providerName] || null;
46
- let configSource = "static";
47
- // Try dynamic loader if not in static config
48
- if (!config && this.providerLoader) {
49
- log_1.oidcLog.debug(`Provider "${providerName}": not in static config, invoking providerLoader`);
50
- config = await this.providerLoader(providerName, this.ctx);
51
- configSource = "dynamic";
52
+ if (cached) {
53
+ log_1.oidcLog.debug(`Provider "${providerName}": cache entry older than ${this.providerCacheTtlSec}s, reloading`);
54
+ }
55
+ // Resolve config (static config first, then the dynamic loader)
56
+ let config;
57
+ try {
58
+ config = await this.resolveConfig(providerName);
59
+ }
60
+ catch (error) {
61
+ return this.keepCachedOnFailure(providerName, cached, "config could not be loaded", error);
52
62
  }
53
63
  if (!config) {
64
+ // Provider is genuinely gone (or never existed) — drop any cached instance
65
+ this.providerInstances.delete(providerName);
54
66
  log_1.oidcLog.debug(`Provider "${providerName}": not found (static=[${Object.keys(this.staticProviders).join(", ")}], loader=${!!this.providerLoader})`);
55
67
  throw (0, error_utils_1.createOidcError)(error_utils_1.OidcErrorCodes.PROVIDER_NOT_CONFIGURED, `OIDC provider '${providerName}' is not configured`, {
56
68
  providerName,
57
69
  availableProviders: Object.keys(this.staticProviders),
58
70
  });
59
71
  }
60
- log_1.oidcLog.debug(`Provider "${providerName}" resolved via ${configSource}:`, `issuer=${config.issuer}`, `clientId=${config.clientId}`, `clientSecret=${(0, log_1.maskSecret)(config.clientSecret)}`, `callbackUrl=${config.callbackUrl}`, config.discoveryUrl ? `discoveryUrl=${config.discoveryUrl}` : `authorizationEndpoint=${config.authorizationEndpoint}`, `scope=${(config.scope || ["openid", "email", "profile"]).join(" ")}`);
72
+ log_1.oidcLog.debug(`Provider "${providerName}" resolved via ${providerName in this.staticProviders ? "static" : "dynamic"}:`, `issuer=${config.issuer}`, `clientId=${config.clientId}`, `clientSecret=${(0, log_1.maskSecret)(config.clientSecret)}`, `callbackUrl=${config.callbackUrl}`, config.discoveryUrl ? `discoveryUrl=${config.discoveryUrl}` : `authorizationEndpoint=${config.authorizationEndpoint}`, `scope=${(config.scope || ["openid", "email", "profile"]).join(" ")}`);
61
73
  // Create and initialize provider
62
- const provider = new OidcProvider_1.OidcProvider(config);
63
- await provider.initialize();
64
- // Cache the instance
65
- this.providerInstances.set(providerName, provider);
66
- return provider;
74
+ try {
75
+ const provider = new OidcProvider_1.OidcProvider(config);
76
+ await provider.initialize();
77
+ // Cache the instance
78
+ this.providerInstances.set(providerName, { provider, loadedAt: Date.now() });
79
+ return provider;
80
+ }
81
+ catch (error) {
82
+ return this.keepCachedOnFailure(providerName, cached, "initialization failed", error);
83
+ }
84
+ }
85
+ /**
86
+ * Resolve provider config from static configuration or the dynamic loader
87
+ */
88
+ async resolveConfig(providerName) {
89
+ const staticConfig = this.staticProviders[providerName];
90
+ if (staticConfig) {
91
+ return staticConfig;
92
+ }
93
+ if (this.providerLoader) {
94
+ log_1.oidcLog.debug(`Provider "${providerName}": not in static config, invoking providerLoader`);
95
+ return await this.providerLoader(providerName, this.ctx);
96
+ }
97
+ return null;
98
+ }
99
+ /**
100
+ * Whether a cache entry has outlived the configured TTL.
101
+ * A TTL of 0 (the default) means entries are cached for the life of the process.
102
+ */
103
+ isExpired(cached) {
104
+ if (this.providerCacheTtlSec <= 0) {
105
+ return false;
106
+ }
107
+ return Date.now() - cached.loadedAt >= this.providerCacheTtlSec * 1000;
108
+ }
109
+ /**
110
+ * Keep serving a previously cached instance when a reload fails.
111
+ * Rethrows when there is nothing cached to fall back on.
112
+ */
113
+ keepCachedOnFailure(providerName, cached, reason, error) {
114
+ if (!cached) {
115
+ throw error;
116
+ }
117
+ flink_1.log.warn(`OIDC Plugin: reload of provider "${providerName}" failed (${reason}), keeping the previously cached instance: ${error.message}`);
118
+ // Restart the TTL window so a persistent failure is not retried on every request
119
+ cached.loadedAt = Date.now();
120
+ return cached.provider;
67
121
  }
68
122
  /**
69
123
  * Check if provider exists in static configuration or can be loaded dynamically
@@ -80,6 +134,8 @@ class ProviderRegistry {
80
134
  * Forces re-initialization of providers on next access.
81
135
  * Useful when provider configurations change.
82
136
  *
137
+ * Exposed to applications as `ctx.plugins.oidc.invalidateProvider()`.
138
+ *
83
139
  * @param providerName - Optional provider to clear (clears all if not specified)
84
140
  */
85
141
  clearCache(providerName) {
@@ -10,6 +10,7 @@ export declare const OidcErrorCodes: {
10
10
  readonly MISSING_STATE: "missing_state";
11
11
  readonly INVALID_STATE: "invalid_state";
12
12
  readonly INVALID_RESPONSE_TYPE: "invalid_response_type";
13
+ readonly INVALID_REDIRECT_URI: "invalid_redirect_uri";
13
14
  readonly SESSION_NOT_FOUND: "session_not_found";
14
15
  readonly SESSION_EXPIRED: "session_expired";
15
16
  readonly TOKEN_EXCHANGE_FAILED: "token_exchange_failed";
@@ -1 +1 @@
1
- {"version":3,"file":"error-utils.d.ts","sourceRoot":"","sources":["../../src/utils/error-utils.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAEjD;;GAEG;AACH,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;CAoCjB,CAAC;AAEX;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,GAAG,SAAS,CAgBvF;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CASvD;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAMhE;AAED;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,GAAG,GAAG,SAAS,CAsDzD"}
1
+ {"version":3,"file":"error-utils.d.ts","sourceRoot":"","sources":["../../src/utils/error-utils.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAEjD;;GAEG;AACH,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;;CAqCjB,CAAC;AAEX;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,GAAG,SAAS,CAgBvF;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CASvD;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAMhE;AAED;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,GAAG,GAAG,SAAS,CAsDzD"}
@@ -18,6 +18,7 @@ exports.OidcErrorCodes = {
18
18
  MISSING_STATE: "missing_state",
19
19
  INVALID_STATE: "invalid_state",
20
20
  INVALID_RESPONSE_TYPE: "invalid_response_type",
21
+ INVALID_REDIRECT_URI: "invalid_redirect_uri",
21
22
  // Session errors
22
23
  SESSION_NOT_FOUND: "session_not_found",
23
24
  SESSION_EXPIRED: "session_expired",
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Redirect URI allow-list utilities
3
+ *
4
+ * The `?redirectUri=` query parameter on `/oidc/:provider/initiate` decides where the
5
+ * host's JWT is delivered once the OIDC flow completes. It is attacker-controllable —
6
+ * anyone can craft an initiate link — so deployments should restrict it to the URLs
7
+ * their own clients use via the `allowedRedirectUris` plugin option.
8
+ */
9
+ /**
10
+ * Allow-list for the `redirectUri` query parameter.
11
+ *
12
+ * Either a list of entries or a predicate:
13
+ * - An entry that is a bare origin (`https://app.example.com`, `myapp://cb`) matches any
14
+ * path/query/fragment below that origin.
15
+ * - An entry with a path (`https://app.example.com/login`) matches that exact URI only.
16
+ * - A predicate receives the requested redirectUri and the provider name.
17
+ */
18
+ export type AllowedRedirectUris = string[] | ((redirectUri: string, provider: string) => boolean);
19
+ /**
20
+ * Whether a redirect target is a relative path on the plugin's own origin.
21
+ *
22
+ * `/dashboard` and `/login?error=x` are relative. `//evil.example` (protocol-relative)
23
+ * and `/\evil.example` are NOT — browsers resolve both of those to another origin.
24
+ */
25
+ export declare function isRelativeRedirect(redirectUri: string): boolean;
26
+ /**
27
+ * Check whether a redirect URI is allowed.
28
+ *
29
+ * @param redirectUri - The redirect target to check
30
+ * @param provider - Provider name (passed to the predicate form)
31
+ * @param allowedRedirectUris - The `allowedRedirectUris` plugin option. Undefined means
32
+ * no allow-list is configured and every URI is accepted (legacy behaviour).
33
+ * @param alwaysAllowed - Entries that bypass the allow-list, e.g. the provider's own
34
+ * `callbackUrl` and the redirectUri already validated at initiate time.
35
+ * @returns true if the redirect may be performed
36
+ */
37
+ export declare function isRedirectUriAllowed(redirectUri: string, provider: string, allowedRedirectUris?: AllowedRedirectUris, alwaysAllowed?: (string | undefined)[]): boolean;
38
+ //# sourceMappingURL=redirect-uri-utils.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redirect-uri-utils.d.ts","sourceRoot":"","sources":["../../src/utils/redirect-uri-utils.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH;;;;;;;;GAQG;AACH,MAAM,MAAM,mBAAmB,GAAG,MAAM,EAAE,GAAG,CAAC,CAAC,WAAW,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC;AAElG;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAE/D;AAiDD;;;;;;;;;;GAUG;AACH,wBAAgB,oBAAoB,CAChC,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,MAAM,EAChB,mBAAmB,CAAC,EAAE,mBAAmB,EACzC,aAAa,GAAE,CAAC,MAAM,GAAG,SAAS,CAAC,EAAO,GAC3C,OAAO,CAsBT"}
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+ /**
3
+ * Redirect URI allow-list utilities
4
+ *
5
+ * The `?redirectUri=` query parameter on `/oidc/:provider/initiate` decides where the
6
+ * host's JWT is delivered once the OIDC flow completes. It is attacker-controllable —
7
+ * anyone can craft an initiate link — so deployments should restrict it to the URLs
8
+ * their own clients use via the `allowedRedirectUris` plugin option.
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.isRelativeRedirect = isRelativeRedirect;
12
+ exports.isRedirectUriAllowed = isRedirectUriAllowed;
13
+ /**
14
+ * Whether a redirect target is a relative path on the plugin's own origin.
15
+ *
16
+ * `/dashboard` and `/login?error=x` are relative. `//evil.example` (protocol-relative)
17
+ * and `/\evil.example` are NOT — browsers resolve both of those to another origin.
18
+ */
19
+ function isRelativeRedirect(redirectUri) {
20
+ return /^\/(?![/\\])/.test(redirectUri);
21
+ }
22
+ function parseUrl(value) {
23
+ try {
24
+ return new URL(value);
25
+ }
26
+ catch {
27
+ return undefined;
28
+ }
29
+ }
30
+ /**
31
+ * Origin of a URL, including custom schemes.
32
+ * `URL.origin` is the string "null" for non-special schemes (e.g. `myapp://cb`),
33
+ * so fall back to scheme + host for those.
34
+ */
35
+ function originOf(url) {
36
+ return url.origin && url.origin !== "null" ? url.origin : `${url.protocol}//${url.host}`;
37
+ }
38
+ /**
39
+ * Whether an allow-list entry names a bare origin (no path, query or fragment)
40
+ * and therefore matches every URI below that origin.
41
+ */
42
+ function isOriginOnlyEntry(url) {
43
+ return (url.pathname === "" || url.pathname === "/") && !url.search && !url.hash;
44
+ }
45
+ /**
46
+ * Match a redirect URI against a single allow-list entry.
47
+ * Exact string match always wins; origin-only entries additionally match by origin.
48
+ */
49
+ function matchesEntry(redirectUri, entry) {
50
+ if (redirectUri === entry) {
51
+ return true;
52
+ }
53
+ const entryUrl = parseUrl(entry);
54
+ if (!entryUrl || !isOriginOnlyEntry(entryUrl)) {
55
+ return false;
56
+ }
57
+ const targetUrl = parseUrl(redirectUri);
58
+ if (!targetUrl) {
59
+ return false;
60
+ }
61
+ return originOf(targetUrl) === originOf(entryUrl);
62
+ }
63
+ /**
64
+ * Check whether a redirect URI is allowed.
65
+ *
66
+ * @param redirectUri - The redirect target to check
67
+ * @param provider - Provider name (passed to the predicate form)
68
+ * @param allowedRedirectUris - The `allowedRedirectUris` plugin option. Undefined means
69
+ * no allow-list is configured and every URI is accepted (legacy behaviour).
70
+ * @param alwaysAllowed - Entries that bypass the allow-list, e.g. the provider's own
71
+ * `callbackUrl` and the redirectUri already validated at initiate time.
72
+ * @returns true if the redirect may be performed
73
+ */
74
+ function isRedirectUriAllowed(redirectUri, provider, allowedRedirectUris, alwaysAllowed = []) {
75
+ // No allow-list configured: unchanged (permissive) behaviour
76
+ if (allowedRedirectUris === undefined) {
77
+ return true;
78
+ }
79
+ // Relative paths can only land on the plugin's own origin
80
+ if (isRelativeRedirect(redirectUri)) {
81
+ return true;
82
+ }
83
+ for (const entry of alwaysAllowed) {
84
+ if (entry && matchesEntry(redirectUri, entry)) {
85
+ return true;
86
+ }
87
+ }
88
+ if (typeof allowedRedirectUris === "function") {
89
+ return allowedRedirectUris(redirectUri, provider);
90
+ }
91
+ return allowedRedirectUris.some((entry) => matchesEntry(redirectUri, entry));
92
+ }
@@ -69,6 +69,10 @@ async function start() {
69
69
  },
70
70
  },
71
71
 
72
+ // Restrict where the JWT may be delivered after login.
73
+ // Without this, any ?redirectUri= passed to /initiate is accepted.
74
+ allowedRedirectUris: ["http://localhost:3000"],
75
+
72
76
  // Step 3: Implement JIT user provisioning
73
77
  onAuthSuccess: async ({ profile, claims, provider }, ctx) => {
74
78
  console.log("OIDC authentication successful");
@@ -64,6 +64,10 @@ async function start() {
64
64
  },
65
65
  },
66
66
 
67
+ // Restrict where the JWT may be delivered after login.
68
+ // Without this, any ?redirectUri= passed to /initiate is accepted.
69
+ allowedRedirectUris: ["https://myapp.com", "myapp://oidc-callback"],
70
+
67
71
  onAuthSuccess: async ({ profile, claims, provider }, ctx) => {
68
72
  // Try to find existing user by OIDC connection
69
73
  let user = await ctx.repos.userRepo.getOne({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flink-app/oidc-plugin",
3
- "version": "2.0.0-alpha.116",
3
+ "version": "2.0.0-alpha.118",
4
4
  "description": "Flink plugin for OIDC authentication with generic IdP support",
5
5
  "author": "joel@frost.se",
6
6
  "license": "MIT",
@@ -11,10 +11,10 @@
11
11
  },
12
12
  "dependencies": {
13
13
  "openid-client": "^5.7.0",
14
- "@flink-app/jwt-auth-plugin": "2.0.0-alpha.116"
14
+ "@flink-app/jwt-auth-plugin": "2.0.0-alpha.118"
15
15
  },
16
16
  "peerDependencies": {
17
- "@flink-app/flink": ">=2.0.0-alpha.116",
17
+ "@flink-app/flink": ">=2.0.0-alpha.118",
18
18
  "mongodb": "^6.15.0"
19
19
  },
20
20
  "peerDependenciesMeta": {
@@ -27,9 +27,9 @@
27
27
  "@types/node": "22.13.10",
28
28
  "ts-node": "^10.9.2",
29
29
  "tsc-watch": "^4.2.9",
30
- "@flink-app/jwt-auth-plugin": "2.0.0-alpha.116",
31
- "@flink-app/flink": "2.0.0-alpha.116",
32
- "@flink-app/test-utils": "2.0.0-alpha.116"
30
+ "@flink-app/flink": "2.0.0-alpha.118",
31
+ "@flink-app/jwt-auth-plugin": "2.0.0-alpha.118",
32
+ "@flink-app/test-utils": "2.0.0-alpha.118"
33
33
  },
34
34
  "scripts": {
35
35
  "test": "jasmine-ts --config=./spec/support/jasmine.json",