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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +296 -17
  3. package/dist/OidcPlugin.d.ts.map +1 -1
  4. package/dist/OidcPlugin.js +35 -2
  5. package/dist/OidcPluginContext.d.ts +17 -0
  6. package/dist/OidcPluginContext.d.ts.map +1 -1
  7. package/dist/OidcPluginOptions.d.ts +123 -3
  8. package/dist/OidcPluginOptions.d.ts.map +1 -1
  9. package/dist/handlers/CallbackOidc.d.ts.map +1 -1
  10. package/dist/handlers/CallbackOidc.js +97 -20
  11. package/dist/handlers/InitiateOidc.d.ts.map +1 -1
  12. package/dist/handlers/InitiateOidc.js +12 -3
  13. package/dist/index.d.ts +2 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +4 -1
  16. package/dist/providers/ProviderRegistry.d.ts +27 -4
  17. package/dist/providers/ProviderRegistry.d.ts.map +1 -1
  18. package/dist/providers/ProviderRegistry.js +77 -21
  19. package/dist/utils/error-utils.d.ts +1 -0
  20. package/dist/utils/error-utils.d.ts.map +1 -1
  21. package/dist/utils/error-utils.js +1 -0
  22. package/dist/utils/redirect-uri-utils.d.ts +38 -0
  23. package/dist/utils/redirect-uri-utils.d.ts.map +1 -0
  24. package/dist/utils/redirect-uri-utils.js +92 -0
  25. package/examples/basic-oidc.ts +4 -0
  26. package/examples/multi-provider.ts +4 -0
  27. package/package.json +6 -6
  28. package/spec/handlers/CallbackOidc.spec.ts +330 -0
  29. package/spec/handlers/InitiateOidc.spec.ts +95 -1
  30. package/spec/helpers/test-helpers.ts +68 -0
  31. package/spec/plugin/OidcPlugin.spec.ts +165 -0
  32. package/spec/providers/ProviderRegistry.spec.ts +142 -0
  33. package/spec/utils/redirect-uri-utils.spec.ts +98 -0
  34. package/src/OidcPlugin.ts +38 -1
  35. package/src/OidcPluginContext.ts +18 -0
  36. package/src/OidcPluginOptions.ts +132 -3
  37. package/src/handlers/CallbackOidc.ts +109 -20
  38. package/src/handlers/InitiateOidc.ts +14 -3
  39. package/src/index.ts +10 -1
  40. package/src/providers/ProviderRegistry.ts +95 -22
  41. package/src/utils/error-utils.ts +1 -0
  42. package/src/utils/redirect-uri-utils.ts +116 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,52 @@
1
1
  # @flink-app/oidc-plugin
2
2
 
3
+ ## 2.0.0-alpha.118
4
+
5
+ ### Minor Changes
6
+
7
+ - 581497b: **Security:** add `allowedRedirectUris` to restrict the `?redirectUri=` query parameter on `/oidc/:provider/initiate`.
8
+
9
+ The `redirectUri` decides where the host's JWT is delivered once the OIDC flow completes, and anyone can craft an initiate link. Until now every value was accepted, so a crafted link could turn a legitimate login into token exfiltration — the user authenticates at the real IdP and the plugin then redirects to the attacker's URL with `#token=...`.
10
+
11
+ `allowedRedirectUris` takes a list of exact URIs, bare origins (`https://app.example.com` covers every path below it, `myapp://cb` covers a native scheme) or a predicate. Relative paths and the provider's own `callbackUrl` are always accepted. A mismatch answers `400` with the new `OidcErrorCodes.INVALID_REDIRECT_URI` and no session is created. The same check is applied to a `redirectUrl` returned by `onAuthSuccess` / `onAuthError` when it differs from the session's, so echoing an attacker-supplied value back cannot bypass the list by accident.
12
+
13
+ Non-breaking: when the option is omitted the behaviour is unchanged (every `redirectUri` accepted) and the plugin logs a warning at startup. Set it on every deployment — a future major will make it required.
14
+
15
+ Also: OIDC error codes are now propagated to the `code` field of `400` responses from the initiate and callback handlers (previously always `badRequest`).
16
+
17
+ - 52d9152: Add `providerCacheTtlSec` and `ctx.plugins.oidc.invalidateProvider()` so provider config can change without a restart.
18
+
19
+ `ProviderRegistry` cached every resolved provider for the life of the process and never re-ran `providerLoader` for a name it had seen, so for hosts that keep provider config in a database, editing an issuer, client id, client secret, scope or discovery URL only took effect after restarting every instance. `clearCache()` existed on the registry but was not reachable through `ctx.plugins.oidc`.
20
+
21
+ - `providerCacheTtlSec` (default `0` = cache forever, unchanged): seconds a resolved provider is reused before its config is loaded and discovery is run again. Reloading is lazy, on the next request for that provider. If the reload fails, the previously cached instance keeps being used and the next attempt happens after another TTL — a discovery hiccup does not break logins that would otherwise work. If the loader returns `null` the cached instance is dropped and the request fails with `provider_not_configured`. Applies to static providers too, whose discovery metadata was otherwise never refreshed.
22
+ - `ctx.plugins.oidc.invalidateProvider(providerName?)`: drops cached instance(s) immediately on the instance it runs on — call it from the handler that saves IdP config; other instances converge within the TTL.
23
+
24
+ Both absent → exactly the previous behaviour. Non-breaking.
25
+
26
+ - 37c5251: Allow `onAuthSuccess` to complete the login without a `token`.
27
+
28
+ Until now `onAuthSuccess` had to return a JWT and the plugin alone decided how it reached the client (`#token=` fragment, `?token=` query, or `{user, token}` JSON). Applications that want an HttpOnly cookie session, a one-time code the client exchanges over POST, or their own login envelope had to rebuild initiate/callback on top of `OidcProvider`.
29
+
30
+ `AuthSuccessCallbackResponse` is now a union of three shapes:
31
+
32
+ - `{ user, token, redirectUrl? }` — unchanged default; the plugin delivers the token per `tokenDelivery`
33
+ - `{ user, redirectUrl }` — the plugin redirects there verbatim, attaching nothing (e.g. the URL carries a short-lived one-time code). Still subject to `allowedRedirectUris`; `?response_type=json` answers `400 invalid_response_type` for this shape since there is no token for the body
34
+ - `{ user, response }` — the application returns the whole `FlinkResponse` (redirect + `Set-Cookie`, a custom JSON envelope, anything a handler may return)
35
+
36
+ `onAuthError` takes the same `response` escape hatch, which wins over `redirectUrl`.
37
+
38
+ The `storeTokens` connection upsert now runs for all three shapes (it only needs `user._id`), and returning none of `token`, `redirectUrl` or `response` still fails with `jwt_generation_failed`.
39
+
40
+ Existing code is unaffected — the token shape is part of the union and behaves exactly as before.
41
+
42
+ Docs: a "Completing the login yourself" section in the README and skill, plus an explicit note that `oidc_connections` rows are only written when `storeTokens: true`, so in auth-only mode `getConnection`/`getConnections` cannot serve as the application's identity link.
43
+
44
+ ### Patch Changes
45
+
46
+ - 1767278: Docs: bring the README in line with the current behaviour — `tokenDelivery` gets an options-table row and its own section (it was only documented in passing), the "returns the token as a URL fragment" statements are qualified with "by default", the React Native example uses query delivery (iOS strips the fragment) and points at `allowedRedirectUris` for custom schemes, and the `onAuthSuccess` / `onAuthError` signatures now show `redirectUri`, `metadata`, the three success shapes and the `response` escape hatch.
47
+ - @flink-app/flink@2.0.0-alpha.118
48
+ - @flink-app/jwt-auth-plugin@2.0.0-alpha.118
49
+
3
50
  ## 2.0.0-alpha.117
4
51
 
5
52
  ### Patch Changes
package/README.md CHANGED
@@ -106,6 +106,10 @@ const app = new FlinkApp<Context>({
106
106
  },
107
107
  },
108
108
 
109
+ // Where the JWT may be delivered after login. ALWAYS set this —
110
+ // without it any ?redirectUri= passed to /initiate is accepted.
111
+ allowedRedirectUris: ["https://myapp.com"],
112
+
109
113
  // Callback after successful OIDC authentication (JIT provisioning)
110
114
  onAuthSuccess: async ({ profile, claims, provider }, ctx) => {
111
115
  // Find user by OIDC subject + issuer (unique IdP identifier)
@@ -162,14 +166,17 @@ await app.start();
162
166
  | Option | Type | Required | Default | Description |
163
167
  | --------------------------- | ---------- | -------- | ------------------ | ------------------------------------------------------ |
164
168
  | `providers` | `object` | Yes | - | OIDC provider configurations (at least one required) |
169
+ | `allowedRedirectUris` | `string[]` \| `Function` | No | (all accepted) | Allow-list for `?redirectUri=` — **set this** (see [Redirect URI Allow-List](#redirect-uri-allow-list)) |
165
170
  | `storeTokens` | `boolean` | No | `false` | Store encrypted OIDC tokens for future API access |
166
171
  | `onAuthSuccess` | `Function` | Yes | - | Callback after successful authentication (JIT) |
167
172
  | `onAuthError` | `Function` | No | - | Callback on OIDC errors |
168
173
  | `providerLoader` | `Function` | No | - | Dynamic provider loading from database |
174
+ | `providerCacheTtlSec` | `number` | No | `0` (forever) | Seconds a resolved provider is reused before its config and discovery are refreshed |
169
175
  | `sessionTTL` | `number` | No | `600` | Session TTL in seconds (default: 10 minutes) |
170
176
  | `sessionsCollectionName` | `string` | No | `"oidc_sessions"` | MongoDB collection for sessions |
171
177
  | `connectionsCollectionName` | `string` | No | `"oidc_connections"` | MongoDB collection for connections |
172
178
  | `encryptionKey` | `string` | No | (derived) | Encryption key for tokens (32+ chars recommended) |
179
+ | `tokenDelivery` | `"fragment"` \| `"query"` \| `Function` | No | `"fragment"` | How the JWT is attached on the post-callback redirect (see [Token Delivery](#token-delivery-fragment-vs-query)) |
173
180
  | `registerRoutes` | `boolean` | No | `true` | Auto-register OIDC routes |
174
181
 
175
182
  ### Provider Configuration
@@ -227,22 +234,30 @@ claims only:
227
234
 
228
235
  #### onAuthSuccess
229
236
 
230
- Called when OIDC authentication succeeds. Must generate and return a JWT token.
237
+ Called when OIDC authentication succeeds. Resolves the user and decides how the login completes.
231
238
 
232
239
  ```typescript
233
240
  onAuthSuccess: async (params: {
234
- profile: OidcProfile; // Normalized user profile
235
- claims: Record<string, any>; // Raw OIDC claims
236
- provider: string; // Provider name
237
- tokens?: OidcTokenSet; // Only if storeTokens: true
238
- }, ctx: Context) => Promise<{
239
- user: any;
240
- token: string; // JWT token from ctx.plugins.jwtAuth.createToken()
241
- redirectUrl?: string;
242
- }>
241
+ profile: OidcProfile; // Normalized user profile
242
+ claims: Record<string, any>; // Raw OIDC claims
243
+ provider: string; // Provider name
244
+ redirectUri: string; // redirectUri from the initiate request (or the fallback)
245
+ metadata: Record<string, string>; // meta.* query params from initiate, prefix stripped
246
+ tokens?: OidcTokenSet; // Only if storeTokens: true
247
+ }, ctx: Context) =>
248
+ Promise<
249
+ // 1. Default — the plugin delivers the token per `tokenDelivery`
250
+ | { user: any; token: string; redirectUrl?: string }
251
+ // 2. The plugin redirects there verbatim, attaching nothing
252
+ | { user: any; redirectUrl: string }
253
+ // 3. Your app returns the whole response (Set-Cookie, custom envelope, ...)
254
+ | { user: any; response: FlinkResponse }
255
+ >
243
256
  ```
244
257
 
245
- **Important:** The `redirectUrl` should NOT include the token. The plugin automatically appends `#token=...` to the URL.
258
+ **Important:** with shape 1 the `redirectUrl` should NOT include the token — the plugin
259
+ appends it (`#token=...` by default). Shapes 2 and 3 are described in
260
+ [Completing the Login Yourself](#completing-the-login-yourself).
246
261
 
247
262
  #### onAuthError
248
263
 
@@ -252,8 +267,11 @@ Called when OIDC authentication fails.
252
267
  onAuthError: async (params: {
253
268
  error: OidcError;
254
269
  provider: string;
270
+ redirectUri?: string; // From the initiate request, if the session was found
271
+ metadata: Record<string, string>; // meta.* query params from initiate
255
272
  }) => Promise<{
256
273
  redirectUrl?: string;
274
+ response?: FlinkResponse; // Takes precedence over redirectUrl
257
275
  }>
258
276
  ```
259
277
 
@@ -321,6 +339,27 @@ GET /oidc/acme/initiate?redirectUri=https://myapp.com/welcome
321
339
 
322
340
  - 302 redirect to IdP authorization URL
323
341
 
342
+ #### Security: the `redirectUri` query parameter
343
+
344
+ `redirectUri` decides where the JWT is delivered when the flow completes, and it is
345
+ **attacker-controllable** — anyone can craft an initiate link. Without an allow-list a
346
+ crafted link turns a legitimate login into token exfiltration: the user authenticates at
347
+ the real IdP, and the plugin then redirects to the attacker's URL with `#token=eyJ...`.
348
+
349
+ Set [`allowedRedirectUris`](#redirect-uri-allow-list) on every deployment.
350
+
351
+ Prefer deriving the final redirect from `metadata` (the `meta.*` query params) rather
352
+ than echoing `redirectUri` straight back from `onAuthSuccess`, so the allow-list is a
353
+ second line of defence rather than the only one:
354
+
355
+ ```typescript
356
+ onAuthSuccess: async ({ metadata }, ctx) => {
357
+ // ...
358
+ const redirectUrl = metadata.intent === "admin" ? "https://admin.myapp.com/auth" : "/dashboard";
359
+ return { user, token, redirectUrl };
360
+ };
361
+ ```
362
+
324
363
  ### OIDC Callback
325
364
 
326
365
  ```
@@ -354,9 +393,39 @@ https://myapp.com/dashboard#token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
354
393
  }
355
394
  ```
356
395
 
396
+ ### Token Delivery (fragment vs query)
397
+
398
+ By default the JWT is attached to the post-callback redirect as a **URL fragment**
399
+ (`#token=...`) — fragments are never sent to the server, which keeps the token out of
400
+ access logs and referrers.
401
+
402
+ Native flows need the query form instead: iOS `ASWebAuthenticationSession` strips the
403
+ fragment before handing the URL to the app, so a custom-scheme callback never sees it.
404
+
405
+ ```typescript
406
+ // Static: "fragment" (default) | "query"
407
+ oidcPlugin({ tokenDelivery: "query", ... });
408
+
409
+ // Per request, when one backend serves both web and native clients
410
+ oidcPlugin({
411
+ tokenDelivery: ({ redirectUrl, provider, metadata }) => (/^https?:/i.test(redirectUrl) ? "fragment" : "query"),
412
+ ...
413
+ });
414
+ ```
415
+
416
+ The function form receives the resolved `redirectUrl`, the `provider` name, and the
417
+ `metadata` bag from the initiate request (`?meta.client=native`), so the app can route by
418
+ URL scheme, by explicit intent, or any other signal. `?response_type=json` ignores the
419
+ setting and returns `{user, token}` as JSON.
420
+
421
+ To keep the token out of the URL altogether, see
422
+ [Completing the Login Yourself](#completing-the-login-yourself).
423
+
357
424
  ### Extracting JWT Token in Frontend
358
425
 
359
- **IMPORTANT:** The plugin returns the JWT token as a **URL fragment** (`#token=...`), NOT as a query parameter (`?token=...`).
426
+ **IMPORTANT:** By default the plugin returns the JWT token as a **URL fragment**
427
+ (`#token=...`), NOT as a query parameter (`?token=...`) — read it accordingly unless you
428
+ set [`tokenDelivery: "query"`](#token-delivery-fragment-vs-query).
360
429
 
361
430
  ```javascript
362
431
  // ✅ CORRECT - Read from URL fragment (hash)
@@ -375,10 +444,81 @@ const params = new URLSearchParams(window.location.search);
375
444
  const token = params.get("token"); // This returns null!
376
445
  ```
377
446
 
447
+ ## Completing the Login Yourself
448
+
449
+ Returning `{ user, token }` is the simple default: the plugin attaches the token per
450
+ [`tokenDelivery`](#token-delivery-fragment-vs-query) (fragment, query or JSON). Two other shapes let the
451
+ application complete the login itself when a token in the URL is not the session model it
452
+ wants — an HttpOnly cookie, a one-time code, or a login envelope its clients already parse.
453
+
454
+ ### Verbatim redirect (one-time code)
455
+
456
+ Return a `redirectUrl` and no `token`: the plugin redirects there **exactly as given** and
457
+ attaches nothing. Keeps the bearer token out of browser history, referrers and the access
458
+ logs of the redirect target.
459
+
460
+ ```typescript
461
+ onAuthSuccess: async ({ profile, claims }, ctx) => {
462
+ const user = await findOrCreateUser(ctx, claims);
463
+
464
+ // Short-lived, single-use code the client exchanges for a real session
465
+ const code = generateCode();
466
+ await ctx.repos.loginCodeRepo.create({ code, userId: user._id, expiresAt: new Date(Date.now() + 60_000) });
467
+
468
+ return { user, redirectUrl: `https://app.example.com/auth/done?code=${code}` };
469
+ };
470
+ ```
471
+
472
+ The client then POSTs the code to one of your own routes, which answers with your normal
473
+ login response. The URL is still subject to [`allowedRedirectUris`](#redirect-uri-allow-list).
474
+
475
+ `?response_type=json` is not available for this shape — the request answers
476
+ `400 invalid_response_type`, since there is no token to put in the JSON body.
477
+
478
+ ### Host-built response (cookie session, custom envelope)
479
+
480
+ Return a `response` and the plugin sends it as-is — anything a Flink handler may return:
481
+
482
+ ```typescript
483
+ // HttpOnly session cookie + redirect
484
+ onAuthSuccess: async ({ profile, claims }, ctx) => {
485
+ const user = await findOrCreateUser(ctx, claims);
486
+ const session = await ctx.repos.sessionRepo.create({ userId: user._id });
487
+
488
+ return {
489
+ user,
490
+ response: {
491
+ redirect: { to: "https://app.example.com/dashboard" },
492
+ headers: { "Set-Cookie": `session=${session.id}; HttpOnly; Secure; SameSite=Lax; Path=/` },
493
+ },
494
+ };
495
+ };
496
+
497
+ // Custom login envelope for native clients
498
+ onAuthSuccess: async ({ profile, claims }, ctx) => {
499
+ const user = await findOrCreateUser(ctx, claims);
500
+ const { accessToken, refreshToken } = await issueTokens(ctx, user);
501
+
502
+ return { user, response: { data: { accessToken, refreshToken, profile: user } } };
503
+ };
504
+ ```
505
+
506
+ `onAuthError` takes the same `response` escape hatch (it wins over `redirectUrl`), e.g. to
507
+ clear a session cookie alongside the error redirect.
508
+
509
+ **In all three shapes** the OIDC connection is still stored when `storeTokens: true` —
510
+ it only needs `user._id`. Returning none of `token`, `redirectUrl` or `response` fails the
511
+ login with `jwt_generation_failed`, as before.
512
+
378
513
  ## Context API
379
514
 
380
515
  The plugin exposes methods via `ctx.plugins.oidc`:
381
516
 
517
+ > **Note:** `oidc_connections` rows are only written when `storeTokens: true`. In auth-only
518
+ > mode (the default) `getConnection` / `getConnections` always return nothing, so they
519
+ > cannot serve as the application's identity link — keep that link on your own user
520
+ > document, as in [JIT with Email Matching](#jit-with-email-matching).
521
+
382
522
  ### getConnection
383
523
 
384
524
  Get stored OIDC connection for a user and provider.
@@ -421,6 +561,22 @@ Delete/unlink an OIDC connection.
421
561
  await ctx.plugins.oidc.deleteConnection(userId, "acme");
422
562
  ```
423
563
 
564
+ ### invalidateProvider
565
+
566
+ Drop cached provider instance(s) so the next request re-runs `providerLoader` and OIDC discovery.
567
+ Call it whenever provider configuration changes at runtime.
568
+
569
+ ```typescript
570
+ // One provider
571
+ ctx.plugins.oidc.invalidateProvider("acme");
572
+
573
+ // All providers
574
+ ctx.plugins.oidc.invalidateProvider();
575
+ ```
576
+
577
+ Only affects the instance it runs on. For horizontally scaled apps, combine it with
578
+ [`providerCacheTtlSec`](#provider-config-caching) so the other instances converge within the TTL.
579
+
424
580
  ## Token Storage
425
581
 
426
582
  ### Auth-Only Mode (Default)
@@ -444,6 +600,9 @@ oidcPlugin({
444
600
  - You don't need to call IdP APIs on behalf of users
445
601
  - You want to minimize stored credentials
446
602
 
603
+ In this mode **no `oidc_connections` rows are written at all** — store the IdP
604
+ subject/issuer on your own user document if you need to look users up by it later.
605
+
447
606
  ### Token Storage Mode
448
607
 
449
608
  Set `storeTokens: true` to store encrypted OIDC tokens for future API access.
@@ -623,6 +782,50 @@ oidcPlugin({
623
782
  })
624
783
  ```
625
784
 
785
+ ### Provider Config Caching
786
+
787
+ **The loader result is cached.** By default a provider is resolved once and reused for the
788
+ life of the process — editing an issuer, client id, client secret, scope or discovery URL
789
+ in the database has no effect until every instance restarts.
790
+
791
+ Set a TTL, and invalidate explicitly where the config is saved:
792
+
793
+ ```typescript
794
+ oidcPlugin({
795
+ providerLoader: async (providerName, ctx) => {
796
+ /* ... load from db ... */
797
+ },
798
+
799
+ // Re-resolve config + re-run discovery at most every 5 minutes
800
+ providerCacheTtlSec: 300,
801
+
802
+ onAuthSuccess: async ({ profile, claims, provider }, ctx) => {
803
+ // ... JIT provisioning
804
+ },
805
+ });
806
+ ```
807
+
808
+ ```typescript
809
+ // src/handlers/admin/PutOidcProvider.ts — admin saves IdP settings
810
+ const handler: PutHandler<Ctx, OidcProviderConfigDB, void> = async ({ ctx, req }) => {
811
+ await ctx.repos.oidcProviderRepo.updateById(req.params.id, req.body);
812
+
813
+ // Takes effect immediately on this instance; others converge within providerCacheTtlSec
814
+ ctx.plugins.oidc.invalidateProvider(req.body.name);
815
+
816
+ return { status: 204 };
817
+ };
818
+ ```
819
+
820
+ Reloading is lazy — it happens on the next request for that provider, not on a timer. If
821
+ the reload fails (IdP unreachable, database hiccup) the previously cached instance keeps
822
+ being used and the next attempt happens after another TTL, so a discovery hiccup does not
823
+ break logins that would otherwise work. If the loader returns `null` (provider removed or
824
+ disabled), the cached instance is dropped and the request fails with `provider_not_configured`.
825
+
826
+ The TTL applies to static providers too: their discovery metadata (endpoints, JWKS URI) is
827
+ otherwise fetched once at first use and never refreshed.
828
+
626
829
  ### Database Schema for Dynamic Providers
627
830
 
628
831
  ```typescript
@@ -649,6 +852,47 @@ interface OidcProviderConfigDB {
649
852
 
650
853
  ## Security
651
854
 
855
+ ### Redirect URI Allow-List
856
+
857
+ `GET /oidc/:provider/initiate?redirectUri=...` decides where the JWT ends up after login.
858
+ Restrict it to the URLs your own clients use:
859
+
860
+ ```typescript
861
+ oidcPlugin({
862
+ // Exact URIs, bare origins, or a predicate
863
+ allowedRedirectUris: [
864
+ "https://app.example.com", // any path on this origin
865
+ "https://admin.example.com/auth-callback", // this exact URI only
866
+ "myapp://oidc-callback", // native app scheme
867
+ ],
868
+
869
+ // ...or a predicate for anything else
870
+ // allowedRedirectUris: (redirectUri, provider) => redirectUri.endsWith(".example.com/cb"),
871
+ });
872
+ ```
873
+
874
+ Matching rules:
875
+
876
+ | Entry | Matches |
877
+ | --- | --- |
878
+ | `https://app.example.com` (no path) | Any path/query/fragment on that origin |
879
+ | `https://app.example.com/login` (has path) | That exact URI only |
880
+ | `myapp://cb` | `myapp://cb` and any path below it |
881
+
882
+ Always accepted regardless of the list:
883
+
884
+ - Relative paths (`/dashboard`) — they can only land on this app's own origin
885
+ - The provider's own `callbackUrl` (the fallback when no `redirectUri` is passed)
886
+
887
+ A `redirectUri` outside the list is rejected with `400 invalid_redirect_uri` and **no
888
+ session is created**. The same check is applied to a `redirectUrl` returned by
889
+ `onAuthSuccess` / `onAuthError` when it differs from the one stored on the session, so a
890
+ host that echoes an attacker-supplied value back cannot bypass the list by accident.
891
+
892
+ **If the option is omitted, every `redirectUri` is accepted** (unchanged legacy
893
+ behaviour) and the plugin logs a warning at startup. A future major version will make
894
+ the allow-list required.
895
+
652
896
  ### CSRF Protection
653
897
 
654
898
  The plugin uses cryptographically secure state parameters to prevent CSRF attacks:
@@ -748,18 +992,24 @@ function LoginPage() {
748
992
 
749
993
  ### React Native App
750
994
 
995
+ Set [`tokenDelivery: "query"`](#token-delivery-fragment-vs-query) for native clients — iOS
996
+ `ASWebAuthenticationSession` strips the URL fragment before handing the callback URL to the
997
+ app, so `#token=...` never arrives.
998
+
751
999
  ```typescript
752
1000
  import { openAuthSessionAsync } from "expo-auth-session";
753
1001
 
754
1002
  async function loginWithOidc() {
755
- const result = await openAuthSessionAsync("https://api.myapp.com/oidc/acme/initiate", "myapp://oidc/callback");
1003
+ const result = await openAuthSessionAsync(
1004
+ "https://api.myapp.com/oidc/acme/initiate?redirectUri=myapp://oidc/callback",
1005
+ "myapp://oidc/callback"
1006
+ );
756
1007
 
757
1008
  if (result.type === "success") {
758
- const url = result.url;
1009
+ const urlObj = new URL(result.url);
759
1010
 
760
- // Extract token from URL fragment
761
- const urlObj = new URL(url);
762
- const token = new URLSearchParams(urlObj.hash.slice(1)).get("token");
1011
+ // With tokenDelivery: "query" the token arrives as a query parameter
1012
+ const token = urlObj.searchParams.get("token") || new URLSearchParams(urlObj.hash.slice(1)).get("token");
763
1013
 
764
1014
  if (token) {
765
1015
  await AsyncStorage.setItem("jwt_token", token);
@@ -769,6 +1019,9 @@ async function loginWithOidc() {
769
1019
  }
770
1020
  ```
771
1021
 
1022
+ Remember to add the custom scheme to [`allowedRedirectUris`](#redirect-uri-allow-list):
1023
+ `allowedRedirectUris: ["https://app.example.com", "myapp://oidc/callback"]`.
1024
+
772
1025
  ## Troubleshooting
773
1026
 
774
1027
  ### OIDC Discovery Failed
@@ -781,6 +1034,24 @@ async function loginWithOidc() {
781
1034
  - Check IdP is accessible from your server
782
1035
  - Try manual endpoint configuration instead
783
1036
 
1037
+ ### Invalid Redirect URI
1038
+
1039
+ **Issue:** `invalid_redirect_uri` error (400)
1040
+
1041
+ **Solution:**
1042
+
1043
+ - The `redirectUri` is not covered by `allowedRedirectUris` — add its origin or exact URI
1044
+ - Remember that an entry *with* a path matches that exact URI only; use the bare origin to cover every path below it
1045
+ - Check schemes and ports match exactly (`https://app.example.com` does not cover `http://` or `:8443`)
1046
+
1047
+ ### JSON Mode Not Available
1048
+
1049
+ **Issue:** `invalid_response_type` error (400) on `?response_type=json`
1050
+
1051
+ **Solution:**
1052
+
1053
+ - The application completed the login without a token (it returned `{ user, redirectUrl }`), so there is nothing to put in a JSON body — drop `response_type=json`, or return `{ user, response }` and build the JSON yourself
1054
+
784
1055
  ### Invalid State Parameter
785
1056
 
786
1057
  **Issue:** `invalid_state` error
@@ -833,11 +1104,19 @@ import {
833
1104
  OidcConnection,
834
1105
  OidcError,
835
1106
  OidcPluginContext,
1107
+ AllowedRedirectUris,
1108
+ AuthSuccessCallbackResponse,
1109
+ AuthSuccessTokenResponse,
1110
+ AuthSuccessRedirectResponse,
1111
+ AuthSuccessCustomResponse,
1112
+ AuthErrorCallbackResponse,
836
1113
  } from "@flink-app/oidc-plugin";
837
1114
  ```
838
1115
 
839
1116
  ## Production Checklist
840
1117
 
1118
+ - [ ] Set `allowedRedirectUris` to the URLs your clients actually use
1119
+ - [ ] Set `providerCacheTtlSec` if provider configs live in a database (otherwise edits need a restart)
841
1120
  - [ ] Configure HTTPS for all OIDC callback URLs
842
1121
  - [ ] Set OIDC credentials in secure environment variables
843
1122
  - [ ] Configure JWT Auth Plugin with secure secret
@@ -1 +1 @@
1
- {"version":3,"file":"OidcPlugin.d.ts","sourceRoot":"","sources":["../src/OidcPlugin.ts"],"names":[],"mappings":"AAAA,OAAO,EAAY,WAAW,EAAO,MAAM,kBAAkB,CAAC;AAG9D,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAWxD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwEG;AACH,wBAAgB,UAAU,CAAC,IAAI,GAAG,GAAG,EAAE,OAAO,EAAE,iBAAiB,CAAC,IAAI,CAAC,GAAG,WAAW,CA2OpF"}
1
+ {"version":3,"file":"OidcPlugin.d.ts","sourceRoot":"","sources":["../src/OidcPlugin.ts"],"names":[],"mappings":"AAAA,OAAO,EAAY,WAAW,EAAO,MAAM,kBAAkB,CAAC;AAG9D,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAWxD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwEG;AACH,wBAAgB,UAAU,CAAC,IAAI,GAAG,GAAG,EAAE,OAAO,EAAE,iBAAiB,CAAC,IAAI,CAAC,GAAG,WAAW,CAgRpF"}
@@ -141,6 +141,25 @@ function oidcPlugin(options) {
141
141
  if (!options.onAuthSuccess) {
142
142
  throw new Error("OIDC Plugin: onAuthSuccess callback is required");
143
143
  }
144
+ // Redirect URI allow-list. Absent = every redirectUri is accepted (legacy behaviour),
145
+ // which lets a crafted initiate link deliver the app's JWT to any URL.
146
+ if (options.allowedRedirectUris === undefined) {
147
+ flink_1.log.warn("OIDC Plugin: allowedRedirectUris is not set — any redirectUri passed to /initiate is accepted. Set it to the URLs your clients use.");
148
+ }
149
+ else if (typeof options.allowedRedirectUris !== "function") {
150
+ if (!Array.isArray(options.allowedRedirectUris) || options.allowedRedirectUris.some((entry) => typeof entry !== "string" || !entry)) {
151
+ throw new Error("OIDC Plugin: allowedRedirectUris must be an array of non-empty strings or a predicate function");
152
+ }
153
+ log_1.oidcLog.debug(`Redirect URI allow-list: [${options.allowedRedirectUris.join(", ")}]`);
154
+ }
155
+ else {
156
+ log_1.oidcLog.debug("Redirect URI allow-list: predicate function");
157
+ }
158
+ // Provider cache TTL (0 = cache for the life of the process, unchanged behaviour)
159
+ const providerCacheTtlSec = options.providerCacheTtlSec ?? 0;
160
+ if (typeof providerCacheTtlSec !== "number" || !Number.isFinite(providerCacheTtlSec) || providerCacheTtlSec < 0) {
161
+ throw new Error("OIDC Plugin: providerCacheTtlSec must be a non-negative number of seconds");
162
+ }
144
163
  // Determine encryption key
145
164
  let encryptionKey = options.encryptionKey;
146
165
  if (!encryptionKey) {
@@ -187,7 +206,7 @@ function oidcPlugin(options) {
187
206
  // Initialize repositories
188
207
  const sessionsCollectionName = options.sessionsCollectionName || "oidc_sessions";
189
208
  const connectionsCollectionName = options.connectionsCollectionName || "oidc_connections";
190
- log_1.oidcLog.debug(`Init: sessionsCollection=${sessionsCollectionName}`, `connectionsCollection=${connectionsCollectionName}`, `storeTokens=${!!options.storeTokens}`, `sessionTTL=${options.sessionTTL ?? 600}s`, `registerRoutes=${options.registerRoutes !== false}`, configuredProviders.length > 0 ? `staticProviders=[${configuredProviders.join(", ")}]` : "staticProviders=[] (dynamic only)", options.providerLoader ? "providerLoader=yes" : "providerLoader=no");
209
+ log_1.oidcLog.debug(`Init: sessionsCollection=${sessionsCollectionName}`, `connectionsCollection=${connectionsCollectionName}`, `storeTokens=${!!options.storeTokens}`, `sessionTTL=${options.sessionTTL ?? 600}s`, `providerCacheTtl=${providerCacheTtlSec > 0 ? `${providerCacheTtlSec}s` : "forever"}`, `registerRoutes=${options.registerRoutes !== false}`, configuredProviders.length > 0 ? `staticProviders=[${configuredProviders.join(", ")}]` : "staticProviders=[] (dynamic only)", options.providerLoader ? "providerLoader=yes" : "providerLoader=no");
191
210
  sessionRepo = new OidcSessionRepo_1.default(sessionsCollectionName, db);
192
211
  connectionRepo = new OidcConnectionRepo_1.default(connectionsCollectionName, db);
193
212
  flinkApp.addRepo("oidcSessionRepo", sessionRepo);
@@ -197,7 +216,7 @@ function oidcPlugin(options) {
197
216
  await db.collection(sessionsCollectionName).createIndex({ createdAt: 1 }, { expireAfterSeconds: sessionTTL });
198
217
  flink_1.log.info(`OIDC Plugin: Created TTL index on ${sessionsCollectionName} with ${sessionTTL}s expiration`);
199
218
  // Initialize provider registry
200
- providerRegistry = new ProviderRegistry_1.ProviderRegistry(providers, options.providerLoader, flinkApp.ctx);
219
+ providerRegistry = new ProviderRegistry_1.ProviderRegistry(providers, options.providerLoader, flinkApp.ctx, providerCacheTtlSec);
201
220
  // Store provider registry in app context for handlers to access
202
221
  flinkApp.ctx.oidcProviderRegistry = providerRegistry;
203
222
  // Register OIDC handlers
@@ -268,6 +287,19 @@ function oidcPlugin(options) {
268
287
  await connectionRepo.deleteByUserAndProvider(userId, provider);
269
288
  flink_1.log.info(`OIDC Plugin: Deleted ${provider} connection for user ${userId}`);
270
289
  }
290
+ /**
291
+ * Drop cached provider instance(s) so the next request re-runs providerLoader
292
+ * and OIDC discovery
293
+ */
294
+ function invalidateProvider(providerName) {
295
+ if (!providerRegistry) {
296
+ // Nothing is cached before init, so there is nothing to invalidate
297
+ log_1.oidcLog.debug("invalidateProvider called before plugin init, nothing to do");
298
+ return;
299
+ }
300
+ providerRegistry.clearCache(providerName);
301
+ flink_1.log.info(`OIDC Plugin: invalidated provider cache for ${providerName ? `"${providerName}"` : "all providers"}`);
302
+ }
271
303
  /**
272
304
  * Plugin context exposed via ctx.plugins.oidc
273
305
  */
@@ -275,6 +307,7 @@ function oidcPlugin(options) {
275
307
  getConnection,
276
308
  getConnections,
277
309
  deleteConnection,
310
+ invalidateProvider,
278
311
  options: Object.freeze({ ...options }),
279
312
  };
280
313
  return {
@@ -63,6 +63,23 @@ export interface OidcPluginContext {
63
63
  * ```
64
64
  */
65
65
  deleteConnection: (userId: string, provider: string) => Promise<void>;
66
+ /**
67
+ * Drop cached provider instance(s) so the next request re-runs `providerLoader`
68
+ * and OIDC discovery for them.
69
+ *
70
+ * Call this when provider configuration changes at runtime, e.g. from the handler
71
+ * where an admin saves IdP settings. Only affects the instance it runs on — with
72
+ * `providerCacheTtlSec` set, other instances converge within the TTL.
73
+ *
74
+ * @param providerName - Provider to invalidate (all providers if omitted)
75
+ *
76
+ * Example:
77
+ * ```typescript
78
+ * await ctx.repos.oidcProviderRepo.updateById(id, { clientSecret });
79
+ * ctx.plugins.oidc.invalidateProvider(providerName);
80
+ * ```
81
+ */
82
+ invalidateProvider: (providerName?: string) => void;
66
83
  /**
67
84
  * Plugin configuration options (read-only)
68
85
  * Useful for checking plugin settings at runtime
@@ -1 +1 @@
1
- {"version":3,"file":"OidcPluginContext.d.ts","sourceRoot":"","sources":["../src/OidcPluginContext.ts"],"names":[],"mappings":"AAAA,OAAO,cAAc,MAAM,0BAA0B,CAAC;AACtD,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAExD;;;;GAIG;AACH,MAAM,WAAW,iBAAiB;IAC9B,IAAI,EAAE;QACF;;;;;;;;;;;;;;;;;;;;WAoBG;QACH,aAAa,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CAAC;QAEpF;;;;;;;;;;;;;;;;WAgBG;QACH,cAAc,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,cAAc,EAAE,CAAC,CAAC;QAE9D;;;;;;;;;;;;;;WAcG;QACH,gBAAgB,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;QAEtE;;;WAGG;QACH,OAAO,EAAE,QAAQ,CAAC,iBAAiB,CAAC,CAAC;KACxC,CAAC;CACL"}
1
+ {"version":3,"file":"OidcPluginContext.d.ts","sourceRoot":"","sources":["../src/OidcPluginContext.ts"],"names":[],"mappings":"AAAA,OAAO,cAAc,MAAM,0BAA0B,CAAC;AACtD,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAExD;;;;GAIG;AACH,MAAM,WAAW,iBAAiB;IAC9B,IAAI,EAAE;QACF;;;;;;;;;;;;;;;;;;;;WAoBG;QACH,aAAa,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CAAC;QAEpF;;;;;;;;;;;;;;;;WAgBG;QACH,cAAc,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,cAAc,EAAE,CAAC,CAAC;QAE9D;;;;;;;;;;;;;;WAcG;QACH,gBAAgB,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;QAEtE;;;;;;;;;;;;;;;WAeG;QACH,kBAAkB,EAAE,CAAC,YAAY,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;QAEpD;;;WAGG;QACH,OAAO,EAAE,QAAQ,CAAC,iBAAiB,CAAC,CAAC;KACxC,CAAC;CACL"}