@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.
Files changed (42) hide show
  1. package/CHANGELOG.md +47 -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
@@ -2,6 +2,8 @@ import OidcProfile from "./schemas/OidcProfile";
2
2
  import OidcTokenSet from "./schemas/OidcTokenSet";
3
3
  import { OidcProviderConfig } from "./OidcProviderConfig";
4
4
  import { TokenDeliveryMode } from "./utils/response-utils";
5
+ import { AllowedRedirectUris } from "./utils/redirect-uri-utils";
6
+ import { FlinkResponse } from "@flink-app/flink";
5
7
 
6
8
  /**
7
9
  * OIDC error information passed to onAuthError callback
@@ -24,10 +26,10 @@ export interface OidcError {
24
26
  }
25
27
 
26
28
  /**
27
- * Response from onAuthSuccess callback
28
- * Must include JWT token generated by the application via jwt-auth-plugin
29
+ * Default completion: the plugin delivers the JWT token generated by the application
30
+ * via jwt-auth-plugin, according to `tokenDelivery` (or as JSON for `?response_type=json`).
29
31
  */
30
- export interface AuthSuccessCallbackResponse {
32
+ export interface AuthSuccessTokenResponse {
31
33
  /**
32
34
  * User object from your application
33
35
  * Should include _id field for connection storage
@@ -49,6 +51,60 @@ export interface AuthSuccessCallbackResponse {
49
51
  redirectUrl?: string;
50
52
  }
51
53
 
54
+ /**
55
+ * Verbatim redirect completion: the plugin redirects to `redirectUrl` exactly as given,
56
+ * without attaching any token. Use when the application has already handed the session
57
+ * over some other way — a one-time code it put in the URL, a server-side session keyed
58
+ * by something in the URL, and so on.
59
+ *
60
+ * The URL is still subject to the `allowedRedirectUris` check.
61
+ */
62
+ export interface AuthSuccessRedirectResponse {
63
+ /**
64
+ * User object from your application
65
+ * Should include _id field for connection storage
66
+ */
67
+ user: any;
68
+
69
+ /**
70
+ * URL to redirect to, used exactly as given (no token is spliced in)
71
+ */
72
+ redirectUrl: string;
73
+
74
+ /**
75
+ * Must not be set — a token means the default token completion instead
76
+ */
77
+ token?: undefined;
78
+ }
79
+
80
+ /**
81
+ * Custom completion: the application owns the entire HTTP response — anything a Flink
82
+ * handler may return, e.g. a redirect plus a `Set-Cookie` header, or a login envelope
83
+ * its clients already parse.
84
+ */
85
+ export interface AuthSuccessCustomResponse {
86
+ /**
87
+ * User object from your application
88
+ * Should include _id field for connection storage
89
+ */
90
+ user: any;
91
+
92
+ /**
93
+ * The response to send, returned as-is by the callback handler
94
+ */
95
+ response: FlinkResponse;
96
+ }
97
+
98
+ /**
99
+ * Response from onAuthSuccess callback
100
+ *
101
+ * Three shapes are supported:
102
+ * 1. `{ user, token, redirectUrl? }` — the plugin delivers the token (default)
103
+ * 2. `{ user, redirectUrl }` — the plugin redirects verbatim, no token attached
104
+ * 3. `{ user, response }` — the application builds the whole response
105
+ */
106
+ export type AuthSuccessCallbackResponse = AuthSuccessTokenResponse | AuthSuccessRedirectResponse | AuthSuccessCustomResponse;
107
+
52
108
  /**
53
109
  * Response from onAuthError callback
54
110
  */
@@ -58,6 +114,13 @@ export interface AuthErrorCallbackResponse {
58
114
  * e.g., "/login?error=authentication_failed"
59
115
  */
60
116
  redirectUrl?: string;
117
+
118
+ /**
119
+ * Optional complete response to send instead, returned as-is by the callback handler.
120
+ * Takes precedence over `redirectUrl`. Use for error completions the plugin cannot
121
+ * express, e.g. clearing a session cookie alongside the redirect.
122
+ */
123
+ response?: FlinkResponse;
61
124
  }
62
125
 
63
126
  /**
@@ -94,6 +157,41 @@ export interface OidcPluginOptions<TCtx = any> {
94
157
  */
95
158
  providers?: Record<string, OidcProviderConfig>;
96
159
 
160
+ /**
161
+ * Which `?redirectUri=` values the initiate endpoint accepts.
162
+ *
163
+ * The `redirectUri` decides where the JWT is delivered once the flow completes, and
164
+ * anyone can craft an initiate link — so without an allow-list a crafted link turns a
165
+ * legitimate login into token exfiltration. Set this on every deployment.
166
+ *
167
+ * Entries are matched as:
168
+ * - an exact URI (`https://app.example.com/login`), or
169
+ * - a bare origin (`https://app.example.com`, `myapp://cb`), which covers every path,
170
+ * query and fragment below that origin.
171
+ *
172
+ * A predicate can be used for anything else.
173
+ *
174
+ * Always accepted regardless of the list:
175
+ * - relative paths (`/dashboard`) — they can only land on this app's own origin
176
+ * - the provider's own `callbackUrl` (the fallback when no `redirectUri` is passed)
177
+ *
178
+ * The same check is applied to a `redirectUrl` returned by `onAuthSuccess` /
179
+ * `onAuthError` when it differs from the one stored on the session, so echoing an
180
+ * attacker-supplied value back cannot bypass the list.
181
+ *
182
+ * Default: undefined — every redirectUri is accepted (logged as a warning at startup).
183
+ *
184
+ * Example:
185
+ * ```typescript
186
+ * allowedRedirectUris: [
187
+ * "https://app.example.com", // any path on this origin
188
+ * "https://admin.example.com/auth-callback",
189
+ * "myapp://oidc-callback" // native app scheme
190
+ * ]
191
+ * ```
192
+ */
193
+ allowedRedirectUris?: AllowedRedirectUris;
194
+
97
195
  /**
98
196
  * Whether to store OIDC tokens for future API access
99
197
  * If false, tokens are discarded after authentication (auth-only mode)
@@ -117,6 +215,15 @@ export interface OidcPluginOptions<TCtx = any> {
117
215
  * 3. Generate JWT token using ctx.plugins.jwtAuth.createToken(payload, roles)
118
216
  * 4. Return user object, JWT token, and optional redirect URL
119
217
  *
218
+ * Applications that complete the login themselves can return one of two other shapes
219
+ * instead of a `token`:
220
+ * - `{ user, redirectUrl }` — the plugin redirects there verbatim, attaching nothing
221
+ * (e.g. the URL carries a short-lived one-time code the client exchanges for a session)
222
+ * - `{ user, response }` — the application returns the whole HTTP response
223
+ * (e.g. a redirect plus a `Set-Cookie` header)
224
+ *
225
+ * See {@link AuthSuccessCallbackResponse}.
226
+ *
120
227
  * @param params - OIDC profile, claims, provider name, and tokens (if storeTokens enabled)
121
228
  * @param ctx - Flink context with access to repos and plugins (including jwtAuth)
122
229
  * @returns User object, JWT token, and optional redirect URL
@@ -281,6 +388,28 @@ export interface OidcPluginOptions<TCtx = any> {
281
388
  */
282
389
  providerLoader?: (providerName: string, ctx: TCtx) => Promise<OidcProviderConfig | null>;
283
390
 
391
+ /**
392
+ * Seconds a resolved provider (static or from `providerLoader`) is reused before its
393
+ * config is loaded and OIDC discovery is run again.
394
+ *
395
+ * Default: 0 — cache for the life of the process (unchanged behaviour).
396
+ *
397
+ * Set this when provider configs live in a database and can change at runtime:
398
+ * without a TTL, editing an issuer, client id, client secret, scope or discovery URL
399
+ * only takes effect after a restart of every instance.
400
+ *
401
+ * Reloading happens lazily on the next request for that provider. If the reload
402
+ * fails (IdP unreachable, database hiccup), the previously cached instance keeps
403
+ * being used and the next attempt happens after another TTL — a discovery hiccup
404
+ * does not break logins that would otherwise work.
405
+ *
406
+ * For an immediate update on the instance handling the change, call
407
+ * `ctx.plugins.oidc.invalidateProvider(name)`; other instances converge within the TTL.
408
+ *
409
+ * Example: `providerCacheTtlSec: 300` (5 minutes)
410
+ */
411
+ providerCacheTtlSec?: number;
412
+
284
413
  /**
285
414
  * Custom collection name for OIDC sessions
286
415
  * Default: 'oidc_sessions'
@@ -19,6 +19,7 @@ import { validateState } from "../utils/state-utils";
19
19
  import { formatTokenResponse } from "../utils/response-utils";
20
20
  import { encryptToken } from "../utils/encryption-utils";
21
21
  import { validateProvider, validateResponseType, createOidcError, OidcErrorCodes, handleProviderError } from "../utils/error-utils";
22
+ import { isRedirectUriAllowed } from "../utils/redirect-uri-utils";
22
23
  import { oidcLog } from "../log";
23
24
 
24
25
  /**
@@ -53,6 +54,22 @@ const CallbackOidc: GetHandler<any, any, PathParams, CallbackRequest> = async ({
53
54
  let sessionRedirectUri: string | undefined;
54
55
  let sessionMetadata: Record<string, string> = {};
55
56
 
57
+ /**
58
+ * Apply the `allowedRedirectUris` allow-list to a redirect target built by the host
59
+ * (the `redirectUrl` returned by onAuthSuccess / onAuthError), so a host that echoes
60
+ * back an attacker-supplied value cannot bypass the list by accident.
61
+ *
62
+ * The redirectUri stored on the session was already validated at initiate time, as is
63
+ * the provider's own callbackUrl — both are always accepted here.
64
+ */
65
+ const isRedirectAllowed = (url: string, callbackUrl?: string): boolean => {
66
+ const { options } = ctx.plugins.oidc;
67
+ return isRedirectUriAllowed(url, provider, options.allowedRedirectUris, [
68
+ callbackUrl || options.providers?.[provider]?.callbackUrl,
69
+ sessionRedirectUri,
70
+ ]);
71
+ };
72
+
56
73
  try {
57
74
  // Validate provider and response_type
58
75
  validateProvider(provider);
@@ -85,16 +102,25 @@ const CallbackOidc: GetHandler<any, any, PathParams, CallbackRequest> = async ({
85
102
  metadata: sessionMetadata,
86
103
  });
87
104
 
105
+ if (errorResult.response) {
106
+ oidcLog.debug("Callback: responding with host-built error response");
107
+ return errorResult.response;
108
+ }
109
+
88
110
  if (errorResult.redirectUrl) {
89
- return {
90
- status: 302,
91
- headers: { Location: errorResult.redirectUrl },
92
- data: {},
93
- };
111
+ if (!isRedirectAllowed(errorResult.redirectUrl)) {
112
+ log.error(`OIDC callback: onAuthError returned a redirectUrl that is not allowed, ignoring it: ${errorResult.redirectUrl}`);
113
+ } else {
114
+ return {
115
+ status: 302,
116
+ headers: { Location: errorResult.redirectUrl },
117
+ data: {},
118
+ };
119
+ }
94
120
  }
95
121
  }
96
122
 
97
- return badRequest(error.message);
123
+ return badRequest(error.message, error.code);
98
124
  }
99
125
 
100
126
  // Validate required parameters
@@ -193,22 +219,37 @@ const CallbackOidc: GetHandler<any, any, PathParams, CallbackRequest> = async ({
193
219
  metadata: sessionMetadata,
194
220
  });
195
221
 
222
+ if (errorResult.response) {
223
+ oidcLog.debug("Callback: responding with host-built error response");
224
+ return errorResult.response;
225
+ }
226
+
196
227
  if (errorResult.redirectUrl) {
197
- return {
198
- status: 302,
199
- headers: { Location: errorResult.redirectUrl },
200
- data: {},
201
- };
228
+ if (!isRedirectAllowed(errorResult.redirectUrl)) {
229
+ log.error(`OIDC callback: onAuthError returned a redirectUrl that is not allowed, ignoring it: ${errorResult.redirectUrl}`);
230
+ } else {
231
+ return {
232
+ status: 302,
233
+ headers: { Location: errorResult.redirectUrl },
234
+ data: {},
235
+ };
236
+ }
202
237
  }
203
238
  }
204
239
 
205
240
  return internalServerError("Authentication failed. Please try again.");
206
241
  }
207
242
 
208
- // Extract user and JWT token from callback result
209
- const { user, token, redirectUrl } = authResult;
243
+ // Extract the completion the host asked for. Three shapes are supported:
244
+ // 1. `token` — the plugin delivers it (fragment/query/JSON)
245
+ // 2. `redirectUrl` without a token — redirect there verbatim, attaching nothing
246
+ // 3. `response` — the host owns the whole HTTP response (Set-Cookie, custom envelope, ...)
247
+ const { user } = authResult;
248
+ const token = "token" in authResult ? authResult.token : undefined;
249
+ const redirectUrl = "redirectUrl" in authResult ? authResult.redirectUrl : undefined;
250
+ const hostResponse = "response" in authResult ? authResult.response : undefined;
210
251
 
211
- if (!token) {
252
+ if (!token && !redirectUrl && !hostResponse) {
212
253
  throw createOidcError(OidcErrorCodes.JWT_GENERATION_FAILED, "No authentication token returned from callback", { hasUser: !!user });
213
254
  }
214
255
 
@@ -262,7 +303,46 @@ const CallbackOidc: GetHandler<any, any, PathParams, CallbackRequest> = async ({
262
303
  }
263
304
  }
264
305
 
306
+ // Shape 3: the host built the whole response — send it as-is
307
+ if (hostResponse) {
308
+ oidcLog.debug(`Callback: auth complete, responding with host-built response (status=${hostResponse.status ?? 200})`);
309
+ return hostResponse;
310
+ }
311
+
312
+ // Shape 2: verbatim redirect, nothing attached
313
+ if (!token) {
314
+ if (response_type === "json") {
315
+ throw createOidcError(
316
+ OidcErrorCodes.INVALID_RESPONSE_TYPE,
317
+ "The application completed the login without a token, so JSON mode is not available for this callback",
318
+ { provider }
319
+ );
320
+ }
321
+
322
+ if (!isRedirectAllowed(redirectUrl!, oidcProvider.config?.callbackUrl)) {
323
+ log.error(`OIDC callback: onAuthSuccess returned a redirectUrl that is not allowed: ${redirectUrl}`);
324
+ throw createOidcError(OidcErrorCodes.INVALID_REDIRECT_URI, "The redirect URL is not allowed", { redirectUrl, provider });
325
+ }
326
+
327
+ oidcLog.debug(`Callback: auth complete, redirecting verbatim to "${redirectUrl}" (no token attached)`);
328
+
329
+ return {
330
+ status: 302,
331
+ headers: { Location: redirectUrl! },
332
+ data: {},
333
+ };
334
+ }
335
+
336
+ // Shape 1: the plugin delivers the token
265
337
  const finalRedirectUrl = redirectUrl || session.redirectUri;
338
+
339
+ // Only the redirect response can leak the token to a foreign origin — the JSON
340
+ // response never uses finalRedirectUrl, so it is not subject to the allow-list.
341
+ if (response_type !== "json" && !isRedirectAllowed(finalRedirectUrl, oidcProvider.config?.callbackUrl)) {
342
+ log.error(`OIDC callback: onAuthSuccess returned a redirectUrl that is not allowed: ${finalRedirectUrl}`);
343
+ throw createOidcError(OidcErrorCodes.INVALID_REDIRECT_URI, "The redirect URL is not allowed", { redirectUrl: finalRedirectUrl, provider });
344
+ }
345
+
266
346
  oidcLog.debug(`Callback: auth complete, responding via ${response_type === "json" ? "JSON" : `redirect to "${finalRedirectUrl}"`}`);
267
347
 
268
348
  // Resolve token delivery mode: literal, function, or default "fragment".
@@ -295,19 +375,28 @@ const CallbackOidc: GetHandler<any, any, PathParams, CallbackRequest> = async ({
295
375
  metadata: sessionMetadata,
296
376
  });
297
377
 
378
+ if (errorResult.response) {
379
+ oidcLog.debug("Callback: responding with host-built error response");
380
+ return errorResult.response;
381
+ }
382
+
298
383
  if (errorResult.redirectUrl) {
299
- return {
300
- status: 302,
301
- headers: { Location: errorResult.redirectUrl },
302
- data: {},
303
- };
384
+ if (!isRedirectAllowed(errorResult.redirectUrl)) {
385
+ log.error(`OIDC callback: onAuthError returned a redirectUrl that is not allowed, ignoring it: ${errorResult.redirectUrl}`);
386
+ } else {
387
+ return {
388
+ status: 302,
389
+ headers: { Location: errorResult.redirectUrl },
390
+ data: {},
391
+ };
392
+ }
304
393
  }
305
394
  } catch (callbackError) {
306
395
  log.error("onAuthError callback failed:", callbackError);
307
396
  }
308
397
  }
309
398
 
310
- return badRequest(error.message);
399
+ return badRequest(error.message, error.code);
311
400
  }
312
401
 
313
402
  // Handle provider errors
@@ -15,6 +15,7 @@ import { GetHandler, HttpMethod, RouteProps, badRequest, internalServerError } f
15
15
  import InitiateRequest from "../schemas/InitiateRequest";
16
16
  import { generateState, generateSessionId, generateNonce } from "../utils/state-utils";
17
17
  import { validateProvider, createOidcError, OidcErrorCodes } from "../utils/error-utils";
18
+ import { isRedirectUriAllowed } from "../utils/redirect-uri-utils";
18
19
  import { generators } from "openid-client";
19
20
  import { oidcLog } from "../log";
20
21
 
@@ -65,7 +66,17 @@ const InitiateOidc: GetHandler<any, any, PathParams, InitiateRequest> = async ({
65
66
 
66
67
  // Determine redirect URI (use provided or default to callback URL)
67
68
  const staticProviderConfig = options.providers?.[provider];
68
- const finalRedirectUri = redirectUri || staticProviderConfig?.callbackUrl || `${req.protocol}://${req.get("host")}/oidc/${provider}/callback`;
69
+ const callbackUrl = oidcProvider?.config?.callbackUrl || staticProviderConfig?.callbackUrl;
70
+
71
+ // The redirectUri decides where the JWT ends up, and the query param is
72
+ // attacker-controllable — validate it against the configured allow-list
73
+ // before anything is persisted.
74
+ if (redirectUri && !isRedirectUriAllowed(redirectUri, provider, options.allowedRedirectUris, [callbackUrl])) {
75
+ oidcLog.debug(`Initiate: rejected redirectUri="${redirectUri}" (not in allowedRedirectUris)`);
76
+ throw createOidcError(OidcErrorCodes.INVALID_REDIRECT_URI, "The provided redirectUri is not allowed", { redirectUri, provider });
77
+ }
78
+
79
+ const finalRedirectUri = redirectUri || callbackUrl || `${req.protocol}://${req.get("host")}/oidc/${provider}/callback`;
69
80
 
70
81
  // Collect meta.* query params as opaque metadata (prefix stripped)
71
82
  const metadata: Record<string, string> = {};
@@ -75,7 +86,7 @@ const InitiateOidc: GetHandler<any, any, PathParams, InitiateRequest> = async ({
75
86
  }
76
87
  }
77
88
 
78
- oidcLog.debug(`Initiate: resolved redirectUri="${finalRedirectUri}" (source: ${redirectUri ? "query param" : staticProviderConfig?.callbackUrl ? "provider config" : "auto-detected"})`);
89
+ oidcLog.debug(`Initiate: resolved redirectUri="${finalRedirectUri}" (source: ${redirectUri ? "query param" : callbackUrl ? "provider config" : "auto-detected"})`);
79
90
 
80
91
  // Generate cryptographically secure parameters
81
92
  const state = generateState();
@@ -117,7 +128,7 @@ const InitiateOidc: GetHandler<any, any, PathParams, InitiateRequest> = async ({
117
128
  } catch (error: any) {
118
129
  // Handle validation errors
119
130
  if (error.code && Object.values(OidcErrorCodes).includes(error.code)) {
120
- return badRequest(error.message);
131
+ return badRequest(error.message, error.code);
121
132
  }
122
133
 
123
134
  // Handle unexpected errors
package/src/index.ts CHANGED
@@ -12,7 +12,15 @@
12
12
  export { oidcPlugin } from "./OidcPlugin";
13
13
 
14
14
  // Configuration types
15
- export { OidcPluginOptions, OidcError, AuthSuccessCallbackResponse, AuthErrorCallbackResponse } from "./OidcPluginOptions";
15
+ export {
16
+ OidcPluginOptions,
17
+ OidcError,
18
+ AuthSuccessCallbackResponse,
19
+ AuthSuccessTokenResponse,
20
+ AuthSuccessRedirectResponse,
21
+ AuthSuccessCustomResponse,
22
+ AuthErrorCallbackResponse,
23
+ } from "./OidcPluginOptions";
16
24
  export { OidcProviderConfig } from "./OidcProviderConfig";
17
25
 
18
26
  // Context types
@@ -35,4 +43,5 @@ export { generateState, generateSessionId, generateNonce, validateState } from "
35
43
  export { encryptToken, decryptToken, validateEncryptionSecret } from "./utils/encryption-utils";
36
44
  export { mapClaimsToProfile, extractCustomClaims } from "./utils/claims-mapper";
37
45
  export { formatTokenResponse, TokenDeliveryMode } from "./utils/response-utils";
46
+ export { isRedirectUriAllowed, isRelativeRedirect, AllowedRedirectUris } from "./utils/redirect-uri-utils";
38
47
  export { createOidcError, validateProvider, handleProviderError, OidcErrorCodes } from "./utils/error-utils";
@@ -2,6 +2,15 @@ import { OidcProvider } from "./OidcProvider";
2
2
  import { OidcProviderConfig } from "../OidcProviderConfig";
3
3
  import { createOidcError, OidcErrorCodes } from "../utils/error-utils";
4
4
  import { oidcLog, maskSecret } from "../log";
5
+ import { log } from "@flink-app/flink";
6
+
7
+ /**
8
+ * A cached provider instance along with the time its config was resolved
9
+ */
10
+ interface CachedProvider {
11
+ provider: OidcProvider;
12
+ loadedAt: number;
13
+ }
5
14
 
6
15
  /**
7
16
  * Provider registry for managing OIDC provider instances
@@ -9,34 +18,43 @@ import { oidcLog, maskSecret } from "../log";
9
18
  * Handles:
10
19
  * - Static provider configuration
11
20
  * - Dynamic provider loading from database
12
- * - Provider instance caching
21
+ * - Provider instance caching (optionally with a TTL)
13
22
  * - Lazy initialization
14
23
  */
15
24
  export class ProviderRegistry {
16
25
  private staticProviders: Record<string, OidcProviderConfig>;
17
- private providerInstances: Map<string, OidcProvider> = new Map();
26
+ private providerInstances: Map<string, CachedProvider> = new Map();
18
27
  private providerLoader?: (providerName: string, ctx: any) => Promise<OidcProviderConfig | null>;
19
28
  private ctx: any;
29
+ private providerCacheTtlSec: number;
20
30
 
21
31
  constructor(
22
32
  staticProviders: Record<string, OidcProviderConfig>,
23
33
  providerLoader?: (providerName: string, ctx: any) => Promise<OidcProviderConfig | null>,
24
- ctx?: any
34
+ ctx?: any,
35
+ providerCacheTtlSec: number = 0
25
36
  ) {
26
37
  this.staticProviders = staticProviders;
27
38
  this.providerLoader = providerLoader;
28
39
  this.ctx = ctx;
40
+ this.providerCacheTtlSec = providerCacheTtlSec;
29
41
  }
30
42
 
31
43
  /**
32
44
  * Get provider instance by name
33
45
  *
34
46
  * Looks up provider in the following order:
35
- * 1. Cached instance
47
+ * 1. Cached instance (unless it is older than `providerCacheTtlSec`)
36
48
  * 2. Static configuration
37
49
  * 3. Dynamic loader (if configured)
38
50
  *
39
- * Providers are lazy-initialized on first use and cached.
51
+ * Providers are lazy-initialized on first use and cached. With a cache TTL
52
+ * configured, an expired entry is reloaded on the next request — the config is
53
+ * resolved again (re-running `providerLoader`) and discovery is re-run.
54
+ *
55
+ * If reloading an expired entry fails, the previous instance keeps being used: a
56
+ * transient discovery or database hiccup should not break logins that would
57
+ * otherwise work. The next reload attempt happens after another TTL.
40
58
  *
41
59
  * @param providerName - Provider identifier
42
60
  * @returns Initialized OIDC provider instance
@@ -44,24 +62,27 @@ export class ProviderRegistry {
44
62
  */
45
63
  async getProvider(providerName: string): Promise<OidcProvider> {
46
64
  // Check cache first
47
- const cachedProvider = this.providerInstances.get(providerName);
48
- if (cachedProvider) {
65
+ const cached = this.providerInstances.get(providerName);
66
+ if (cached && !this.isExpired(cached)) {
49
67
  oidcLog.debug(`Provider "${providerName}": using cached instance`);
50
- return cachedProvider;
68
+ return cached.provider;
51
69
  }
52
70
 
53
- // Try static configuration
54
- let config: OidcProviderConfig | null = this.staticProviders[providerName] || null;
55
- let configSource = "static";
71
+ if (cached) {
72
+ oidcLog.debug(`Provider "${providerName}": cache entry older than ${this.providerCacheTtlSec}s, reloading`);
73
+ }
56
74
 
57
- // Try dynamic loader if not in static config
58
- if (!config && this.providerLoader) {
59
- oidcLog.debug(`Provider "${providerName}": not in static config, invoking providerLoader`);
60
- config = await this.providerLoader(providerName, this.ctx);
61
- configSource = "dynamic";
75
+ // Resolve config (static config first, then the dynamic loader)
76
+ let config: OidcProviderConfig | null;
77
+ try {
78
+ config = await this.resolveConfig(providerName);
79
+ } catch (error: any) {
80
+ return this.keepCachedOnFailure(providerName, cached, "config could not be loaded", error);
62
81
  }
63
82
 
64
83
  if (!config) {
84
+ // Provider is genuinely gone (or never existed) — drop any cached instance
85
+ this.providerInstances.delete(providerName);
65
86
  oidcLog.debug(`Provider "${providerName}": not found (static=[${Object.keys(this.staticProviders).join(", ")}], loader=${!!this.providerLoader})`);
66
87
  throw createOidcError(OidcErrorCodes.PROVIDER_NOT_CONFIGURED, `OIDC provider '${providerName}' is not configured`, {
67
88
  providerName,
@@ -70,7 +91,7 @@ export class ProviderRegistry {
70
91
  }
71
92
 
72
93
  oidcLog.debug(
73
- `Provider "${providerName}" resolved via ${configSource}:`,
94
+ `Provider "${providerName}" resolved via ${providerName in this.staticProviders ? "static" : "dynamic"}:`,
74
95
  `issuer=${config.issuer}`,
75
96
  `clientId=${config.clientId}`,
76
97
  `clientSecret=${maskSecret(config.clientSecret)}`,
@@ -80,13 +101,63 @@ export class ProviderRegistry {
80
101
  );
81
102
 
82
103
  // Create and initialize provider
83
- const provider = new OidcProvider(config);
84
- await provider.initialize();
104
+ try {
105
+ const provider = new OidcProvider(config);
106
+ await provider.initialize();
85
107
 
86
- // Cache the instance
87
- this.providerInstances.set(providerName, provider);
108
+ // Cache the instance
109
+ this.providerInstances.set(providerName, { provider, loadedAt: Date.now() });
88
110
 
89
- return provider;
111
+ return provider;
112
+ } catch (error: any) {
113
+ return this.keepCachedOnFailure(providerName, cached, "initialization failed", error);
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Resolve provider config from static configuration or the dynamic loader
119
+ */
120
+ private async resolveConfig(providerName: string): Promise<OidcProviderConfig | null> {
121
+ const staticConfig = this.staticProviders[providerName];
122
+ if (staticConfig) {
123
+ return staticConfig;
124
+ }
125
+
126
+ if (this.providerLoader) {
127
+ oidcLog.debug(`Provider "${providerName}": not in static config, invoking providerLoader`);
128
+ return await this.providerLoader(providerName, this.ctx);
129
+ }
130
+
131
+ return null;
132
+ }
133
+
134
+ /**
135
+ * Whether a cache entry has outlived the configured TTL.
136
+ * A TTL of 0 (the default) means entries are cached for the life of the process.
137
+ */
138
+ private isExpired(cached: CachedProvider): boolean {
139
+ if (this.providerCacheTtlSec <= 0) {
140
+ return false;
141
+ }
142
+
143
+ return Date.now() - cached.loadedAt >= this.providerCacheTtlSec * 1000;
144
+ }
145
+
146
+ /**
147
+ * Keep serving a previously cached instance when a reload fails.
148
+ * Rethrows when there is nothing cached to fall back on.
149
+ */
150
+ private keepCachedOnFailure(providerName: string, cached: CachedProvider | undefined, reason: string, error: any): OidcProvider {
151
+ if (!cached) {
152
+ throw error;
153
+ }
154
+
155
+ log.warn(`OIDC Plugin: reload of provider "${providerName}" failed (${reason}), keeping the previously cached instance: ${error.message}`);
156
+
157
+ // Restart the TTL window so a persistent failure is not retried on every request
158
+ cached.loadedAt = Date.now();
159
+
160
+ return cached.provider;
90
161
  }
91
162
 
92
163
  /**
@@ -105,6 +176,8 @@ export class ProviderRegistry {
105
176
  * Forces re-initialization of providers on next access.
106
177
  * Useful when provider configurations change.
107
178
  *
179
+ * Exposed to applications as `ctx.plugins.oidc.invalidateProvider()`.
180
+ *
108
181
  * @param providerName - Optional provider to clear (clears all if not specified)
109
182
  */
110
183
  clearCache(providerName?: string): void {
@@ -14,6 +14,7 @@ export const OidcErrorCodes = {
14
14
  MISSING_STATE: "missing_state",
15
15
  INVALID_STATE: "invalid_state",
16
16
  INVALID_RESPONSE_TYPE: "invalid_response_type",
17
+ INVALID_REDIRECT_URI: "invalid_redirect_uri",
17
18
 
18
19
  // Session errors
19
20
  SESSION_NOT_FOUND: "session_not_found",