@flink-app/oidc-plugin 1.0.0 → 2.0.0-alpha.101

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 (52) hide show
  1. package/CHANGELOG.md +566 -0
  2. package/README.md +23 -2
  3. package/dist/OidcPlugin.d.ts +1 -1
  4. package/dist/OidcPlugin.d.ts.map +1 -1
  5. package/dist/OidcPlugin.js +32 -18
  6. package/dist/OidcPluginOptions.d.ts +75 -8
  7. package/dist/OidcPluginOptions.d.ts.map +1 -1
  8. package/dist/OidcProviderConfig.d.ts +21 -1
  9. package/dist/OidcProviderConfig.d.ts.map +1 -1
  10. package/dist/handlers/CallbackOidc.d.ts.map +1 -1
  11. package/dist/handlers/CallbackOidc.js +50 -5
  12. package/dist/handlers/InitiateOidc.d.ts.map +1 -1
  13. package/dist/handlers/InitiateOidc.js +14 -1
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/log.d.ts +27 -0
  17. package/dist/log.d.ts.map +1 -0
  18. package/dist/log.js +37 -0
  19. package/dist/providers/OidcProvider.d.ts +1 -1
  20. package/dist/providers/OidcProvider.d.ts.map +1 -1
  21. package/dist/providers/OidcProvider.js +46 -5
  22. package/dist/providers/ProviderRegistry.d.ts +2 -1
  23. package/dist/providers/ProviderRegistry.d.ts.map +1 -1
  24. package/dist/providers/ProviderRegistry.js +10 -2
  25. package/dist/schemas/CallbackRequest.d.ts +3 -6
  26. package/dist/schemas/CallbackRequest.d.ts.map +1 -1
  27. package/dist/schemas/InitiateRequest.d.ts +3 -6
  28. package/dist/schemas/InitiateRequest.d.ts.map +1 -1
  29. package/dist/schemas/OidcSession.d.ts +5 -0
  30. package/dist/schemas/OidcSession.d.ts.map +1 -1
  31. package/dist/utils/response-utils.d.ts +16 -5
  32. package/dist/utils/response-utils.d.ts.map +1 -1
  33. package/dist/utils/response-utils.js +31 -10
  34. package/examples/multi-provider.ts +1 -1
  35. package/package.json +8 -11
  36. package/spec/helpers/test-helpers.ts +14 -0
  37. package/spec/plugin/OidcPlugin.spec.ts +104 -3
  38. package/spec/providers/ProviderRegistry.spec.ts +8 -7
  39. package/spec/utils/response-utils.spec.ts +85 -0
  40. package/src/OidcPlugin.ts +54 -21
  41. package/src/OidcPluginOptions.ts +84 -9
  42. package/src/OidcProviderConfig.ts +23 -1
  43. package/src/handlers/CallbackOidc.ts +66 -5
  44. package/src/handlers/InitiateOidc.ts +19 -1
  45. package/src/index.ts +1 -1
  46. package/src/log.ts +33 -0
  47. package/src/providers/OidcProvider.ts +60 -7
  48. package/src/providers/ProviderRegistry.ts +22 -3
  49. package/src/schemas/CallbackRequest.ts +4 -6
  50. package/src/schemas/InitiateRequest.ts +3 -6
  51. package/src/schemas/OidcSession.ts +6 -0
  52. package/src/utils/response-utils.ts +53 -10
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Tests for formatTokenResponse — covers JSON mode, fragment mode (default,
3
+ * backwards compatible), and query mode (including the tricky case where
4
+ * the redirectUrl already carries a fragment that must be preserved).
5
+ */
6
+
7
+ import { formatTokenResponse } from "../../src/utils/response-utils";
8
+
9
+ describe("response-utils", () => {
10
+ describe("formatTokenResponse", () => {
11
+ const token = "jwt.token.value";
12
+ const user = { id: "u1", email: "a@b.c" };
13
+
14
+ describe("JSON mode", () => {
15
+ it("returns { data: { user, token } } when response_type=json", () => {
16
+ const result = formatTokenResponse(token, user, "https://app/cb", "json");
17
+ expect(result).toEqual({ data: { user, token } });
18
+ });
19
+
20
+ it("ignores tokenDelivery when response_type=json", () => {
21
+ const result = formatTokenResponse(token, user, "https://app/cb", "json", "query");
22
+ expect(result).toEqual({ data: { user, token } });
23
+ });
24
+ });
25
+
26
+ describe("fragment mode (default)", () => {
27
+ it("appends #token=... to a plain URL", () => {
28
+ const result = formatTokenResponse(token, user, "https://app/cb");
29
+ expect(result.status).toBe(302);
30
+ expect(result.headers.Location).toBe(`https://app/cb#token=${encodeURIComponent(token)}`);
31
+ });
32
+
33
+ it("uses & when the URL already has a fragment", () => {
34
+ const result = formatTokenResponse(token, user, "https://app/cb#foo=bar");
35
+ expect(result.headers.Location).toBe(`https://app/cb#foo=bar&token=${encodeURIComponent(token)}`);
36
+ });
37
+
38
+ it("preserves existing query string", () => {
39
+ const result = formatTokenResponse(token, user, "https://app/cb?next=/dashboard");
40
+ expect(result.headers.Location).toBe(`https://app/cb?next=/dashboard#token=${encodeURIComponent(token)}`);
41
+ });
42
+
43
+ it("URL-encodes tokens with special characters", () => {
44
+ const weird = "a b+c/d=e";
45
+ const result = formatTokenResponse(weird, user, "https://app/cb");
46
+ expect(result.headers.Location).toBe(`https://app/cb#token=${encodeURIComponent(weird)}`);
47
+ });
48
+
49
+ it("is the default when tokenDelivery is omitted", () => {
50
+ const implicit = formatTokenResponse(token, user, "https://app/cb");
51
+ const explicit = formatTokenResponse(token, user, "https://app/cb", undefined, "fragment");
52
+ expect(implicit.headers.Location).toBe(explicit.headers.Location);
53
+ });
54
+ });
55
+
56
+ describe("query mode", () => {
57
+ it("appends ?token=... to a plain URL", () => {
58
+ const result = formatTokenResponse(token, user, "myapp://cb", undefined, "query");
59
+ expect(result.status).toBe(302);
60
+ expect(result.headers.Location).toBe(`myapp://cb?token=${encodeURIComponent(token)}`);
61
+ });
62
+
63
+ it("uses & when the URL already has a query string", () => {
64
+ const result = formatTokenResponse(token, user, "myapp://cb?foo=1", undefined, "query");
65
+ expect(result.headers.Location).toBe(`myapp://cb?foo=1&token=${encodeURIComponent(token)}`);
66
+ });
67
+
68
+ it("splices the query param BEFORE an existing fragment", () => {
69
+ const result = formatTokenResponse(token, user, "myapp://cb#state=xyz", undefined, "query");
70
+ expect(result.headers.Location).toBe(`myapp://cb?token=${encodeURIComponent(token)}#state=xyz`);
71
+ });
72
+
73
+ it("splices with & before fragment when query already present", () => {
74
+ const result = formatTokenResponse(token, user, "myapp://cb?foo=1#bar", undefined, "query");
75
+ expect(result.headers.Location).toBe(`myapp://cb?foo=1&token=${encodeURIComponent(token)}#bar`);
76
+ });
77
+
78
+ it("URL-encodes tokens with special characters", () => {
79
+ const weird = "a b+c/d=e";
80
+ const result = formatTokenResponse(weird, user, "myapp://cb", undefined, "query");
81
+ expect(result.headers.Location).toBe(`myapp://cb?token=${encodeURIComponent(weird)}`);
82
+ });
83
+ });
84
+ });
85
+ });
package/src/OidcPlugin.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import { FlinkApp, FlinkPlugin, log } from "@flink-app/flink";
2
+ import { oidcLog, maskSecret } from "./log";
2
3
  import { Db } from "mongodb";
3
4
  import { OidcPluginOptions } from "./OidcPluginOptions";
4
5
  import { OidcPluginContext } from "./OidcPluginContext";
5
6
  import { OidcInternalContext } from "./OidcInternalContext";
6
7
  import OidcSessionRepo from "./repos/OidcSessionRepo";
7
8
  import OidcConnectionRepo from "./repos/OidcConnectionRepo";
8
- import { encryptToken, decryptToken, validateEncryptionSecret } from "./utils/encryption-utils";
9
+ import { decryptToken, validateEncryptionSecret } from "./utils/encryption-utils";
9
10
  import OidcConnection from "./schemas/OidcConnection";
10
11
  import { ProviderRegistry } from "./providers/ProviderRegistry";
11
12
  import * as InitiateOidc from "./handlers/InitiateOidc";
@@ -84,16 +85,18 @@ import * as CallbackOidc from "./handlers/CallbackOidc";
84
85
  * });
85
86
  * ```
86
87
  */
87
- export function oidcPlugin(options: OidcPluginOptions): FlinkPlugin {
88
+ export function oidcPlugin<TCtx = any>(options: OidcPluginOptions<TCtx>): FlinkPlugin {
88
89
  // Validation
89
- if (!options.providers || Object.keys(options.providers).length === 0) {
90
- throw new Error("OIDC Plugin: At least one provider must be configured");
90
+ const providers = options.providers || {};
91
+
92
+ if (Object.keys(providers).length === 0 && !options.providerLoader) {
93
+ throw new Error("OIDC Plugin: At least one provider must be configured, or providerLoader must be provided");
91
94
  }
92
95
 
93
96
  // Validate provider configurations
94
- const configuredProviders = Object.keys(options.providers);
97
+ const configuredProviders = Object.keys(providers);
95
98
  for (const providerName of configuredProviders) {
96
- const providerConfig = options.providers[providerName];
99
+ const providerConfig = providers[providerName];
97
100
  if (!providerConfig) continue;
98
101
 
99
102
  if (!providerConfig.issuer) {
@@ -102,9 +105,6 @@ export function oidcPlugin(options: OidcPluginOptions): FlinkPlugin {
102
105
  if (!providerConfig.clientId) {
103
106
  throw new Error(`OIDC Plugin: ${providerName} clientId is required`);
104
107
  }
105
- if (!providerConfig.clientSecret) {
106
- throw new Error(`OIDC Plugin: ${providerName} clientSecret is required`);
107
- }
108
108
  if (!providerConfig.callbackUrl) {
109
109
  throw new Error(`OIDC Plugin: ${providerName} callbackUrl is required`);
110
110
  }
@@ -118,6 +118,17 @@ export function oidcPlugin(options: OidcPluginOptions): FlinkPlugin {
118
118
  );
119
119
  }
120
120
  }
121
+
122
+ oidcLog.debug(
123
+ `Provider "${providerName}" config:`,
124
+ `issuer=${providerConfig.issuer}`,
125
+ `clientId=${providerConfig.clientId}`,
126
+ `clientSecret=${maskSecret(providerConfig.clientSecret)}`,
127
+ `callbackUrl=${providerConfig.callbackUrl}`,
128
+ providerConfig.discoveryUrl ? `discoveryUrl=${providerConfig.discoveryUrl}` : `authorizationEndpoint=${providerConfig.authorizationEndpoint} tokenEndpoint=${providerConfig.tokenEndpoint}`,
129
+ `scope=${(providerConfig.scope || ["openid", "email", "profile"]).join(" ")}`,
130
+ providerConfig.tokenEndpointAuthMethod ? `authMethod=${providerConfig.tokenEndpointAuthMethod}` : ""
131
+ );
121
132
  }
122
133
 
123
134
  if (!options.onAuthSuccess) {
@@ -127,24 +138,36 @@ export function oidcPlugin(options: OidcPluginOptions): FlinkPlugin {
127
138
  // Determine encryption key
128
139
  let encryptionKey = options.encryptionKey;
129
140
  if (!encryptionKey) {
130
- // Derive from first configured provider's client secret
131
- const firstProvider = configuredProviders[0];
132
- const firstProviderConfig = options.providers[firstProvider];
133
- if (firstProviderConfig) {
134
- encryptionKey = firstProviderConfig.clientSecret;
141
+ // Derive from the first configured provider that has a client secret
142
+ const providerWithSecret = configuredProviders.find((name) => providers[name]?.clientSecret);
143
+ if (providerWithSecret) {
144
+ encryptionKey = providers[providerWithSecret].clientSecret;
135
145
  log.warn(
136
146
  "OIDC Plugin: No encryption key provided, deriving from client secret. " + "For better security, provide a dedicated encryptionKey in options."
137
147
  );
148
+ oidcLog.debug(`Encryption key derived from provider "${providerWithSecret}" clientSecret`);
138
149
  }
150
+ } else {
151
+ oidcLog.debug(`Encryption key: ${maskSecret(encryptionKey)} (explicit)`);
139
152
  }
140
153
 
141
- if (!encryptionKey || encryptionKey.length < 32) {
142
- throw new Error("OIDC Plugin: Encryption key must be at least 32 characters");
154
+ // Encryption key is required when storing tokens
155
+ if (options.storeTokens) {
156
+ if (!encryptionKey || encryptionKey.length < 32) {
157
+ throw new Error(
158
+ "OIDC Plugin: Encryption key must be at least 32 characters. " +
159
+ "Provide an explicit encryptionKey when using storeTokens with public clients (no clientSecret)."
160
+ );
161
+ }
162
+ validateEncryptionSecret(encryptionKey);
163
+ } else if (encryptionKey) {
164
+ // Validate if provided, even when not storing tokens (used for getConnection/getConnections)
165
+ if (encryptionKey.length < 32) {
166
+ throw new Error("OIDC Plugin: Encryption key must be at least 32 characters");
167
+ }
168
+ validateEncryptionSecret(encryptionKey);
143
169
  }
144
170
 
145
- // Validate encryption key
146
- validateEncryptionSecret(encryptionKey);
147
-
148
171
  let flinkApp: FlinkApp<OidcInternalContext>;
149
172
  let sessionRepo: OidcSessionRepo;
150
173
  let connectionRepo: OidcConnectionRepo;
@@ -167,6 +190,16 @@ export function oidcPlugin(options: OidcPluginOptions): FlinkPlugin {
167
190
  const sessionsCollectionName = options.sessionsCollectionName || "oidc_sessions";
168
191
  const connectionsCollectionName = options.connectionsCollectionName || "oidc_connections";
169
192
 
193
+ oidcLog.debug(
194
+ `Init: sessionsCollection=${sessionsCollectionName}`,
195
+ `connectionsCollection=${connectionsCollectionName}`,
196
+ `storeTokens=${!!options.storeTokens}`,
197
+ `sessionTTL=${options.sessionTTL ?? 600}s`,
198
+ `registerRoutes=${options.registerRoutes !== false}`,
199
+ configuredProviders.length > 0 ? `staticProviders=[${configuredProviders.join(", ")}]` : "staticProviders=[] (dynamic only)",
200
+ options.providerLoader ? "providerLoader=yes" : "providerLoader=no"
201
+ );
202
+
170
203
  sessionRepo = new OidcSessionRepo(sessionsCollectionName, db);
171
204
  connectionRepo = new OidcConnectionRepo(connectionsCollectionName, db);
172
205
 
@@ -180,7 +213,7 @@ export function oidcPlugin(options: OidcPluginOptions): FlinkPlugin {
180
213
  log.info(`OIDC Plugin: Created TTL index on ${sessionsCollectionName} with ${sessionTTL}s expiration`);
181
214
 
182
215
  // Initialize provider registry
183
- providerRegistry = new ProviderRegistry(options.providers, options.providerLoader);
216
+ providerRegistry = new ProviderRegistry(providers, options.providerLoader, flinkApp.ctx);
184
217
 
185
218
  // Store provider registry in app context for handlers to access
186
219
  (flinkApp.ctx as any).oidcProviderRegistry = providerRegistry;
@@ -192,7 +225,7 @@ export function oidcPlugin(options: OidcPluginOptions): FlinkPlugin {
192
225
  flinkApp.addHandler(CallbackOidc);
193
226
  }
194
227
 
195
- log.info(`OIDC Plugin initialized with providers: ${configuredProviders.join(", ")}`);
228
+ log.info(`OIDC Plugin initialized${configuredProviders.length > 0 ? ` with providers: ${configuredProviders.join(", ")}` : " with dynamic provider loader"}`);
196
229
  } catch (error) {
197
230
  log.error("Failed to initialize OIDC Plugin:", error);
198
231
  throw error;
@@ -1,6 +1,7 @@
1
1
  import OidcProfile from "./schemas/OidcProfile";
2
2
  import OidcTokenSet from "./schemas/OidcTokenSet";
3
3
  import { OidcProviderConfig } from "./OidcProviderConfig";
4
+ import { TokenDeliveryMode } from "./utils/response-utils";
4
5
 
5
6
  /**
6
7
  * OIDC error information passed to onAuthError callback
@@ -41,7 +42,9 @@ export interface AuthSuccessCallbackResponse {
41
42
 
42
43
  /**
43
44
  * Optional redirect URL after authentication
44
- * Plugin will append #token=... to this URL
45
+ *
46
+ * The plugin will attach the JWT token to this URL according to the
47
+ * top-level `tokenDelivery` option (fragment by default, or query).
45
48
  */
46
49
  redirectUrl?: string;
47
50
  }
@@ -64,7 +67,7 @@ export interface AuthErrorCallbackResponse {
64
67
  * The onAuthSuccess callback receives the Flink context as a second parameter,
65
68
  * allowing the application to generate JWT tokens using ctx.plugins.jwtAuth.createToken().
66
69
  */
67
- export interface OidcPluginOptions {
70
+ export interface OidcPluginOptions<TCtx = any> {
68
71
  /**
69
72
  * OIDC provider configurations
70
73
  * Key = provider name (used in URLs: /oidc/{provider}/initiate)
@@ -89,7 +92,7 @@ export interface OidcPluginOptions {
89
92
  * }
90
93
  * }
91
94
  */
92
- providers: Record<string, OidcProviderConfig>;
95
+ providers?: Record<string, OidcProviderConfig>;
93
96
 
94
97
  /**
95
98
  * Whether to store OIDC tokens for future API access
@@ -120,7 +123,7 @@ export interface OidcPluginOptions {
120
123
  *
121
124
  * Example:
122
125
  * ```typescript
123
- * onAuthSuccess: async ({ profile, claims, provider }, ctx) => {
126
+ * onAuthSuccess: async ({ profile, claims, provider, metadata }, ctx) => {
124
127
  * // Find user by OIDC subject + issuer
125
128
  * let user = await ctx.repos.userRepo.getOne({
126
129
  * 'oidcConnections.subject': claims.sub,
@@ -172,13 +175,35 @@ export interface OidcPluginOptions {
172
175
  */
173
176
  provider: string;
174
177
 
178
+ /**
179
+ * The redirectUri passed on the initiate request (stored in session)
180
+ * Use this to redirect back to the originating client application.
181
+ *
182
+ * Example:
183
+ * ```typescript
184
+ * return { user, token, redirectUrl: redirectUri || DEFAULT_REDIRECT_URL };
185
+ * ```
186
+ */
187
+ redirectUri: string;
188
+
189
+ /**
190
+ * Arbitrary metadata collected from meta.* query parameters on the initiate request.
191
+ * Opaque to the plugin — it only transports these values.
192
+ *
193
+ * Example: GET /oidc/acme/initiate?meta.intent=admin&meta.source=mobile
194
+ * Produces: { intent: "admin", source: "mobile" }
195
+ *
196
+ * Empty object ({}) if no meta.* params were provided.
197
+ */
198
+ metadata: Record<string, string>;
199
+
175
200
  /**
176
201
  * OIDC tokens (only if storeTokens: true)
177
202
  * Includes accessToken, idToken, refreshToken
178
203
  */
179
204
  tokens?: OidcTokenSet;
180
205
  },
181
- ctx: any
206
+ ctx: TCtx
182
207
  ) => Promise<AuthSuccessCallbackResponse>;
183
208
 
184
209
  /**
@@ -208,7 +233,21 @@ export interface OidcPluginOptions {
208
233
  * }
209
234
  * ```
210
235
  */
211
- onAuthError?: (params: { error: OidcError; provider: string }) => Promise<AuthErrorCallbackResponse>;
236
+ onAuthError?: (params: {
237
+ error: OidcError;
238
+ provider: string;
239
+ /**
240
+ * The redirectUri from the initiate request (if available)
241
+ * May be undefined for errors that occur before session lookup
242
+ * (e.g., IdP returns an error before we can retrieve the session)
243
+ */
244
+ redirectUri?: string;
245
+ /**
246
+ * Metadata from the initiate request (meta.* query params, prefix stripped).
247
+ * Empty object ({}) if no meta.* params were provided or session was not found.
248
+ */
249
+ metadata: Record<string, string>;
250
+ }) => Promise<AuthErrorCallbackResponse>;
212
251
 
213
252
  /**
214
253
  * Dynamic provider loader callback
@@ -219,11 +258,12 @@ export interface OidcPluginOptions {
219
258
  * Use this for multi-tenant scenarios where each organization has different IdPs.
220
259
  *
221
260
  * @param providerName - Name of the provider to load
261
+ * @param ctx - Flink context with access to repos and plugins
222
262
  * @returns Provider configuration or null if not found
223
263
  *
224
264
  * Example:
225
265
  * ```typescript
226
- * providerLoader: async (providerName) => {
266
+ * providerLoader: async (providerName, ctx) => {
227
267
  * const config = await ctx.repos.oidcProviderRepo.getByName(providerName);
228
268
  * if (!config || !config.enabled) {
229
269
  * return null;
@@ -239,7 +279,7 @@ export interface OidcPluginOptions {
239
279
  * }
240
280
  * ```
241
281
  */
242
- providerLoader?: (providerName: string) => Promise<OidcProviderConfig | null>;
282
+ providerLoader?: (providerName: string, ctx: TCtx) => Promise<OidcProviderConfig | null>;
243
283
 
244
284
  /**
245
285
  * Custom collection name for OIDC sessions
@@ -265,7 +305,9 @@ export interface OidcPluginOptions {
265
305
 
266
306
  /**
267
307
  * Encryption key for encrypting stored OIDC tokens
268
- * If not provided, will be derived from first configured provider's client secret
308
+ * If not provided, will be derived from the first configured provider's client secret
309
+ *
310
+ * Required when using storeTokens with public clients (no clientSecret).
269
311
  *
270
312
  * Recommended: Use a dedicated encryption key from environment variables
271
313
  * Must be at least 32 characters
@@ -274,6 +316,39 @@ export interface OidcPluginOptions {
274
316
  */
275
317
  encryptionKey?: string;
276
318
 
319
+ /**
320
+ * Controls how the JWT token is delivered back to the client on the
321
+ * post-callback redirect (ignored when the caller uses `?response_type=json`).
322
+ *
323
+ * - `"fragment"` (default): appends `#token=...` to the redirectUrl. Best
324
+ * for web browsers — URL fragments are not sent to the server in HTTP
325
+ * requests, which preserves a small but real security property.
326
+ * - `"query"`: appends `?token=...` (or `&token=...`). Required for native
327
+ * mobile flows where the callback comes back via a custom URL scheme,
328
+ * because iOS `ASWebAuthenticationSession` strips URL fragments before
329
+ * delivering the URL to the app.
330
+ * - function: called once per callback to decide the delivery mode. Use
331
+ * this when one app serves both web and native clients. The function
332
+ * receives the resolved redirectUrl and the metadata bag from the
333
+ * initiate request, so the host can route by URL scheme, by
334
+ * `meta.intent`, or any other signal — the plugin makes no assumptions.
335
+ *
336
+ * Default: `"fragment"` (unchanged from prior versions).
337
+ *
338
+ * Example — route native (custom-scheme) clients to query, browsers to fragment:
339
+ * ```typescript
340
+ * tokenDelivery: ({ redirectUrl }) =>
341
+ * /^https?:/i.test(redirectUrl) ? "fragment" : "query",
342
+ * ```
343
+ */
344
+ tokenDelivery?:
345
+ | TokenDeliveryMode
346
+ | ((params: {
347
+ redirectUrl: string;
348
+ provider: string;
349
+ metadata: Record<string, string>;
350
+ }) => TokenDeliveryMode);
351
+
277
352
  /**
278
353
  * Whether to register OIDC routes automatically
279
354
  * If false, you must manually handle OIDC flow
@@ -20,8 +20,20 @@ export interface OidcProviderConfig {
20
20
  /**
21
21
  * OAuth 2.0 client secret
22
22
  * Provided by the IdP - keep this secure!
23
+ *
24
+ * Optional for public clients using PKCE-only authentication.
25
+ * When omitted, token_endpoint_auth_method defaults to "none".
23
26
  */
24
- clientSecret: string;
27
+ clientSecret?: string;
28
+
29
+ /**
30
+ * Token endpoint authentication method
31
+ *
32
+ * - "client_secret_basic": HTTP Basic auth with client_id and client_secret (default when clientSecret is provided)
33
+ * - "client_secret_post": client_id and client_secret in POST body
34
+ * - "none": No client authentication (default when clientSecret is omitted, for public/PKCE-only clients)
35
+ */
36
+ tokenEndpointAuthMethod?: "client_secret_basic" | "client_secret_post" | "none";
25
37
 
26
38
  /**
27
39
  * Callback URL for OAuth redirect
@@ -84,4 +96,14 @@ export interface OidcProviderConfig {
84
96
  * e.g., { "department": "custom:department", "role": "custom:role" }
85
97
  */
86
98
  claimMapping?: Record<string, string>;
99
+
100
+ /**
101
+ * Whether to fetch additional claims from the UserInfo endpoint.
102
+ * Default: true
103
+ *
104
+ * Set to `false` for IdPs whose UserInfo endpoint is unreliable or
105
+ * returns no additional data beyond what's already in the ID token.
106
+ * When disabled, profiles are built from ID token claims only.
107
+ */
108
+ useUserInfoEndpoint?: boolean;
87
109
  }
@@ -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 { oidcLog } from "../log";
22
23
 
23
24
  /**
24
25
  * Path parameters for the handler
@@ -48,21 +49,40 @@ const CallbackOidc: GetHandler<any, any, PathParams, CallbackRequest> = async ({
48
49
  const { provider } = req.params;
49
50
  const { code, state, error: oidcError, error_description, response_type } = req.query;
50
51
 
52
+ // Track session outside try/catch so redirectUri and metadata are available in error handling
53
+ let sessionRedirectUri: string | undefined;
54
+ let sessionMetadata: Record<string, string> = {};
55
+
51
56
  try {
52
57
  // Validate provider and response_type
53
58
  validateProvider(provider);
54
59
  validateResponseType(response_type);
55
60
 
61
+ oidcLog.debug(`Callback: provider="${provider}" hasCode=${!!code} hasState=${!!state} responseType="${response_type || "redirect"}"`);
62
+
56
63
  // Check for OIDC provider errors (e.g., user denied access)
57
64
  if (oidcError) {
65
+ oidcLog.debug(`Callback: IdP returned error="${oidcError}" description="${error_description || ""}"`);
58
66
  const error = handleProviderError({ error: oidcError, error_description });
59
67
 
68
+ // Try to retrieve session redirectUri (state may be available even on IdP errors)
69
+ if (state) {
70
+ const errorSession = await ctx.repos.oidcSessionRepo.getByState(state);
71
+ sessionRedirectUri = errorSession?.redirectUri;
72
+ sessionMetadata = errorSession?.metadata ?? {};
73
+ if (errorSession) {
74
+ await ctx.repos.oidcSessionRepo.deleteBySessionId(errorSession.sessionId);
75
+ }
76
+ }
77
+
60
78
  // Call onAuthError callback if provided
61
79
  const { options } = ctx.plugins.oidc;
62
80
  if (options.onAuthError) {
63
81
  const errorResult = await options.onAuthError({
64
82
  error,
65
83
  provider,
84
+ redirectUri: sessionRedirectUri,
85
+ metadata: sessionMetadata,
66
86
  });
67
87
 
68
88
  if (errorResult.redirectUrl) {
@@ -89,9 +109,12 @@ const CallbackOidc: GetHandler<any, any, PathParams, CallbackRequest> = async ({
89
109
  const session = await ctx.repos.oidcSessionRepo.getByState(state);
90
110
 
91
111
  if (!session) {
112
+ oidcLog.debug(`Callback: no session found for state (may be expired)`);
92
113
  throw createOidcError(OidcErrorCodes.SESSION_EXPIRED, "OIDC session not found or expired. Please try logging in again.", { state });
93
114
  }
94
115
 
116
+ oidcLog.debug(`Callback: session found sessionId=${session.sessionId} provider="${session.provider}" redirectUri="${session.redirectUri}"`);
117
+
95
118
  // Validate state parameter (CSRF protection)
96
119
  if (!validateState(state, session.state)) {
97
120
  throw createOidcError(OidcErrorCodes.INVALID_STATE, "Invalid state parameter. Possible CSRF attack detected.", {
@@ -99,8 +122,13 @@ const CallbackOidc: GetHandler<any, any, PathParams, CallbackRequest> = async ({
99
122
  });
100
123
  }
101
124
 
125
+ // Track redirectUri and metadata for error handling
126
+ sessionRedirectUri = session.redirectUri;
127
+ sessionMetadata = session.metadata ?? {};
128
+
102
129
  // Delete session immediately after validation (one-time use)
103
130
  await ctx.repos.oidcSessionRepo.deleteBySessionId(session.sessionId);
131
+ oidcLog.debug(`Callback: state validated, session deleted (one-time use)`);
104
132
 
105
133
  // Get plugin options
106
134
  const { options } = ctx.plugins.oidc;
@@ -121,14 +149,26 @@ const CallbackOidc: GetHandler<any, any, PathParams, CallbackRequest> = async ({
121
149
  nonce: session.nonce,
122
150
  });
123
151
 
124
- // Build user profile from ID token and UserInfo
125
- const profile = await oidcProvider.buildProfile(tokenSet, true);
152
+ oidcLog.debug(
153
+ `Callback: token exchange successful`,
154
+ `sub="${tokenSet.claims.sub}"`,
155
+ `iss="${tokenSet.claims.iss}"`,
156
+ `email="${tokenSet.claims.email || "(none)"}"`,
157
+ `hasRefreshToken=${!!tokenSet.refreshToken}`,
158
+ `expiresIn=${tokenSet.expiresIn ?? "(none)"}s`
159
+ );
160
+
161
+ // Build user profile from ID token and (optionally) UserInfo
162
+ const profile = await oidcProvider.buildProfile(tokenSet, oidcProvider.config.useUserInfoEndpoint ?? true);
163
+ oidcLog.debug(`Callback: profile built id="${profile.id}" email="${profile.email || "(none)"}" name="${profile.name || "(none)"}"`);
126
164
 
127
165
  // Call onAuthSuccess callback to create/link user and generate JWT token
128
166
  const authSuccessParams = {
129
167
  profile,
130
168
  claims: tokenSet.claims,
131
169
  provider,
170
+ redirectUri: session.redirectUri,
171
+ metadata: sessionMetadata,
132
172
  ...(options.storeTokens ? { tokens: tokenSet } : {}),
133
173
  };
134
174
 
@@ -148,6 +188,8 @@ const CallbackOidc: GetHandler<any, any, PathParams, CallbackRequest> = async ({
148
188
  const errorResult = await options.onAuthError({
149
189
  error: oidcError,
150
190
  provider,
191
+ redirectUri: session.redirectUri,
192
+ metadata: sessionMetadata,
151
193
  });
152
194
 
153
195
  if (errorResult.redirectUrl) {
@@ -172,7 +214,7 @@ const CallbackOidc: GetHandler<any, any, PathParams, CallbackRequest> = async ({
172
214
  // Store OIDC connection if token storage is enabled
173
215
  if (options.storeTokens && user && user._id) {
174
216
  // Get encryption secret (use provider client secret or global encryption key)
175
- const staticProviderConfig = options.providers[provider];
217
+ const staticProviderConfig = options.providers?.[provider];
176
218
  const encryptionSecret = options.encryptionKey || staticProviderConfig?.clientSecret;
177
219
 
178
220
  if (!encryptionSecret) {
@@ -190,7 +232,8 @@ const CallbackOidc: GetHandler<any, any, PathParams, CallbackRequest> = async ({
190
232
  const existingConnection = await ctx.repos.oidcConnectionRepo.findByUserAndProvider(user._id, provider);
191
233
 
192
234
  if (existingConnection) {
193
- await ctx.repos.oidcConnectionRepo.updateOne(existingConnection._id!, {
235
+ oidcLog.debug(`Callback: updating existing connection for userId=${user._id} provider="${provider}"`);
236
+ await ctx.repos.oidcConnectionRepo.updateById(existingConnection._id!, {
194
237
  accessToken: encryptedAccessToken,
195
238
  idToken: encryptedIdToken,
196
239
  refreshToken: encryptedRefreshToken,
@@ -199,6 +242,7 @@ const CallbackOidc: GetHandler<any, any, PathParams, CallbackRequest> = async ({
199
242
  updatedAt: new Date(),
200
243
  });
201
244
  } else {
245
+ oidcLog.debug(`Callback: creating new connection for userId=${user._id} provider="${provider}" subject="${tokenSet.claims.sub}"`);
202
246
  await ctx.repos.oidcConnectionRepo.create({
203
247
  userId: user._id,
204
248
  provider,
@@ -217,8 +261,23 @@ const CallbackOidc: GetHandler<any, any, PathParams, CallbackRequest> = async ({
217
261
  }
218
262
  }
219
263
 
264
+ const finalRedirectUrl = redirectUrl || session.redirectUri;
265
+ oidcLog.debug(`Callback: auth complete, responding via ${response_type === "json" ? "JSON" : `redirect to "${finalRedirectUrl}"`}`);
266
+
267
+ // Resolve token delivery mode: literal, function, or default "fragment".
268
+ // The plugin stays a dumb transport — the host app makes the policy decision.
269
+ const tokenDeliverySetting = options.tokenDelivery ?? "fragment";
270
+ const resolvedTokenDelivery =
271
+ typeof tokenDeliverySetting === "function"
272
+ ? tokenDeliverySetting({
273
+ redirectUrl: finalRedirectUrl,
274
+ provider,
275
+ metadata: sessionMetadata,
276
+ })
277
+ : tokenDeliverySetting;
278
+
220
279
  // Return JWT token in requested format
221
- return formatTokenResponse(token, user, redirectUrl || session.redirectUri, response_type);
280
+ return formatTokenResponse(token, user, finalRedirectUrl, response_type, resolvedTokenDelivery);
222
281
  } catch (error: any) {
223
282
  log.error("OIDC callback error:", error);
224
283
 
@@ -231,6 +290,8 @@ const CallbackOidc: GetHandler<any, any, PathParams, CallbackRequest> = async ({
231
290
  const errorResult = await options.onAuthError({
232
291
  error,
233
292
  provider,
293
+ redirectUri: sessionRedirectUri,
294
+ metadata: sessionMetadata,
234
295
  });
235
296
 
236
297
  if (errorResult.redirectUrl) {