Flink plugin for OIDC authentication with generic IdP support
npm install @flink-app/oidc-pluginA 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.
- OpenID Connect Authorization Code flow with any OIDC-compliant IdP
- Automatic JWT token generation via JWT Auth Plugin integration
- MongoDB session storage with automatic TTL cleanup
- Support for multiple OIDC providers per application
- OIDC Discovery support (automatic endpoint configuration)
- Manual endpoint configuration for custom IdPs
- PKCE (Proof Key for Code Exchange) for enhanced security
- CSRF protection with cryptographically secure state parameters
- Nonce validation for ID token replay protection
- Encrypted token storage (AES-256-GCM)
- JIT (Just-In-Time) user provisioning
- Built-in HTTP endpoints for OIDC flow
- TypeScript support with full type safety
- Configurable response formats (JSON, URL fragment)
- Dynamic provider loading from database
``bash`
npm install @flink-app/oidc-plugin @flink-app/jwt-auth-plugin
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.
You need OIDC application credentials from your Identity Provider:
#### Generic OIDC Provider Setup
1. Register your application with your IdP
2. Configure the redirect URI: https://yourdomain.com/oidc/{provider}/callbackhttps://idp.example.com
3. Obtain Client ID and Client Secret
4. Note the Issuer URL (e.g., ){issuer}/.well-known/openid-configuration
5. Get the Discovery URL (usually )
#### Common OIDC Providers
- Azure AD / Entra ID: https://login.microsoftonline.com/{tenant}/v2.0https://{domain}.okta.com
- Okta: https://{domain}.auth0.com
- Auth0: https://{domain}/realms/{realm}
- Keycloak: https://accounts.google.com
- Google: (use oauth-plugin for Google)
The plugin requires MongoDB to store OIDC sessions during the authentication flow.
`typescript
import { FlinkApp } from "@flink-app/flink";
import { jwtAuthPlugin } from "@flink-app/jwt-auth-plugin";
import { oidcPlugin } from "@flink-app/oidc-plugin";
import { Context } from "./Context";
const app = new FlinkApp
name: "My App",
// JWT Auth Plugin MUST be configured first
auth: jwtAuthPlugin({
secret: process.env.JWT_SECRET!,
getUser: async (tokenData) => {
return await app.ctx.repos.userRepo.getById(tokenData.userId);
},
rolePermissions: {
user: ["read:own", "write:own"],
admin: ["read:all", "write:all"],
},
}),
db: {
uri: process.env.MONGODB_URI!,
},
plugins: [
oidcPlugin({
providers: {
// Provider name used in URLs: /oidc/acme/initiate
acme: {
issuer: process.env.OIDC_ISSUER!,
clientId: process.env.OIDC_CLIENT_ID!,
clientSecret: process.env.OIDC_CLIENT_SECRET!,
callbackUrl: "https://myapp.com/oidc/acme/callback",
// Option 1: Use OIDC Discovery (recommended)
discoveryUrl: ${process.env.OIDC_ISSUER}/.well-known/openid-configuration,
// Option 2: Manual endpoint configuration (if discovery not available)
// authorizationEndpoint: "https://idp.acme.com/authorize",
// tokenEndpoint: "https://idp.acme.com/token",
// userinfoEndpoint: "https://idp.acme.com/userinfo",
// jwksUri: "https://idp.acme.com/.well-known/jwks.json",
scope: ["openid", "email", "profile"],
},
},
// Callback after successful OIDC authentication (JIT provisioning)
onAuthSuccess: async ({ profile, claims, provider }, ctx) => {
// Find user by OIDC subject + issuer (unique IdP identifier)
let user = await ctx.repos.userRepo.getOne({
"oidcConnections.subject": claims.sub,
"oidcConnections.issuer": claims.iss,
});
if (!user) {
// JIT provisioning - create new user
user = await ctx.repos.userRepo.create({
email: claims.email,
name: claims.name,
emailVerified: claims.email_verified || false,
oidcConnections: [
{
issuer: claims.iss,
subject: claims.sub,
provider: "acme",
},
],
createdAt: new Date(),
});
}
// Generate JWT token for YOUR application
const token = await ctx.plugins.jwtAuth.createToken({ userId: user._id, email: user.email }, ["user"]);
return {
user,
token,
redirectUrl: "/dashboard",
};
},
// Optional: Handle OIDC errors
onAuthError: async ({ error, provider }) => {
console.error(OIDC error for ${provider}:, error);/login?error=${error.code}
return {
redirectUrl: ,
};
},
}),
],
});
await app.start();
`
| Option | Type | Required | Default | Description |
| --------------------------- | ---------- | -------- | ------------------ | ------------------------------------------------------ |
| providers | object | Yes | - | OIDC provider configurations (at least one required) |storeTokens
| | boolean | No | false | Store encrypted OIDC tokens for future API access |onAuthSuccess
| | Function | Yes | - | Callback after successful authentication (JIT) |onAuthError
| | Function | No | - | Callback on OIDC errors |providerLoader
| | Function | No | - | Dynamic provider loading from database |sessionTTL
| | number | No | 600 | Session TTL in seconds (default: 10 minutes) |sessionsCollectionName
| | string | No | "oidc_sessions" | MongoDB collection for sessions |connectionsCollectionName
| | string | No | "oidc_connections" | MongoDB collection for connections |encryptionKey
| | string | No | (derived) | Encryption key for tokens (32+ chars recommended) |registerRoutes
| | boolean | No | true | Auto-register OIDC routes |
#### OIDC Discovery (Recommended)
`typescript`
{
issuer: "https://idp.acme.com",
clientId: "your-client-id",
clientSecret: "your-client-secret",
callbackUrl: "https://myapp.com/oidc/acme/callback",
discoveryUrl: "https://idp.acme.com/.well-known/openid-configuration",
scope: ["openid", "email", "profile"]
}
#### Manual Configuration
`typescript`
{
issuer: "https://idp.acme.com",
clientId: "your-client-id",
clientSecret: "your-client-secret",
callbackUrl: "https://myapp.com/oidc/acme/callback",
authorizationEndpoint: "https://idp.acme.com/authorize",
tokenEndpoint: "https://idp.acme.com/token",
userinfoEndpoint: "https://idp.acme.com/userinfo",
jwksUri: "https://idp.acme.com/.well-known/jwks.json",
scope: ["openid", "email", "profile"]
}
#### onAuthSuccess
Called when OIDC authentication succeeds. Must generate and return a JWT token.
`typescript`
onAuthSuccess: async (params: {
profile: OidcProfile; // Normalized user profile
claims: Record
provider: string; // Provider name
tokens?: OidcTokenSet; // Only if storeTokens: true
}, ctx: Context) => Promise<{
user: any;
token: string; // JWT token from ctx.plugins.jwtAuth.createToken()
redirectUrl?: string;
}>
Important: The redirectUrl should NOT include the token. The plugin automatically appends #token=... to the URL.
#### onAuthError
Called when OIDC authentication fails.
`typescript`
onAuthError: async (params: {
error: OidcError;
provider: string;
}) => Promise<{
redirectUrl?: string;
}>
`
1. User visits: GET /login
→ Shows "Login via Portal" button
2. User clicks button
→ Redirects to: GET /oidc/acme/initiate
3. Plugin generates state, code_verifier, nonce, stores session
→ Redirects to IdP: https://idp.acme.com/authorize?
client_id=...&
redirect_uri=https://myapp.com/oidc/acme/callback&
scope=openid+email+profile&
state=...&
code_challenge=...&
code_challenge_method=S256&
nonce=...&
response_type=code
4. User logs in at IdP portal
→ IdP validates credentials
5. IdP redirects back: GET /oidc/acme/callback?code=...&state=...
6. Plugin validates state (CSRF protection)
→ Exchanges code for tokens using code_verifier (PKCE)
→ Validates ID token signature (JWT)
→ Validates nonce in ID token (replay protection)
→ Extracts claims from ID token
→ Optionally calls UserInfo endpoint
7. Plugin calls onAuthSuccess with profile
→ App checks if user exists by subject+issuer
→ If not, JIT create user (provisions account)
→ App generates JWT token via ctx.plugins.jwtAuth.createToken()
8. Plugin returns JWT to client
→ Redirects to: https://myapp.com/dashboard#token=eyJ...
9. Frontend extracts token from URL fragment
→ Stores JWT in localStorage/sessionStorage
→ Uses JWT for subsequent API calls
`
``
GET /oidc/:provider/initiate?redirectUri={optional_redirect}
Example:
``
GET /oidc/acme/initiate
GET /oidc/acme/initiate?redirectUri=https://myapp.com/welcome
Response:
- 302 redirect to IdP authorization URL
``
GET /oidc/:provider/callback?code={auth_code}&state={state}&response_type={json}
Query Parameters:
- code - Authorization code from IdPstate
- - CSRF protection tokenresponse_type
- - Optional: json for JSON response, omit for redirect
Response Formats:
1. URL Fragment Redirect (default):
``
https://myapp.com/dashboard#token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
2. JSON Response (when response_type=json):
`json`
{
"user": {
"_id": "...",
"email": "user@example.com",
"name": "John Doe"
},
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
IMPORTANT: The plugin returns the JWT token as a URL fragment (#token=...), NOT as a query parameter (?token=...).
`javascript
// ✅ CORRECT - Read from URL fragment (hash)
const hash = window.location.hash.slice(1); // Remove leading #
const params = new URLSearchParams(hash);
const token = params.get("token");
// Store JWT token
localStorage.setItem("jwt_token", token);
// Clean URL (remove fragment)
window.history.replaceState({}, document.title, "/dashboard");
// ❌ WRONG - Reading from query parameters won't work
const params = new URLSearchParams(window.location.search);
const token = params.get("token"); // This returns null!
`
The plugin exposes methods via ctx.plugins.oidc:
Get stored OIDC connection for a user and provider.
`typescript
const connection = await ctx.plugins.oidc.getConnection(userId, "acme");
// Returns OidcConnection or null
interface OidcConnection {
_id: string;
userId: string;
provider: string;
subject: string; // OIDC sub claim
issuer: string; // OIDC iss claim
email?: string;
accessToken?: string; // Decrypted (if storeTokens enabled)
idToken?: string; // Decrypted
refreshToken?: string; // Decrypted
scope?: string;
expiresAt?: Date;
createdAt: Date;
updatedAt: Date;
}
`
Get all OIDC connections for a user.
`typescript`
const connections = await ctx.plugins.oidc.getConnections(userId);
// Returns OidcConnection[]
Delete/unlink an OIDC connection.
`typescript`
await ctx.plugins.oidc.deleteConnection(userId, "acme");
By default, storeTokens: false, meaning OIDC tokens are NOT stored. OIDC is used only for authentication.
`typescript`
oidcPlugin({
providers: { acme: {...} },
storeTokens: false, // OIDC tokens discarded after auth
onAuthSuccess: async ({ profile, claims }, ctx) => {
// Create user and generate JWT token
// OIDC tokens are NOT available here
}
})
Use when:
- You only need OIDC for user authentication
- You don't need to call IdP APIs on behalf of users
- You want to minimize stored credentials
Set storeTokens: true to store encrypted OIDC tokens for future API access.
`typescript`
oidcPlugin({
providers: { acme: {...} },
storeTokens: true, // Store encrypted OIDC tokens
onAuthSuccess: async ({ profile, claims, tokens }, ctx) => {
// tokens.accessToken, tokens.idToken, tokens.refreshToken are available
// Tokens are automatically encrypted and stored
}
})
Use when:
- You need to call IdP APIs on behalf of users
- You want to access user's resources at the IdP
- You need long-term API access via refresh tokens
Note: OIDC tokens are encrypted using AES-256-GCM before storage.
`typescript
onAuthSuccess: async ({ profile, claims }, ctx) => {
let user = await ctx.repos.userRepo.getOne({
"oidcConnections.subject": claims.sub,
"oidcConnections.issuer": claims.iss,
});
if (!user) {
user = await ctx.repos.userRepo.create({
email: claims.email,
name: claims.name,
oidcConnections: [
{
issuer: claims.iss,
subject: claims.sub,
provider: "acme",
},
],
});
}
const token = await ctx.plugins.jwtAuth.createToken({ userId: user._id }, ["user"]);
return { user, token, redirectUrl: "/dashboard" };
};
`
`typescript
onAuthSuccess: async ({ profile, claims }, ctx) => {
// First try to find by OIDC connection
let user = await ctx.repos.userRepo.getOne({
"oidcConnections.subject": claims.sub,
"oidcConnections.issuer": claims.iss,
});
// If not found, try to find by email
if (!user) {
user = await ctx.repos.userRepo.getOne({ email: claims.email });
if (user) {
// Link OIDC connection to existing user
user.oidcConnections = user.oidcConnections || [];
user.oidcConnections.push({
issuer: claims.iss,
subject: claims.sub,
provider: "acme",
});
await ctx.repos.userRepo.updateOne(user._id, {
oidcConnections: user.oidcConnections,
});
} else {
// Create new user
user = await ctx.repos.userRepo.create({
email: claims.email,
name: claims.name,
oidcConnections: [
{
issuer: claims.iss,
subject: claims.sub,
provider: "acme",
},
],
});
}
}
const token = await ctx.plugins.jwtAuth.createToken({ userId: user._id }, ["user"]);
return { user, token, redirectUrl: "/dashboard" };
};
`
`typescript
onAuthSuccess: async ({ profile, claims }, ctx) => {
const groups = claims.groups || []; // Custom claim from IdP
const roles = mapGroupsToRoles(groups); // Your mapping logic
let user = await ctx.repos.userRepo.getOne({
"oidcConnections.subject": claims.sub,
"oidcConnections.issuer": claims.iss,
});
if (!user) {
user = await ctx.repos.userRepo.create({
email: claims.email,
name: claims.name,
roles,
oidcConnections: [
{
issuer: claims.iss,
subject: claims.sub,
provider: "acme",
},
],
});
} else {
// Update roles on each login
await ctx.repos.userRepo.updateOne(user._id, { roles });
}
const token = await ctx.plugins.jwtAuth.createToken({ userId: user._id }, roles);
return { user, token, redirectUrl: "/dashboard" };
};
function mapGroupsToRoles(groups: string[]): string[] {
const roleMap: Record
"admins": "admin",
"developers": "developer",
"users": "user",
};
return groups.map((group) => roleMap[group] || "user").filter(Boolean);
}
`
Load OIDC provider configurations from database at runtime:
`typescript
oidcPlugin({
providers: {}, // Empty static config
// Dynamic loader
providerLoader: async (providerName) => {
const config = await ctx.repos.oidcProviderRepo.getByName(providerName);
if (!config || !config.enabled) {
return null;
}
return {
issuer: config.issuer,
clientId: config.clientId,
clientSecret: decryptSecret(config.clientSecret),
callbackUrl: config.callbackUrl,
discoveryUrl: config.discoveryUrl,
scope: config.scope || ["openid", "email", "profile"]
};
},
onAuthSuccess: async ({ profile, claims, provider }, ctx) => {
// ... JIT provisioning
}
})
`
`typescript`
interface OidcProviderConfigDB {
_id: string;
name: string; // Provider name (used in URLs)
organizationId?: string; // For multi-tenant
enabled: boolean;
issuer: string;
clientId: string;
clientSecret: string; // Store encrypted!
callbackUrl: string;
discoveryUrl?: string;
authorizationEndpoint?: string;
tokenEndpoint?: string;
userinfoEndpoint?: string;
jwksUri?: string;
scope: string[];
createdAt: Date;
updatedAt: Date;
}
The plugin uses cryptographically secure state parameters to prevent CSRF attacks:
1. Generate 32-byte random state using crypto.randomBytes()
2. Store state in MongoDB session with 10-minute expiration
3. Validate state on callback using constant-time comparison
4. Clear session after successful validation
PKCE prevents authorization code interception attacks:
1. Generate code_verifier (random 43-128 char string)code_challenge
2. Calculate = BASE64URL(SHA256(code_verifier))code_challenge
3. Send in authorization requestcode_verifier
4. Send in token exchange
5. IdP validates: SHA256(code_verifier) === code_challenge
Even if attacker steals authorization code, they can't exchange it without the code_verifier.
The plugin validates ID tokens automatically:
- JWT signature verification using IdP's public keys (JWKS)
- Issuer (iss) claim validationaud
- Audience () claim validation (must match client ID)exp
- Expiration () claim validation
- Nonce validation for replay protection
When storeTokens: true, OIDC tokens are encrypted before storage:
- Algorithm: AES-256-GCM
- Encryption key: Derived from client secret or custom key
- Storage: Encrypted tokens in MongoDB
- Decryption: Automatic when retrieved via context methods
IMPORTANT: OIDC callback URLs MUST use HTTPS in production. IdPs reject HTTP callback URLs for security reasons.
Never commit secrets to version control:
`bash`.env
OIDC_ISSUER=https://idp.acme.com
OIDC_CLIENT_ID=your_client_id
OIDC_CLIENT_SECRET=your_client_secret
OIDC_ENCRYPTION_KEY=your_encryption_key_32_chars_min
JWT_SECRET=your_jwt_secret
`typescript
import React from "react";
function LoginPage() {
const handleOidcLogin = () => {
// Redirect to OIDC initiation
window.location.href = "/oidc/acme/initiate?redirectUri=https://myapp.com/dashboard";
};
React.useEffect(() => {
// Extract token from URL fragment
const hash = window.location.hash.slice(1);
const params = new URLSearchParams(hash);
const token = params.get("token");
if (token) {
// Store JWT token
localStorage.setItem("jwt_token", token);
// Clean URL
window.history.replaceState({}, document.title, "/dashboard");
// Redirect to dashboard
window.location.href = "/dashboard";
}
}, []);
return (
$3
`typescript
import { openAuthSessionAsync } from "expo-auth-session";async function loginWithOidc() {
const result = await openAuthSessionAsync("https://api.myapp.com/oidc/acme/initiate", "myapp://oidc/callback");
if (result.type === "success") {
const url = result.url;
// Extract token from URL fragment
const urlObj = new URL(url);
const token = new URLSearchParams(urlObj.hash.slice(1)).get("token");
if (token) {
await AsyncStorage.setItem("jwt_token", token);
// Navigate to home screen
}
}
}
`Troubleshooting
$3
Issue:
discovery_failed errorSolution:
- Verify discovery URL is correct
- Check IdP is accessible from your server
- Try manual endpoint configuration instead
$3
Issue:
invalid_state errorSolution:
- Ensure cookies are enabled (sessions use MongoDB, but CSRF validation may use cookies)
- Check session TTL hasn't expired (default: 10 minutes)
- Verify clock synchronization between servers
$3
Issue:
token_exchange_failed errorSolution:
- Verify client ID and secret are correct
- Check callback URL matches exactly (including trailing slashes)
- Ensure code hasn't expired (typically 10 minutes)
- Check PKCE is supported by IdP
$3
Issue:
id_token_validation_failed errorSolution:
- Verify issuer URL matches ID token
iss claim
- Check client ID matches ID token aud claim
- Ensure ID token hasn't expired
- Verify nonce matches$3
Issue:
jwt_generation_failed errorSolution:
- Ensure JWT Auth Plugin is configured
- Verify
ctx.plugins.jwtAuth is available in onAuthSuccess
- Check JWT secret is set in environment variablesTypeScript Types
`typescript
import {
OidcPluginOptions,
OidcProfile,
OidcTokenSet,
OidcConnection,
OidcError,
OidcPluginContext,
} from "@flink-app/oidc-plugin";
`Production Checklist
- [ ] Configure HTTPS for all OIDC callback URLs
- [ ] Set OIDC credentials in secure environment variables
- [ ] Configure JWT Auth Plugin with secure secret
- [ ] Set appropriate JWT token expiration
- [ ] Implement rate limiting on OIDC endpoints
- [ ] Set up monitoring and error alerting
- [ ] Test OIDC flow for all providers
- [ ] Implement proper error handling in callbacks
- [ ] Configure CORS for OIDC endpoints
- [ ] Set up session cleanup and monitoring
- [ ] Document OIDC provider setup for team
- [ ] Test JIT provisioning logic
- [ ] Validate role mapping (if using)
Examples
See the
examples/ directory for complete working examples:-
basic-oidc.ts - Basic OIDC authentication with JIT provisioning
- multi-provider.ts - Multiple OIDC provider support
- token-storage.ts - Storing OIDC tokens for API access
- dynamic-providers.ts - Loading providers from database
- role-mapping.ts` - Mapping IdP groups to app rolesMIT