@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.
- package/CHANGELOG.md +566 -0
- package/README.md +23 -2
- package/dist/OidcPlugin.d.ts +1 -1
- package/dist/OidcPlugin.d.ts.map +1 -1
- package/dist/OidcPlugin.js +32 -18
- package/dist/OidcPluginOptions.d.ts +75 -8
- package/dist/OidcPluginOptions.d.ts.map +1 -1
- package/dist/OidcProviderConfig.d.ts +21 -1
- package/dist/OidcProviderConfig.d.ts.map +1 -1
- package/dist/handlers/CallbackOidc.d.ts.map +1 -1
- package/dist/handlers/CallbackOidc.js +50 -5
- package/dist/handlers/InitiateOidc.d.ts.map +1 -1
- package/dist/handlers/InitiateOidc.js +14 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/log.d.ts +27 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +37 -0
- package/dist/providers/OidcProvider.d.ts +1 -1
- package/dist/providers/OidcProvider.d.ts.map +1 -1
- package/dist/providers/OidcProvider.js +46 -5
- package/dist/providers/ProviderRegistry.d.ts +2 -1
- package/dist/providers/ProviderRegistry.d.ts.map +1 -1
- package/dist/providers/ProviderRegistry.js +10 -2
- package/dist/schemas/CallbackRequest.d.ts +3 -6
- package/dist/schemas/CallbackRequest.d.ts.map +1 -1
- package/dist/schemas/InitiateRequest.d.ts +3 -6
- package/dist/schemas/InitiateRequest.d.ts.map +1 -1
- package/dist/schemas/OidcSession.d.ts +5 -0
- package/dist/schemas/OidcSession.d.ts.map +1 -1
- package/dist/utils/response-utils.d.ts +16 -5
- package/dist/utils/response-utils.d.ts.map +1 -1
- package/dist/utils/response-utils.js +31 -10
- package/examples/multi-provider.ts +1 -1
- package/package.json +8 -11
- package/spec/helpers/test-helpers.ts +14 -0
- package/spec/plugin/OidcPlugin.spec.ts +104 -3
- package/spec/providers/ProviderRegistry.spec.ts +8 -7
- package/spec/utils/response-utils.spec.ts +85 -0
- package/src/OidcPlugin.ts +54 -21
- package/src/OidcPluginOptions.ts +84 -9
- package/src/OidcProviderConfig.ts +23 -1
- package/src/handlers/CallbackOidc.ts +66 -5
- package/src/handlers/InitiateOidc.ts +19 -1
- package/src/index.ts +1 -1
- package/src/log.ts +33 -0
- package/src/providers/OidcProvider.ts +60 -7
- package/src/providers/ProviderRegistry.ts +22 -3
- package/src/schemas/CallbackRequest.ts +4 -6
- package/src/schemas/InitiateRequest.ts +3 -6
- package/src/schemas/OidcSession.ts +6 -0
- 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 {
|
|
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
|
-
|
|
90
|
-
|
|
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(
|
|
97
|
+
const configuredProviders = Object.keys(providers);
|
|
95
98
|
for (const providerName of configuredProviders) {
|
|
96
|
-
const providerConfig =
|
|
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
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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(
|
|
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;
|
package/src/OidcPluginOptions.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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:
|
|
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: {
|
|
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
|
|
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
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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,
|
|
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) {
|