@flink-app/oidc-plugin 2.0.0-alpha.116 → 2.0.0-alpha.118

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +296 -17
  3. package/dist/OidcPlugin.d.ts.map +1 -1
  4. package/dist/OidcPlugin.js +35 -2
  5. package/dist/OidcPluginContext.d.ts +17 -0
  6. package/dist/OidcPluginContext.d.ts.map +1 -1
  7. package/dist/OidcPluginOptions.d.ts +123 -3
  8. package/dist/OidcPluginOptions.d.ts.map +1 -1
  9. package/dist/handlers/CallbackOidc.d.ts.map +1 -1
  10. package/dist/handlers/CallbackOidc.js +97 -20
  11. package/dist/handlers/InitiateOidc.d.ts.map +1 -1
  12. package/dist/handlers/InitiateOidc.js +12 -3
  13. package/dist/index.d.ts +2 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +4 -1
  16. package/dist/providers/ProviderRegistry.d.ts +27 -4
  17. package/dist/providers/ProviderRegistry.d.ts.map +1 -1
  18. package/dist/providers/ProviderRegistry.js +77 -21
  19. package/dist/utils/error-utils.d.ts +1 -0
  20. package/dist/utils/error-utils.d.ts.map +1 -1
  21. package/dist/utils/error-utils.js +1 -0
  22. package/dist/utils/redirect-uri-utils.d.ts +38 -0
  23. package/dist/utils/redirect-uri-utils.d.ts.map +1 -0
  24. package/dist/utils/redirect-uri-utils.js +92 -0
  25. package/examples/basic-oidc.ts +4 -0
  26. package/examples/multi-provider.ts +4 -0
  27. package/package.json +6 -6
  28. package/spec/handlers/CallbackOidc.spec.ts +330 -0
  29. package/spec/handlers/InitiateOidc.spec.ts +95 -1
  30. package/spec/helpers/test-helpers.ts +68 -0
  31. package/spec/plugin/OidcPlugin.spec.ts +165 -0
  32. package/spec/providers/ProviderRegistry.spec.ts +142 -0
  33. package/spec/utils/redirect-uri-utils.spec.ts +98 -0
  34. package/src/OidcPlugin.ts +38 -1
  35. package/src/OidcPluginContext.ts +18 -0
  36. package/src/OidcPluginOptions.ts +132 -3
  37. package/src/handlers/CallbackOidc.ts +109 -20
  38. package/src/handlers/InitiateOidc.ts +14 -3
  39. package/src/index.ts +10 -1
  40. package/src/providers/ProviderRegistry.ts +95 -22
  41. package/src/utils/error-utils.ts +1 -0
  42. package/src/utils/redirect-uri-utils.ts +116 -0
@@ -0,0 +1,330 @@
1
+ /**
2
+ * Tests for CallbackOidc handler
3
+ *
4
+ * Note: These are unit tests for handler logic with a mocked provider.
5
+ * For full integration tests, use the test-utils package in a demo app.
6
+ */
7
+
8
+ import CallbackOidc from "../../src/handlers/CallbackOidc";
9
+ import { OidcErrorCodes } from "../../src/utils/error-utils";
10
+ import { createMockCtx, createMockReq, createMockProfile, createMockTokenSet, createTestProviderConfig } from "../helpers/test-helpers";
11
+
12
+ const CALLBACK_URL = "https://api.example.com/oidc/test/callback";
13
+ const SESSION_REDIRECT_URI = "https://app.example.com/auth-callback";
14
+ const STATE = "a".repeat(64);
15
+
16
+ /**
17
+ * Run the callback handler with a mocked context, session and provider.
18
+ */
19
+ async function callback(pluginOptions: any, query: Record<string, any> = {}, sessionRedirectUri: string = SESSION_REDIRECT_URI) {
20
+ return (await callbackWithCtx(pluginOptions, query, sessionRedirectUri)).res;
21
+ }
22
+
23
+ /**
24
+ * Same as `callback`, but also returns the mocked context so repo writes can be asserted.
25
+ */
26
+ async function callbackWithCtx(pluginOptions: any, query: Record<string, any> = {}, sessionRedirectUri: string = SESSION_REDIRECT_URI) {
27
+ const providerConfig = createTestProviderConfig({ callbackUrl: CALLBACK_URL });
28
+
29
+ const ctx = createMockCtx(
30
+ {
31
+ providers: { test: providerConfig },
32
+ onAuthSuccess: async () => ({ user: { _id: "user-1" }, token: "jwt-token" }),
33
+ ...pluginOptions,
34
+ },
35
+ {
36
+ config: providerConfig,
37
+ exchangeCodeForToken: async () => createMockTokenSet(),
38
+ buildProfile: async () => createMockProfile(),
39
+ }
40
+ );
41
+
42
+ await ctx.repos.oidcSessionRepo.create({
43
+ sessionId: "session-1",
44
+ state: STATE,
45
+ codeVerifier: "verifier",
46
+ nonce: "nonce",
47
+ provider: "test",
48
+ redirectUri: sessionRedirectUri,
49
+ metadata: {},
50
+ createdAt: new Date(),
51
+ });
52
+
53
+ const res: any = await (CallbackOidc as any)({
54
+ ctx,
55
+ req: createMockReq({ provider: "test" }, { code: "auth-code", state: STATE, ...query }),
56
+ });
57
+
58
+ return { res, ctx };
59
+ }
60
+
61
+ describe("CallbackOidc handler", () => {
62
+ describe("Route configuration", () => {
63
+ it("should export correct route configuration", () => {
64
+ const { Route } = require("../../src/handlers/CallbackOidc");
65
+ expect(Route.path).toBe("/oidc/:provider/callback");
66
+ expect(Route.method).toBe("get");
67
+ });
68
+ });
69
+
70
+ describe("redirectUri allow-list", () => {
71
+ it("should redirect to the session redirectUri when the host returns no redirectUrl", async () => {
72
+ const res = await callback({ allowedRedirectUris: ["https://app.example.com"] });
73
+
74
+ expect(res.status).toBe(302);
75
+ expect(res.headers.Location).toContain(`${SESSION_REDIRECT_URI}#token=`);
76
+ });
77
+
78
+ it("should allow a host-returned redirectUrl inside the allow-list", async () => {
79
+ const res = await callback({
80
+ allowedRedirectUris: ["https://app.example.com"],
81
+ onAuthSuccess: async () => ({ user: { _id: "user-1" }, token: "jwt-token", redirectUrl: "https://app.example.com/welcome" }),
82
+ });
83
+
84
+ expect(res.status).toBe(302);
85
+ expect(res.headers.Location).toContain("https://app.example.com/welcome#token=");
86
+ });
87
+
88
+ it("should reject a host-returned redirectUrl outside the allow-list", async () => {
89
+ const res = await callback({
90
+ allowedRedirectUris: ["https://app.example.com"],
91
+ onAuthSuccess: async () => ({ user: { _id: "user-1" }, token: "jwt-token", redirectUrl: "https://evil.example/cb" }),
92
+ });
93
+
94
+ expect(res.status).toBe(400);
95
+ expect(res.error.code).toBe(OidcErrorCodes.INVALID_REDIRECT_URI);
96
+ });
97
+
98
+ it("should allow a relative host-returned redirectUrl", async () => {
99
+ const res = await callback({
100
+ allowedRedirectUris: ["https://app.example.com"],
101
+ onAuthSuccess: async () => ({ user: { _id: "user-1" }, token: "jwt-token", redirectUrl: "/dashboard" }),
102
+ });
103
+
104
+ expect(res.status).toBe(302);
105
+ expect(res.headers.Location).toContain("/dashboard#token=");
106
+ });
107
+
108
+ it("should allow any host-returned redirectUrl when no allow-list is configured", async () => {
109
+ const res = await callback({
110
+ onAuthSuccess: async () => ({ user: { _id: "user-1" }, token: "jwt-token", redirectUrl: "https://anywhere.example/cb" }),
111
+ });
112
+
113
+ expect(res.status).toBe(302);
114
+ expect(res.headers.Location).toContain("https://anywhere.example/cb#token=");
115
+ });
116
+
117
+ it("should not apply the allow-list to JSON responses", async () => {
118
+ const res = await callback(
119
+ {
120
+ allowedRedirectUris: ["https://app.example.com"],
121
+ onAuthSuccess: async () => ({ user: { _id: "user-1" }, token: "jwt-token", redirectUrl: "https://evil.example/cb" }),
122
+ },
123
+ { response_type: "json" }
124
+ );
125
+
126
+ expect(res.data.token).toBe("jwt-token");
127
+ });
128
+
129
+ it("should ignore an onAuthError redirectUrl outside the allow-list", async () => {
130
+ const res = await callback(
131
+ {
132
+ allowedRedirectUris: ["https://app.example.com"],
133
+ onAuthError: async () => ({ redirectUrl: "https://evil.example/cb" }),
134
+ },
135
+ { error: "access_denied" }
136
+ );
137
+
138
+ expect(res.status).toBe(400);
139
+ expect(res.error.code).toBe(OidcErrorCodes.ACCESS_DENIED);
140
+ });
141
+
142
+ it("should follow an onAuthError redirectUrl inside the allow-list", async () => {
143
+ const res = await callback(
144
+ {
145
+ allowedRedirectUris: ["https://app.example.com"],
146
+ onAuthError: async () => ({ redirectUrl: "https://app.example.com/login?error=denied" }),
147
+ },
148
+ { error: "access_denied" }
149
+ );
150
+
151
+ expect(res.status).toBe(302);
152
+ expect(res.headers.Location).toBe("https://app.example.com/login?error=denied");
153
+ });
154
+ });
155
+
156
+ describe("completion shapes", () => {
157
+ const user = { _id: "user-1" };
158
+
159
+ describe("token (default)", () => {
160
+ it("should attach the token as a fragment", async () => {
161
+ const res = await callback({ onAuthSuccess: async () => ({ user, token: "jwt-token", redirectUrl: SESSION_REDIRECT_URI }) });
162
+
163
+ expect(res.status).toBe(302);
164
+ expect(res.headers.Location).toBe(`${SESSION_REDIRECT_URI}#token=jwt-token`);
165
+ });
166
+
167
+ it("should attach the token as a query param when tokenDelivery is query", async () => {
168
+ const res = await callback({
169
+ tokenDelivery: "query",
170
+ onAuthSuccess: async () => ({ user, token: "jwt-token", redirectUrl: SESSION_REDIRECT_URI }),
171
+ });
172
+
173
+ expect(res.status).toBe(302);
174
+ expect(res.headers.Location).toBe(`${SESSION_REDIRECT_URI}?token=jwt-token`);
175
+ });
176
+
177
+ it("should return JSON for response_type=json", async () => {
178
+ const res = await callback({ onAuthSuccess: async () => ({ user, token: "jwt-token" }) }, { response_type: "json" });
179
+
180
+ expect(res.data.token).toBe("jwt-token");
181
+ expect(res.data.user).toEqual(user);
182
+ });
183
+ });
184
+
185
+ describe("verbatim redirect (no token)", () => {
186
+ it("should redirect verbatim without attaching a token", async () => {
187
+ const doneUrl = "https://app.example.com/done?code=one-time-code";
188
+ const res = await callback({ onAuthSuccess: async () => ({ user, redirectUrl: doneUrl }) });
189
+
190
+ expect(res.status).toBe(302);
191
+ expect(res.headers.Location).toBe(doneUrl);
192
+ expect(res.headers.Location).not.toContain("token=");
193
+ });
194
+
195
+ it("should apply the redirectUri allow-list", async () => {
196
+ const res = await callback({
197
+ allowedRedirectUris: ["https://app.example.com"],
198
+ onAuthSuccess: async () => ({ user, redirectUrl: "https://evil.example/cb?code=x" }),
199
+ });
200
+
201
+ expect(res.status).toBe(400);
202
+ expect(res.error.code).toBe(OidcErrorCodes.INVALID_REDIRECT_URI);
203
+ });
204
+
205
+ it("should allow a relative redirect", async () => {
206
+ const res = await callback({
207
+ allowedRedirectUris: ["https://app.example.com"],
208
+ onAuthSuccess: async () => ({ user, redirectUrl: "/done?code=one-time-code" }),
209
+ });
210
+
211
+ expect(res.status).toBe(302);
212
+ expect(res.headers.Location).toBe("/done?code=one-time-code");
213
+ });
214
+
215
+ it("should reject response_type=json when there is no token", async () => {
216
+ const res = await callback(
217
+ { onAuthSuccess: async () => ({ user, redirectUrl: "https://app.example.com/done?code=x" }) },
218
+ { response_type: "json" }
219
+ );
220
+
221
+ expect(res.status).toBe(400);
222
+ expect(res.error.code).toBe(OidcErrorCodes.INVALID_RESPONSE_TYPE);
223
+ });
224
+ });
225
+
226
+ describe("host-built response", () => {
227
+ it("should return the response as-is", async () => {
228
+ const hostResponse = {
229
+ status: 302,
230
+ headers: {
231
+ "Set-Cookie": "session=abc; HttpOnly; Secure; Path=/",
232
+ Location: "https://app.example.com/dashboard",
233
+ },
234
+ data: {},
235
+ };
236
+
237
+ const res = await callback({ onAuthSuccess: async () => ({ user, response: hostResponse }) });
238
+
239
+ expect(res).toBe(hostResponse);
240
+ });
241
+
242
+ it("should allow a JSON envelope of the host's own shape", async () => {
243
+ const res = await callback({
244
+ onAuthSuccess: async () => ({
245
+ user,
246
+ response: { data: { accessToken: "at", refreshToken: "rt", profile: { id: "user-1" } } },
247
+ }),
248
+ });
249
+
250
+ expect(res.data.accessToken).toBe("at");
251
+ expect(res.data.refreshToken).toBe("rt");
252
+ });
253
+
254
+ it("should take precedence over response_type=json", async () => {
255
+ const res = await callback(
256
+ { onAuthSuccess: async () => ({ user, response: { status: 204 } }) },
257
+ { response_type: "json" }
258
+ );
259
+
260
+ expect(res.status).toBe(204);
261
+ });
262
+ });
263
+
264
+ it("should fail when the callback returns no completion at all", async () => {
265
+ const res = await callback({ onAuthSuccess: async () => ({ user }) });
266
+
267
+ expect(res.status).toBe(400);
268
+ expect(res.error.code).toBe(OidcErrorCodes.JWT_GENERATION_FAILED);
269
+ });
270
+
271
+ describe("storeTokens", () => {
272
+ const storeTokensOptions = {
273
+ storeTokens: true,
274
+ encryptionKey: "test-encryption-key-at-least-32-characters",
275
+ };
276
+
277
+ async function connectionsWrittenFor(onAuthSuccess: any): Promise<any[]> {
278
+ const { ctx } = await callbackWithCtx({ ...storeTokensOptions, onAuthSuccess });
279
+
280
+ return ctx.repos.oidcConnectionRepo.created;
281
+ }
282
+
283
+ it("should write the connection for the token shape", async () => {
284
+ const connections = await connectionsWrittenFor(async () => ({ user, token: "jwt-token" }));
285
+
286
+ expect(connections.length).toBe(1);
287
+ expect(connections[0].userId).toBe("user-1");
288
+ });
289
+
290
+ it("should write the connection for the verbatim redirect shape", async () => {
291
+ const connections = await connectionsWrittenFor(async () => ({ user, redirectUrl: "https://app.example.com/done?code=x" }));
292
+
293
+ expect(connections.length).toBe(1);
294
+ expect(connections[0].userId).toBe("user-1");
295
+ });
296
+
297
+ it("should write the connection for the host-built response shape", async () => {
298
+ const connections = await connectionsWrittenFor(async () => ({ user, response: { status: 204 } }));
299
+
300
+ expect(connections.length).toBe(1);
301
+ expect(connections[0].userId).toBe("user-1");
302
+ });
303
+ });
304
+ });
305
+
306
+ describe("onAuthError response escape hatch", () => {
307
+ it("should return a host-built error response", async () => {
308
+ const hostResponse = {
309
+ status: 302,
310
+ headers: { "Set-Cookie": "session=; Max-Age=0", Location: "https://app.example.com/login?error=denied" },
311
+ data: {},
312
+ };
313
+
314
+ const res = await callback({ onAuthError: async () => ({ response: hostResponse }) }, { error: "access_denied" });
315
+
316
+ expect(res).toBe(hostResponse);
317
+ });
318
+
319
+ it("should take precedence over redirectUrl", async () => {
320
+ const res = await callback(
321
+ {
322
+ onAuthError: async () => ({ response: { status: 418 }, redirectUrl: "https://app.example.com/login" }),
323
+ },
324
+ { error: "access_denied" }
325
+ );
326
+
327
+ expect(res.status).toBe(418);
328
+ });
329
+ });
330
+ });
@@ -6,8 +6,32 @@
6
6
  */
7
7
 
8
8
  import InitiateOidc from "../../src/handlers/InitiateOidc";
9
- import { validateProvider } from "../../src/utils/error-utils";
9
+ import { validateProvider, OidcErrorCodes } from "../../src/utils/error-utils";
10
10
  import { generateState, generateSessionId, generateNonce } from "../../src/utils/state-utils";
11
+ import { createMockCtx, createMockReq, createTestProviderConfig } from "../helpers/test-helpers";
12
+
13
+ /**
14
+ * Run the initiate handler with a mocked context/request.
15
+ */
16
+ async function initiate(pluginOptions: any, query: Record<string, any>) {
17
+ const providerConfig = createTestProviderConfig({ callbackUrl: "https://api.example.com/oidc/test/callback" });
18
+
19
+ const ctx = createMockCtx(
20
+ {
21
+ providers: { test: providerConfig },
22
+ onAuthSuccess: async () => ({ user: {}, token: "jwt" }),
23
+ ...pluginOptions,
24
+ },
25
+ {
26
+ config: providerConfig,
27
+ getAuthorizationUrl: async () => "https://test-idp.example.com/authorize",
28
+ }
29
+ );
30
+
31
+ const res: any = await (InitiateOidc as any)({ ctx, req: createMockReq({ provider: "test" }, query) });
32
+
33
+ return { res, sessions: ctx.repos.oidcSessionRepo.sessions };
34
+ }
11
35
 
12
36
  describe("InitiateOidc handler", () => {
13
37
  describe("Route configuration", () => {
@@ -59,4 +83,74 @@ describe("InitiateOidc handler", () => {
59
83
  expect(nonce1.length).toBe(32);
60
84
  });
61
85
  });
86
+
87
+ describe("redirectUri allow-list", () => {
88
+ it("should accept any redirectUri when allowedRedirectUris is not set", async () => {
89
+ const { res, sessions } = await initiate({}, { redirectUri: "https://evil.example/cb" });
90
+
91
+ expect(res.status).toBe(302);
92
+ expect(sessions.length).toBe(1);
93
+ expect(sessions[0].redirectUri).toBe("https://evil.example/cb");
94
+ });
95
+
96
+ it("should accept an exact match", async () => {
97
+ const { res, sessions } = await initiate(
98
+ { allowedRedirectUris: ["https://app.example.com/login"] },
99
+ { redirectUri: "https://app.example.com/login" }
100
+ );
101
+
102
+ expect(res.status).toBe(302);
103
+ expect(sessions[0].redirectUri).toBe("https://app.example.com/login");
104
+ });
105
+
106
+ it("should accept any path below an origin entry", async () => {
107
+ const { res } = await initiate({ allowedRedirectUris: ["https://app.example.com"] }, { redirectUri: "https://app.example.com/login?next=/x" });
108
+
109
+ expect(res.status).toBe(302);
110
+ });
111
+
112
+ it("should accept a custom app scheme entry", async () => {
113
+ const { res } = await initiate({ allowedRedirectUris: ["myapp://oidc-callback"] }, { redirectUri: "myapp://oidc-callback" });
114
+
115
+ expect(res.status).toBe(302);
116
+ });
117
+
118
+ it("should accept a redirectUri approved by a predicate", async () => {
119
+ const { res } = await initiate(
120
+ { allowedRedirectUris: (uri: string, provider: string) => provider === "test" && uri.startsWith("https://app.example.com/") },
121
+ { redirectUri: "https://app.example.com/welcome" }
122
+ );
123
+
124
+ expect(res.status).toBe(302);
125
+ });
126
+
127
+ it("should always accept the provider callbackUrl", async () => {
128
+ const { res } = await initiate({ allowedRedirectUris: [] }, { redirectUri: "https://api.example.com/oidc/test/callback" });
129
+
130
+ expect(res.status).toBe(302);
131
+ });
132
+
133
+ it("should reject a redirectUri outside the allow-list without creating a session", async () => {
134
+ const { res, sessions } = await initiate({ allowedRedirectUris: ["https://app.example.com"] }, { redirectUri: "https://evil.example/cb" });
135
+
136
+ expect(res.status).toBe(400);
137
+ expect(res.error.code).toBe(OidcErrorCodes.INVALID_REDIRECT_URI);
138
+ expect(sessions.length).toBe(0);
139
+ });
140
+
141
+ it("should reject a redirectUri rejected by a predicate", async () => {
142
+ const { res, sessions } = await initiate({ allowedRedirectUris: () => false }, { redirectUri: "https://evil.example/cb" });
143
+
144
+ expect(res.status).toBe(400);
145
+ expect(res.error.code).toBe(OidcErrorCodes.INVALID_REDIRECT_URI);
146
+ expect(sessions.length).toBe(0);
147
+ });
148
+
149
+ it("should fall back to the provider callbackUrl when no redirectUri is passed", async () => {
150
+ const { res, sessions } = await initiate({ allowedRedirectUris: ["https://app.example.com"] }, {});
151
+
152
+ expect(res.status).toBe(302);
153
+ expect(sessions[0].redirectUri).toBe("https://api.example.com/oidc/test/callback");
154
+ });
155
+ });
62
156
  });
@@ -101,6 +101,74 @@ export function createMockProfile() {
101
101
  };
102
102
  }
103
103
 
104
+ /**
105
+ * Minimal in-memory OIDC session repo for handler unit tests
106
+ */
107
+ export function createMockSessionRepo() {
108
+ const sessions: any[] = [];
109
+
110
+ return {
111
+ sessions,
112
+ create: async (session: any) => {
113
+ sessions.push(session);
114
+ return session;
115
+ },
116
+ getByState: async (state: string) => sessions.find((s) => s.state === state) || null,
117
+ deleteBySessionId: async (sessionId: string) => {
118
+ const idx = sessions.findIndex((s) => s.sessionId === sessionId);
119
+ if (idx >= 0) sessions.splice(idx, 1);
120
+ },
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Create a mock Flink context for OIDC handler unit tests
126
+ *
127
+ * @param options - Plugin options as seen by the handlers (via ctx.plugins.oidc.options)
128
+ * @param provider - Mock OIDC provider instance returned by the provider registry
129
+ */
130
+ export function createMockCtx(options: any, provider?: any) {
131
+ const sessionRepo = createMockSessionRepo();
132
+
133
+ return {
134
+ repos: {
135
+ oidcSessionRepo: sessionRepo,
136
+ oidcConnectionRepo: {
137
+ /** Connections written during the test */
138
+ created: [] as any[],
139
+ findByUserAndProvider: async () => null,
140
+ create: async function (connection: any) {
141
+ this.created.push(connection);
142
+ return connection;
143
+ },
144
+ updateById: async () => undefined,
145
+ },
146
+ },
147
+ plugins: {
148
+ oidc: { options },
149
+ },
150
+ oidcProviderRegistry: {
151
+ getProvider: async () =>
152
+ provider || {
153
+ config: createTestProviderConfig(),
154
+ getAuthorizationUrl: async () => "https://test-idp.example.com/authorize?client_id=test-client-id",
155
+ },
156
+ },
157
+ } as any;
158
+ }
159
+
160
+ /**
161
+ * Create a mock Express request for OIDC handler unit tests
162
+ */
163
+ export function createMockReq(params: Record<string, string>, query: Record<string, any> = {}) {
164
+ return {
165
+ params,
166
+ query,
167
+ protocol: "https",
168
+ get: (header: string) => (header.toLowerCase() === "host" ? "api.example.com" : undefined),
169
+ } as any;
170
+ }
171
+
104
172
  /**
105
173
  * Wait for a condition to be true
106
174
  */