@flink-app/oidc-plugin 1.0.0

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 (112) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +846 -0
  4. package/dist/OidcInternalContext.d.ts +15 -0
  5. package/dist/OidcInternalContext.d.ts.map +1 -0
  6. package/dist/OidcInternalContext.js +2 -0
  7. package/dist/OidcPlugin.d.ts +77 -0
  8. package/dist/OidcPlugin.d.ts.map +1 -0
  9. package/dist/OidcPlugin.js +274 -0
  10. package/dist/OidcPluginContext.d.ts +73 -0
  11. package/dist/OidcPluginContext.d.ts.map +1 -0
  12. package/dist/OidcPluginContext.js +2 -0
  13. package/dist/OidcPluginOptions.d.ts +267 -0
  14. package/dist/OidcPluginOptions.d.ts.map +1 -0
  15. package/dist/OidcPluginOptions.js +2 -0
  16. package/dist/OidcProviderConfig.d.ts +77 -0
  17. package/dist/OidcProviderConfig.d.ts.map +1 -0
  18. package/dist/OidcProviderConfig.js +2 -0
  19. package/dist/handlers/CallbackOidc.d.ts +38 -0
  20. package/dist/handlers/CallbackOidc.d.ts.map +1 -0
  21. package/dist/handlers/CallbackOidc.js +219 -0
  22. package/dist/handlers/InitiateOidc.d.ts +35 -0
  23. package/dist/handlers/InitiateOidc.d.ts.map +1 -0
  24. package/dist/handlers/InitiateOidc.js +91 -0
  25. package/dist/index.d.ts +27 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +40 -0
  28. package/dist/providers/OidcProvider.d.ts +90 -0
  29. package/dist/providers/OidcProvider.d.ts.map +1 -0
  30. package/dist/providers/OidcProvider.js +208 -0
  31. package/dist/providers/ProviderRegistry.d.ts +55 -0
  32. package/dist/providers/ProviderRegistry.d.ts.map +1 -0
  33. package/dist/providers/ProviderRegistry.js +94 -0
  34. package/dist/repos/OidcConnectionRepo.d.ts +75 -0
  35. package/dist/repos/OidcConnectionRepo.d.ts.map +1 -0
  36. package/dist/repos/OidcConnectionRepo.js +122 -0
  37. package/dist/repos/OidcSessionRepo.d.ts +57 -0
  38. package/dist/repos/OidcSessionRepo.d.ts.map +1 -0
  39. package/dist/repos/OidcSessionRepo.js +91 -0
  40. package/dist/schemas/CallbackRequest.d.ts +37 -0
  41. package/dist/schemas/CallbackRequest.d.ts.map +1 -0
  42. package/dist/schemas/CallbackRequest.js +2 -0
  43. package/dist/schemas/InitiateRequest.d.ts +17 -0
  44. package/dist/schemas/InitiateRequest.d.ts.map +1 -0
  45. package/dist/schemas/InitiateRequest.js +2 -0
  46. package/dist/schemas/OidcConnection.d.ts +69 -0
  47. package/dist/schemas/OidcConnection.d.ts.map +1 -0
  48. package/dist/schemas/OidcConnection.js +2 -0
  49. package/dist/schemas/OidcProfile.d.ts +69 -0
  50. package/dist/schemas/OidcProfile.d.ts.map +1 -0
  51. package/dist/schemas/OidcProfile.js +2 -0
  52. package/dist/schemas/OidcSession.d.ts +46 -0
  53. package/dist/schemas/OidcSession.d.ts.map +1 -0
  54. package/dist/schemas/OidcSession.js +2 -0
  55. package/dist/schemas/OidcTokenSet.d.ts +42 -0
  56. package/dist/schemas/OidcTokenSet.d.ts.map +1 -0
  57. package/dist/schemas/OidcTokenSet.js +2 -0
  58. package/dist/utils/claims-mapper.d.ts +46 -0
  59. package/dist/utils/claims-mapper.d.ts.map +1 -0
  60. package/dist/utils/claims-mapper.js +104 -0
  61. package/dist/utils/encryption-utils.d.ts +32 -0
  62. package/dist/utils/encryption-utils.d.ts.map +1 -0
  63. package/dist/utils/encryption-utils.js +82 -0
  64. package/dist/utils/error-utils.d.ts +65 -0
  65. package/dist/utils/error-utils.d.ts.map +1 -0
  66. package/dist/utils/error-utils.js +150 -0
  67. package/dist/utils/response-utils.d.ts +18 -0
  68. package/dist/utils/response-utils.d.ts.map +1 -0
  69. package/dist/utils/response-utils.js +42 -0
  70. package/dist/utils/state-utils.d.ts +36 -0
  71. package/dist/utils/state-utils.d.ts.map +1 -0
  72. package/dist/utils/state-utils.js +66 -0
  73. package/examples/basic-oidc.ts +151 -0
  74. package/examples/multi-provider.ts +146 -0
  75. package/package.json +44 -0
  76. package/spec/handlers/InitiateOidc.spec.ts +62 -0
  77. package/spec/helpers/reporter.ts +34 -0
  78. package/spec/helpers/test-helpers.ts +108 -0
  79. package/spec/plugin/OidcPlugin.spec.ts +126 -0
  80. package/spec/providers/ProviderRegistry.spec.ts +197 -0
  81. package/spec/repos/OidcConnectionRepo.spec.ts +257 -0
  82. package/spec/repos/OidcSessionRepo.spec.ts +196 -0
  83. package/spec/support/jasmine.json +7 -0
  84. package/spec/utils/claims-mapper.spec.ts +257 -0
  85. package/spec/utils/encryption-utils.spec.ts +126 -0
  86. package/spec/utils/error-utils.spec.ts +107 -0
  87. package/spec/utils/state-utils.spec.ts +102 -0
  88. package/src/OidcInternalContext.ts +15 -0
  89. package/src/OidcPlugin.ts +290 -0
  90. package/src/OidcPluginContext.ts +76 -0
  91. package/src/OidcPluginOptions.ts +286 -0
  92. package/src/OidcProviderConfig.ts +87 -0
  93. package/src/handlers/CallbackOidc.ts +257 -0
  94. package/src/handlers/InitiateOidc.ts +110 -0
  95. package/src/index.ts +38 -0
  96. package/src/providers/OidcProvider.ts +237 -0
  97. package/src/providers/ProviderRegistry.ts +107 -0
  98. package/src/repos/OidcConnectionRepo.ts +132 -0
  99. package/src/repos/OidcSessionRepo.ts +99 -0
  100. package/src/schemas/CallbackRequest.ts +41 -0
  101. package/src/schemas/InitiateRequest.ts +17 -0
  102. package/src/schemas/OidcConnection.ts +80 -0
  103. package/src/schemas/OidcProfile.ts +79 -0
  104. package/src/schemas/OidcSession.ts +52 -0
  105. package/src/schemas/OidcTokenSet.ts +47 -0
  106. package/src/utils/claims-mapper.ts +114 -0
  107. package/src/utils/encryption-utils.ts +92 -0
  108. package/src/utils/error-utils.ts +167 -0
  109. package/src/utils/response-utils.ts +41 -0
  110. package/src/utils/state-utils.ts +66 -0
  111. package/tsconfig.dist.json +9 -0
  112. package/tsconfig.json +20 -0
package/README.md ADDED
@@ -0,0 +1,846 @@
1
+ # OIDC Authentication Plugin
2
+
3
+ A flexible OpenID Connect (OIDC) authentication plugin for Flink that supports generic Identity Providers (IdPs) with MongoDB session storage, JWT token generation, and configurable token handling.
4
+
5
+ ## Features
6
+
7
+ - OpenID Connect Authorization Code flow with any OIDC-compliant IdP
8
+ - Automatic JWT token generation via JWT Auth Plugin integration
9
+ - MongoDB session storage with automatic TTL cleanup
10
+ - Support for multiple OIDC providers per application
11
+ - OIDC Discovery support (automatic endpoint configuration)
12
+ - Manual endpoint configuration for custom IdPs
13
+ - PKCE (Proof Key for Code Exchange) for enhanced security
14
+ - CSRF protection with cryptographically secure state parameters
15
+ - Nonce validation for ID token replay protection
16
+ - Encrypted token storage (AES-256-GCM)
17
+ - JIT (Just-In-Time) user provisioning
18
+ - Built-in HTTP endpoints for OIDC flow
19
+ - TypeScript support with full type safety
20
+ - Configurable response formats (JSON, URL fragment)
21
+ - Dynamic provider loading from database
22
+
23
+ ## Installation
24
+
25
+ ```bash
26
+ npm install @flink-app/oidc-plugin @flink-app/jwt-auth-plugin
27
+ ```
28
+
29
+ ## Prerequisites
30
+
31
+ ### 1. JWT Auth Plugin Dependency
32
+
33
+ This plugin requires `@flink-app/jwt-auth-plugin` to be installed and configured. The OIDC plugin uses the JWT Auth Plugin to generate authentication tokens after successful OIDC authentication.
34
+
35
+ ### 2. OIDC Provider Credentials
36
+
37
+ You need OIDC application credentials from your Identity Provider:
38
+
39
+ #### Generic OIDC Provider Setup
40
+
41
+ 1. Register your application with your IdP
42
+ 2. Configure the redirect URI: `https://yourdomain.com/oidc/{provider}/callback`
43
+ 3. Obtain Client ID and Client Secret
44
+ 4. Note the Issuer URL (e.g., `https://idp.example.com`)
45
+ 5. Get the Discovery URL (usually `{issuer}/.well-known/openid-configuration`)
46
+
47
+ #### Common OIDC Providers
48
+
49
+ - **Azure AD / Entra ID**: `https://login.microsoftonline.com/{tenant}/v2.0`
50
+ - **Okta**: `https://{domain}.okta.com`
51
+ - **Auth0**: `https://{domain}.auth0.com`
52
+ - **Keycloak**: `https://{domain}/realms/{realm}`
53
+ - **Google**: `https://accounts.google.com` (use oauth-plugin for Google)
54
+
55
+ ### 3. MongoDB Connection
56
+
57
+ The plugin requires MongoDB to store OIDC sessions during the authentication flow.
58
+
59
+ ## Quick Start
60
+
61
+ ```typescript
62
+ import { FlinkApp } from "@flink-app/flink";
63
+ import { jwtAuthPlugin } from "@flink-app/jwt-auth-plugin";
64
+ import { oidcPlugin } from "@flink-app/oidc-plugin";
65
+ import { Context } from "./Context";
66
+
67
+ const app = new FlinkApp<Context>({
68
+ name: "My App",
69
+
70
+ // JWT Auth Plugin MUST be configured first
71
+ auth: jwtAuthPlugin({
72
+ secret: process.env.JWT_SECRET!,
73
+ getUser: async (tokenData) => {
74
+ return await app.ctx.repos.userRepo.getById(tokenData.userId);
75
+ },
76
+ rolePermissions: {
77
+ user: ["read:own", "write:own"],
78
+ admin: ["read:all", "write:all"],
79
+ },
80
+ }),
81
+
82
+ db: {
83
+ uri: process.env.MONGODB_URI!,
84
+ },
85
+
86
+ plugins: [
87
+ oidcPlugin({
88
+ providers: {
89
+ // Provider name used in URLs: /oidc/acme/initiate
90
+ acme: {
91
+ issuer: process.env.OIDC_ISSUER!,
92
+ clientId: process.env.OIDC_CLIENT_ID!,
93
+ clientSecret: process.env.OIDC_CLIENT_SECRET!,
94
+ callbackUrl: "https://myapp.com/oidc/acme/callback",
95
+
96
+ // Option 1: Use OIDC Discovery (recommended)
97
+ discoveryUrl: `${process.env.OIDC_ISSUER}/.well-known/openid-configuration`,
98
+
99
+ // Option 2: Manual endpoint configuration (if discovery not available)
100
+ // authorizationEndpoint: "https://idp.acme.com/authorize",
101
+ // tokenEndpoint: "https://idp.acme.com/token",
102
+ // userinfoEndpoint: "https://idp.acme.com/userinfo",
103
+ // jwksUri: "https://idp.acme.com/.well-known/jwks.json",
104
+
105
+ scope: ["openid", "email", "profile"],
106
+ },
107
+ },
108
+
109
+ // Callback after successful OIDC authentication (JIT provisioning)
110
+ onAuthSuccess: async ({ profile, claims, provider }, ctx) => {
111
+ // Find user by OIDC subject + issuer (unique IdP identifier)
112
+ let user = await ctx.repos.userRepo.getOne({
113
+ "oidcConnections.subject": claims.sub,
114
+ "oidcConnections.issuer": claims.iss,
115
+ });
116
+
117
+ if (!user) {
118
+ // JIT provisioning - create new user
119
+ user = await ctx.repos.userRepo.create({
120
+ email: claims.email,
121
+ name: claims.name,
122
+ emailVerified: claims.email_verified || false,
123
+ oidcConnections: [
124
+ {
125
+ issuer: claims.iss,
126
+ subject: claims.sub,
127
+ provider: "acme",
128
+ },
129
+ ],
130
+ createdAt: new Date(),
131
+ });
132
+ }
133
+
134
+ // Generate JWT token for YOUR application
135
+ const token = await ctx.plugins.jwtAuth.createToken({ userId: user._id, email: user.email }, ["user"]);
136
+
137
+ return {
138
+ user,
139
+ token,
140
+ redirectUrl: "/dashboard",
141
+ };
142
+ },
143
+
144
+ // Optional: Handle OIDC errors
145
+ onAuthError: async ({ error, provider }) => {
146
+ console.error(`OIDC error for ${provider}:`, error);
147
+ return {
148
+ redirectUrl: `/login?error=${error.code}`,
149
+ };
150
+ },
151
+ }),
152
+ ],
153
+ });
154
+
155
+ await app.start();
156
+ ```
157
+
158
+ ## Configuration
159
+
160
+ ### OidcPluginOptions
161
+
162
+ | Option | Type | Required | Default | Description |
163
+ | --------------------------- | ---------- | -------- | ------------------ | ------------------------------------------------------ |
164
+ | `providers` | `object` | Yes | - | OIDC provider configurations (at least one required) |
165
+ | `storeTokens` | `boolean` | No | `false` | Store encrypted OIDC tokens for future API access |
166
+ | `onAuthSuccess` | `Function` | Yes | - | Callback after successful authentication (JIT) |
167
+ | `onAuthError` | `Function` | No | - | Callback on OIDC errors |
168
+ | `providerLoader` | `Function` | No | - | Dynamic provider loading from database |
169
+ | `sessionTTL` | `number` | No | `600` | Session TTL in seconds (default: 10 minutes) |
170
+ | `sessionsCollectionName` | `string` | No | `"oidc_sessions"` | MongoDB collection for sessions |
171
+ | `connectionsCollectionName` | `string` | No | `"oidc_connections"` | MongoDB collection for connections |
172
+ | `encryptionKey` | `string` | No | (derived) | Encryption key for tokens (32+ chars recommended) |
173
+ | `registerRoutes` | `boolean` | No | `true` | Auto-register OIDC routes |
174
+
175
+ ### Provider Configuration
176
+
177
+ #### OIDC Discovery (Recommended)
178
+
179
+ ```typescript
180
+ {
181
+ issuer: "https://idp.acme.com",
182
+ clientId: "your-client-id",
183
+ clientSecret: "your-client-secret",
184
+ callbackUrl: "https://myapp.com/oidc/acme/callback",
185
+ discoveryUrl: "https://idp.acme.com/.well-known/openid-configuration",
186
+ scope: ["openid", "email", "profile"]
187
+ }
188
+ ```
189
+
190
+ #### Manual Configuration
191
+
192
+ ```typescript
193
+ {
194
+ issuer: "https://idp.acme.com",
195
+ clientId: "your-client-id",
196
+ clientSecret: "your-client-secret",
197
+ callbackUrl: "https://myapp.com/oidc/acme/callback",
198
+ authorizationEndpoint: "https://idp.acme.com/authorize",
199
+ tokenEndpoint: "https://idp.acme.com/token",
200
+ userinfoEndpoint: "https://idp.acme.com/userinfo",
201
+ jwksUri: "https://idp.acme.com/.well-known/jwks.json",
202
+ scope: ["openid", "email", "profile"]
203
+ }
204
+ ```
205
+
206
+ ### Callback Functions
207
+
208
+ #### onAuthSuccess
209
+
210
+ Called when OIDC authentication succeeds. Must generate and return a JWT token.
211
+
212
+ ```typescript
213
+ onAuthSuccess: async (params: {
214
+ profile: OidcProfile; // Normalized user profile
215
+ claims: Record<string, any>; // Raw OIDC claims
216
+ provider: string; // Provider name
217
+ tokens?: OidcTokenSet; // Only if storeTokens: true
218
+ }, ctx: Context) => Promise<{
219
+ user: any;
220
+ token: string; // JWT token from ctx.plugins.jwtAuth.createToken()
221
+ redirectUrl?: string;
222
+ }>
223
+ ```
224
+
225
+ **Important:** The `redirectUrl` should NOT include the token. The plugin automatically appends `#token=...` to the URL.
226
+
227
+ #### onAuthError
228
+
229
+ Called when OIDC authentication fails.
230
+
231
+ ```typescript
232
+ onAuthError: async (params: {
233
+ error: OidcError;
234
+ provider: string;
235
+ }) => Promise<{
236
+ redirectUrl?: string;
237
+ }>
238
+ ```
239
+
240
+ ## OIDC Flow
241
+
242
+ ### Complete Authentication Flow
243
+
244
+ ```
245
+ 1. User visits: GET /login
246
+ → Shows "Login via Portal" button
247
+
248
+ 2. User clicks button
249
+ → Redirects to: GET /oidc/acme/initiate
250
+
251
+ 3. Plugin generates state, code_verifier, nonce, stores session
252
+ → Redirects to IdP: https://idp.acme.com/authorize?
253
+ client_id=...&
254
+ redirect_uri=https://myapp.com/oidc/acme/callback&
255
+ scope=openid+email+profile&
256
+ state=...&
257
+ code_challenge=...&
258
+ code_challenge_method=S256&
259
+ nonce=...&
260
+ response_type=code
261
+
262
+ 4. User logs in at IdP portal
263
+ → IdP validates credentials
264
+
265
+ 5. IdP redirects back: GET /oidc/acme/callback?code=...&state=...
266
+
267
+ 6. Plugin validates state (CSRF protection)
268
+ → Exchanges code for tokens using code_verifier (PKCE)
269
+ → Validates ID token signature (JWT)
270
+ → Validates nonce in ID token (replay protection)
271
+ → Extracts claims from ID token
272
+ → Optionally calls UserInfo endpoint
273
+
274
+ 7. Plugin calls onAuthSuccess with profile
275
+ → App checks if user exists by subject+issuer
276
+ → If not, JIT create user (provisions account)
277
+ → App generates JWT token via ctx.plugins.jwtAuth.createToken()
278
+
279
+ 8. Plugin returns JWT to client
280
+ → Redirects to: https://myapp.com/dashboard#token=eyJ...
281
+
282
+ 9. Frontend extracts token from URL fragment
283
+ → Stores JWT in localStorage/sessionStorage
284
+ → Uses JWT for subsequent API calls
285
+ ```
286
+
287
+ ### Initiate OIDC Flow
288
+
289
+ ```
290
+ GET /oidc/:provider/initiate?redirectUri={optional_redirect}
291
+ ```
292
+
293
+ **Example:**
294
+
295
+ ```
296
+ GET /oidc/acme/initiate
297
+ GET /oidc/acme/initiate?redirectUri=https://myapp.com/welcome
298
+ ```
299
+
300
+ **Response:**
301
+
302
+ - 302 redirect to IdP authorization URL
303
+
304
+ ### OIDC Callback
305
+
306
+ ```
307
+ GET /oidc/:provider/callback?code={auth_code}&state={state}&response_type={json}
308
+ ```
309
+
310
+ **Query Parameters:**
311
+
312
+ - `code` - Authorization code from IdP
313
+ - `state` - CSRF protection token
314
+ - `response_type` - Optional: `json` for JSON response, omit for redirect
315
+
316
+ **Response Formats:**
317
+
318
+ 1. **URL Fragment Redirect** (default):
319
+
320
+ ```
321
+ https://myapp.com/dashboard#token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
322
+ ```
323
+
324
+ 2. **JSON Response** (when `response_type=json`):
325
+
326
+ ```json
327
+ {
328
+ "user": {
329
+ "_id": "...",
330
+ "email": "user@example.com",
331
+ "name": "John Doe"
332
+ },
333
+ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
334
+ }
335
+ ```
336
+
337
+ ### Extracting JWT Token in Frontend
338
+
339
+ **IMPORTANT:** The plugin returns the JWT token as a **URL fragment** (`#token=...`), NOT as a query parameter (`?token=...`).
340
+
341
+ ```javascript
342
+ // ✅ CORRECT - Read from URL fragment (hash)
343
+ const hash = window.location.hash.slice(1); // Remove leading #
344
+ const params = new URLSearchParams(hash);
345
+ const token = params.get("token");
346
+
347
+ // Store JWT token
348
+ localStorage.setItem("jwt_token", token);
349
+
350
+ // Clean URL (remove fragment)
351
+ window.history.replaceState({}, document.title, "/dashboard");
352
+
353
+ // ❌ WRONG - Reading from query parameters won't work
354
+ const params = new URLSearchParams(window.location.search);
355
+ const token = params.get("token"); // This returns null!
356
+ ```
357
+
358
+ ## Context API
359
+
360
+ The plugin exposes methods via `ctx.plugins.oidc`:
361
+
362
+ ### getConnection
363
+
364
+ Get stored OIDC connection for a user and provider.
365
+
366
+ ```typescript
367
+ const connection = await ctx.plugins.oidc.getConnection(userId, "acme");
368
+
369
+ // Returns OidcConnection or null
370
+ interface OidcConnection {
371
+ _id: string;
372
+ userId: string;
373
+ provider: string;
374
+ subject: string; // OIDC sub claim
375
+ issuer: string; // OIDC iss claim
376
+ email?: string;
377
+ accessToken?: string; // Decrypted (if storeTokens enabled)
378
+ idToken?: string; // Decrypted
379
+ refreshToken?: string; // Decrypted
380
+ scope?: string;
381
+ expiresAt?: Date;
382
+ createdAt: Date;
383
+ updatedAt: Date;
384
+ }
385
+ ```
386
+
387
+ ### getConnections
388
+
389
+ Get all OIDC connections for a user.
390
+
391
+ ```typescript
392
+ const connections = await ctx.plugins.oidc.getConnections(userId);
393
+ // Returns OidcConnection[]
394
+ ```
395
+
396
+ ### deleteConnection
397
+
398
+ Delete/unlink an OIDC connection.
399
+
400
+ ```typescript
401
+ await ctx.plugins.oidc.deleteConnection(userId, "acme");
402
+ ```
403
+
404
+ ## Token Storage
405
+
406
+ ### Auth-Only Mode (Default)
407
+
408
+ By default, `storeTokens: false`, meaning OIDC tokens are NOT stored. OIDC is used only for authentication.
409
+
410
+ ```typescript
411
+ oidcPlugin({
412
+ providers: { acme: {...} },
413
+ storeTokens: false, // OIDC tokens discarded after auth
414
+ onAuthSuccess: async ({ profile, claims }, ctx) => {
415
+ // Create user and generate JWT token
416
+ // OIDC tokens are NOT available here
417
+ }
418
+ })
419
+ ```
420
+
421
+ **Use when:**
422
+
423
+ - You only need OIDC for user authentication
424
+ - You don't need to call IdP APIs on behalf of users
425
+ - You want to minimize stored credentials
426
+
427
+ ### Token Storage Mode
428
+
429
+ Set `storeTokens: true` to store encrypted OIDC tokens for future API access.
430
+
431
+ ```typescript
432
+ oidcPlugin({
433
+ providers: { acme: {...} },
434
+ storeTokens: true, // Store encrypted OIDC tokens
435
+ onAuthSuccess: async ({ profile, claims, tokens }, ctx) => {
436
+ // tokens.accessToken, tokens.idToken, tokens.refreshToken are available
437
+ // Tokens are automatically encrypted and stored
438
+ }
439
+ })
440
+ ```
441
+
442
+ **Use when:**
443
+
444
+ - You need to call IdP APIs on behalf of users
445
+ - You want to access user's resources at the IdP
446
+ - You need long-term API access via refresh tokens
447
+
448
+ **Note:** OIDC tokens are encrypted using AES-256-GCM before storage.
449
+
450
+ ## JIT Provisioning Patterns
451
+
452
+ ### Basic JIT
453
+
454
+ ```typescript
455
+ onAuthSuccess: async ({ profile, claims }, ctx) => {
456
+ let user = await ctx.repos.userRepo.getOne({
457
+ "oidcConnections.subject": claims.sub,
458
+ "oidcConnections.issuer": claims.iss,
459
+ });
460
+
461
+ if (!user) {
462
+ user = await ctx.repos.userRepo.create({
463
+ email: claims.email,
464
+ name: claims.name,
465
+ oidcConnections: [
466
+ {
467
+ issuer: claims.iss,
468
+ subject: claims.sub,
469
+ provider: "acme",
470
+ },
471
+ ],
472
+ });
473
+ }
474
+
475
+ const token = await ctx.plugins.jwtAuth.createToken({ userId: user._id }, ["user"]);
476
+
477
+ return { user, token, redirectUrl: "/dashboard" };
478
+ };
479
+ ```
480
+
481
+ ### JIT with Email Matching
482
+
483
+ ```typescript
484
+ onAuthSuccess: async ({ profile, claims }, ctx) => {
485
+ // First try to find by OIDC connection
486
+ let user = await ctx.repos.userRepo.getOne({
487
+ "oidcConnections.subject": claims.sub,
488
+ "oidcConnections.issuer": claims.iss,
489
+ });
490
+
491
+ // If not found, try to find by email
492
+ if (!user) {
493
+ user = await ctx.repos.userRepo.getOne({ email: claims.email });
494
+
495
+ if (user) {
496
+ // Link OIDC connection to existing user
497
+ user.oidcConnections = user.oidcConnections || [];
498
+ user.oidcConnections.push({
499
+ issuer: claims.iss,
500
+ subject: claims.sub,
501
+ provider: "acme",
502
+ });
503
+ await ctx.repos.userRepo.updateOne(user._id, {
504
+ oidcConnections: user.oidcConnections,
505
+ });
506
+ } else {
507
+ // Create new user
508
+ user = await ctx.repos.userRepo.create({
509
+ email: claims.email,
510
+ name: claims.name,
511
+ oidcConnections: [
512
+ {
513
+ issuer: claims.iss,
514
+ subject: claims.sub,
515
+ provider: "acme",
516
+ },
517
+ ],
518
+ });
519
+ }
520
+ }
521
+
522
+ const token = await ctx.plugins.jwtAuth.createToken({ userId: user._id }, ["user"]);
523
+
524
+ return { user, token, redirectUrl: "/dashboard" };
525
+ };
526
+ ```
527
+
528
+ ### JIT with Role Mapping
529
+
530
+ ```typescript
531
+ onAuthSuccess: async ({ profile, claims }, ctx) => {
532
+ const groups = claims.groups || []; // Custom claim from IdP
533
+ const roles = mapGroupsToRoles(groups); // Your mapping logic
534
+
535
+ let user = await ctx.repos.userRepo.getOne({
536
+ "oidcConnections.subject": claims.sub,
537
+ "oidcConnections.issuer": claims.iss,
538
+ });
539
+
540
+ if (!user) {
541
+ user = await ctx.repos.userRepo.create({
542
+ email: claims.email,
543
+ name: claims.name,
544
+ roles,
545
+ oidcConnections: [
546
+ {
547
+ issuer: claims.iss,
548
+ subject: claims.sub,
549
+ provider: "acme",
550
+ },
551
+ ],
552
+ });
553
+ } else {
554
+ // Update roles on each login
555
+ await ctx.repos.userRepo.updateOne(user._id, { roles });
556
+ }
557
+
558
+ const token = await ctx.plugins.jwtAuth.createToken({ userId: user._id }, roles);
559
+
560
+ return { user, token, redirectUrl: "/dashboard" };
561
+ };
562
+
563
+ function mapGroupsToRoles(groups: string[]): string[] {
564
+ const roleMap: Record<string, string> = {
565
+ "admins": "admin",
566
+ "developers": "developer",
567
+ "users": "user",
568
+ };
569
+
570
+ return groups.map((group) => roleMap[group] || "user").filter(Boolean);
571
+ }
572
+ ```
573
+
574
+ ## Multi-Tenant / Dynamic Providers
575
+
576
+ Load OIDC provider configurations from database at runtime:
577
+
578
+ ```typescript
579
+ oidcPlugin({
580
+ providers: {}, // Empty static config
581
+
582
+ // Dynamic loader
583
+ providerLoader: async (providerName) => {
584
+ const config = await ctx.repos.oidcProviderRepo.getByName(providerName);
585
+
586
+ if (!config || !config.enabled) {
587
+ return null;
588
+ }
589
+
590
+ return {
591
+ issuer: config.issuer,
592
+ clientId: config.clientId,
593
+ clientSecret: decryptSecret(config.clientSecret),
594
+ callbackUrl: config.callbackUrl,
595
+ discoveryUrl: config.discoveryUrl,
596
+ scope: config.scope || ["openid", "email", "profile"]
597
+ };
598
+ },
599
+
600
+ onAuthSuccess: async ({ profile, claims, provider }, ctx) => {
601
+ // ... JIT provisioning
602
+ }
603
+ })
604
+ ```
605
+
606
+ ### Database Schema for Dynamic Providers
607
+
608
+ ```typescript
609
+ interface OidcProviderConfigDB {
610
+ _id: string;
611
+ name: string; // Provider name (used in URLs)
612
+ organizationId?: string; // For multi-tenant
613
+ enabled: boolean;
614
+ issuer: string;
615
+ clientId: string;
616
+ clientSecret: string; // Store encrypted!
617
+ callbackUrl: string;
618
+ discoveryUrl?: string;
619
+ authorizationEndpoint?: string;
620
+ tokenEndpoint?: string;
621
+ userinfoEndpoint?: string;
622
+ jwksUri?: string;
623
+ scope: string[];
624
+ createdAt: Date;
625
+ updatedAt: Date;
626
+ }
627
+ ```
628
+
629
+ ## Security
630
+
631
+ ### CSRF Protection
632
+
633
+ The plugin uses cryptographically secure state parameters to prevent CSRF attacks:
634
+
635
+ 1. Generate 32-byte random state using `crypto.randomBytes()`
636
+ 2. Store state in MongoDB session with 10-minute expiration
637
+ 3. Validate state on callback using constant-time comparison
638
+ 4. Clear session after successful validation
639
+
640
+ ### PKCE (Proof Key for Code Exchange)
641
+
642
+ PKCE prevents authorization code interception attacks:
643
+
644
+ 1. Generate `code_verifier` (random 43-128 char string)
645
+ 2. Calculate `code_challenge` = BASE64URL(SHA256(code_verifier))
646
+ 3. Send `code_challenge` in authorization request
647
+ 4. Send `code_verifier` in token exchange
648
+ 5. IdP validates: SHA256(code_verifier) === code_challenge
649
+
650
+ Even if attacker steals authorization code, they can't exchange it without the `code_verifier`.
651
+
652
+ ### ID Token Validation
653
+
654
+ The plugin validates ID tokens automatically:
655
+
656
+ - JWT signature verification using IdP's public keys (JWKS)
657
+ - Issuer (`iss`) claim validation
658
+ - Audience (`aud`) claim validation (must match client ID)
659
+ - Expiration (`exp`) claim validation
660
+ - Nonce validation for replay protection
661
+
662
+ ### Token Encryption
663
+
664
+ When `storeTokens: true`, OIDC tokens are encrypted before storage:
665
+
666
+ - **Algorithm:** AES-256-GCM
667
+ - **Encryption key:** Derived from client secret or custom key
668
+ - **Storage:** Encrypted tokens in MongoDB
669
+ - **Decryption:** Automatic when retrieved via context methods
670
+
671
+ ### HTTPS Requirement
672
+
673
+ **IMPORTANT:** OIDC callback URLs MUST use HTTPS in production. IdPs reject HTTP callback URLs for security reasons.
674
+
675
+ ### Secrets Management
676
+
677
+ Never commit secrets to version control:
678
+
679
+ ```bash
680
+ # .env
681
+ OIDC_ISSUER=https://idp.acme.com
682
+ OIDC_CLIENT_ID=your_client_id
683
+ OIDC_CLIENT_SECRET=your_client_secret
684
+ OIDC_ENCRYPTION_KEY=your_encryption_key_32_chars_min
685
+ JWT_SECRET=your_jwt_secret
686
+ ```
687
+
688
+ ## Client Integration Examples
689
+
690
+ ### React Web App
691
+
692
+ ```typescript
693
+ import React from "react";
694
+
695
+ function LoginPage() {
696
+ const handleOidcLogin = () => {
697
+ // Redirect to OIDC initiation
698
+ window.location.href = "/oidc/acme/initiate?redirectUri=https://myapp.com/dashboard";
699
+ };
700
+
701
+ React.useEffect(() => {
702
+ // Extract token from URL fragment
703
+ const hash = window.location.hash.slice(1);
704
+ const params = new URLSearchParams(hash);
705
+ const token = params.get("token");
706
+
707
+ if (token) {
708
+ // Store JWT token
709
+ localStorage.setItem("jwt_token", token);
710
+
711
+ // Clean URL
712
+ window.history.replaceState({}, document.title, "/dashboard");
713
+
714
+ // Redirect to dashboard
715
+ window.location.href = "/dashboard";
716
+ }
717
+ }, []);
718
+
719
+ return (
720
+ <div>
721
+ <h1>Login</h1>
722
+ <button onClick={handleOidcLogin}>Login via Portal</button>
723
+ </div>
724
+ );
725
+ }
726
+ ```
727
+
728
+ ### React Native App
729
+
730
+ ```typescript
731
+ import { openAuthSessionAsync } from "expo-auth-session";
732
+
733
+ async function loginWithOidc() {
734
+ const result = await openAuthSessionAsync("https://api.myapp.com/oidc/acme/initiate", "myapp://oidc/callback");
735
+
736
+ if (result.type === "success") {
737
+ const url = result.url;
738
+
739
+ // Extract token from URL fragment
740
+ const urlObj = new URL(url);
741
+ const token = new URLSearchParams(urlObj.hash.slice(1)).get("token");
742
+
743
+ if (token) {
744
+ await AsyncStorage.setItem("jwt_token", token);
745
+ // Navigate to home screen
746
+ }
747
+ }
748
+ }
749
+ ```
750
+
751
+ ## Troubleshooting
752
+
753
+ ### OIDC Discovery Failed
754
+
755
+ **Issue:** `discovery_failed` error
756
+
757
+ **Solution:**
758
+
759
+ - Verify discovery URL is correct
760
+ - Check IdP is accessible from your server
761
+ - Try manual endpoint configuration instead
762
+
763
+ ### Invalid State Parameter
764
+
765
+ **Issue:** `invalid_state` error
766
+
767
+ **Solution:**
768
+
769
+ - Ensure cookies are enabled (sessions use MongoDB, but CSRF validation may use cookies)
770
+ - Check session TTL hasn't expired (default: 10 minutes)
771
+ - Verify clock synchronization between servers
772
+
773
+ ### Token Exchange Failed
774
+
775
+ **Issue:** `token_exchange_failed` error
776
+
777
+ **Solution:**
778
+
779
+ - Verify client ID and secret are correct
780
+ - Check callback URL matches exactly (including trailing slashes)
781
+ - Ensure code hasn't expired (typically 10 minutes)
782
+ - Check PKCE is supported by IdP
783
+
784
+ ### ID Token Validation Failed
785
+
786
+ **Issue:** `id_token_validation_failed` error
787
+
788
+ **Solution:**
789
+
790
+ - Verify issuer URL matches ID token `iss` claim
791
+ - Check client ID matches ID token `aud` claim
792
+ - Ensure ID token hasn't expired
793
+ - Verify nonce matches
794
+
795
+ ### JWT Token Not Generated
796
+
797
+ **Issue:** `jwt_generation_failed` error
798
+
799
+ **Solution:**
800
+
801
+ - Ensure JWT Auth Plugin is configured
802
+ - Verify `ctx.plugins.jwtAuth` is available in `onAuthSuccess`
803
+ - Check JWT secret is set in environment variables
804
+
805
+ ## TypeScript Types
806
+
807
+ ```typescript
808
+ import {
809
+ OidcPluginOptions,
810
+ OidcProfile,
811
+ OidcTokenSet,
812
+ OidcConnection,
813
+ OidcError,
814
+ OidcPluginContext,
815
+ } from "@flink-app/oidc-plugin";
816
+ ```
817
+
818
+ ## Production Checklist
819
+
820
+ - [ ] Configure HTTPS for all OIDC callback URLs
821
+ - [ ] Set OIDC credentials in secure environment variables
822
+ - [ ] Configure JWT Auth Plugin with secure secret
823
+ - [ ] Set appropriate JWT token expiration
824
+ - [ ] Implement rate limiting on OIDC endpoints
825
+ - [ ] Set up monitoring and error alerting
826
+ - [ ] Test OIDC flow for all providers
827
+ - [ ] Implement proper error handling in callbacks
828
+ - [ ] Configure CORS for OIDC endpoints
829
+ - [ ] Set up session cleanup and monitoring
830
+ - [ ] Document OIDC provider setup for team
831
+ - [ ] Test JIT provisioning logic
832
+ - [ ] Validate role mapping (if using)
833
+
834
+ ## Examples
835
+
836
+ See the `examples/` directory for complete working examples:
837
+
838
+ - `basic-oidc.ts` - Basic OIDC authentication with JIT provisioning
839
+ - `multi-provider.ts` - Multiple OIDC provider support
840
+ - `token-storage.ts` - Storing OIDC tokens for API access
841
+ - `dynamic-providers.ts` - Loading providers from database
842
+ - `role-mapping.ts` - Mapping IdP groups to app roles
843
+
844
+ ## License
845
+
846
+ MIT