@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.
- package/CHANGELOG.md +55 -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
package/src/OidcPluginOptions.ts
CHANGED
|
@@ -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
|
-
*
|
|
28
|
-
*
|
|
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
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
|
209
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
|
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" :
|
|
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 {
|
|
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,
|
|
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
|
|
48
|
-
if (
|
|
65
|
+
const cached = this.providerInstances.get(providerName);
|
|
66
|
+
if (cached && !this.isExpired(cached)) {
|
|
49
67
|
oidcLog.debug(`Provider "${providerName}": using cached instance`);
|
|
50
|
-
return
|
|
68
|
+
return cached.provider;
|
|
51
69
|
}
|
|
52
70
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
71
|
+
if (cached) {
|
|
72
|
+
oidcLog.debug(`Provider "${providerName}": cache entry older than ${this.providerCacheTtlSec}s, reloading`);
|
|
73
|
+
}
|
|
56
74
|
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
config = await this.
|
|
61
|
-
|
|
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 ${
|
|
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
|
-
|
|
84
|
-
|
|
104
|
+
try {
|
|
105
|
+
const provider = new OidcProvider(config);
|
|
106
|
+
await provider.initialize();
|
|
85
107
|
|
|
86
|
-
|
|
87
|
-
|
|
108
|
+
// Cache the instance
|
|
109
|
+
this.providerInstances.set(providerName, { provider, loadedAt: Date.now() });
|
|
88
110
|
|
|
89
|
-
|
|
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 {
|
package/src/utils/error-utils.ts
CHANGED
|
@@ -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",
|