import { FlinkApp, FlinkPlugin, log } from "@flink-app/flink"; import { Db } from "mongodb"; import { OidcPluginOptions } from "./OidcPluginOptions"; import { OidcPluginContext } from "./OidcPluginContext"; import { OidcInternalContext } from "./OidcInternalContext"; import OidcSessionRepo from "./repos/OidcSessionRepo"; import OidcConnectionRepo from "./repos/OidcConnectionRepo"; import { encryptToken, decryptToken, validateEncryptionSecret } from "./utils/encryption-utils"; import OidcConnection from "./schemas/OidcConnection"; import { ProviderRegistry } from "./providers/ProviderRegistry"; import * as InitiateOidc from "./handlers/InitiateOidc"; import * as CallbackOidc from "./handlers/CallbackOidc"; /** * OIDC Plugin Factory Function * * Creates a Flink plugin for OIDC authentication with generic IdP support. * Integrates with JWT Auth Plugin for token generation. * * @param options - OIDC plugin configuration options * @returns FlinkPlugin instance * * @example * ```typescript * import { jwtAuthPlugin } from '@flink-app/jwt-auth-plugin'; * import { oidcPlugin } from '@flink-app/oidc-plugin'; * * const app = new FlinkApp({ * auth: jwtAuthPlugin({ * secret: process.env.JWT_SECRET!, * getUser: async (tokenData) => { * return ctx.repos.userRepo.getById(tokenData.userId); * }, * rolePermissions: { * user: ['read:own'], * admin: ['read:all', 'write:all'] * } * }), * * plugins: [ * oidcPlugin({ * providers: { * acme: { * issuer: 'https://idp.acme.com', * clientId: process.env.OIDC_CLIENT_ID!, * clientSecret: process.env.OIDC_CLIENT_SECRET!, * callbackUrl: 'https://myapp.com/oidc/acme/callback', * discoveryUrl: 'https://idp.acme.com/.well-known/openid-configuration' * } * }, * onAuthSuccess: async ({ profile, claims, provider }, ctx) => { * // Find or create user (JIT provisioning) * 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 * }] * }); * } * * // Generate JWT token * const token = await ctx.plugins.jwtAuth.createToken( * { userId: user._id, email: user.email }, * ['user'] * ); * * return { * user, * token, * redirectUrl: '/dashboard' * }; * } * }) * ] * }); * ``` */ export function oidcPlugin(options: OidcPluginOptions): FlinkPlugin { // Validation if (!options.providers || Object.keys(options.providers).length === 0) { throw new Error("OIDC Plugin: At least one provider must be configured"); } // Validate provider configurations const configuredProviders = Object.keys(options.providers); for (const providerName of configuredProviders) { const providerConfig = options.providers[providerName]; if (!providerConfig) continue; if (!providerConfig.issuer) { throw new Error(`OIDC Plugin: ${providerName} issuer is required`); } if (!providerConfig.clientId) { throw new Error(`OIDC Plugin: ${providerName} clientId is required`); } if (!providerConfig.clientSecret) { throw new Error(`OIDC Plugin: ${providerName} clientSecret is required`); } if (!providerConfig.callbackUrl) { throw new Error(`OIDC Plugin: ${providerName} callbackUrl is required`); } // Validate that either discoveryUrl or manual endpoints are provided if (!providerConfig.discoveryUrl) { if (!providerConfig.authorizationEndpoint || !providerConfig.tokenEndpoint || !providerConfig.jwksUri) { throw new Error( `OIDC Plugin: ${providerName} must have either discoveryUrl or manual endpoints ` + `(authorizationEndpoint, tokenEndpoint, jwksUri)` ); } } } if (!options.onAuthSuccess) { throw new Error("OIDC Plugin: onAuthSuccess callback is required"); } // Determine encryption key let encryptionKey = options.encryptionKey; if (!encryptionKey) { // Derive from first configured provider's client secret const firstProvider = configuredProviders[0]; const firstProviderConfig = options.providers[firstProvider]; if (firstProviderConfig) { encryptionKey = firstProviderConfig.clientSecret; log.warn( "OIDC Plugin: No encryption key provided, deriving from client secret. " + "For better security, provide a dedicated encryptionKey in options." ); } } if (!encryptionKey || encryptionKey.length < 32) { throw new Error("OIDC Plugin: Encryption key must be at least 32 characters"); } // Validate encryption key validateEncryptionSecret(encryptionKey); let flinkApp: FlinkApp; let sessionRepo: OidcSessionRepo; let connectionRepo: OidcConnectionRepo; let providerRegistry: ProviderRegistry; /** * Plugin initialization */ async function init(app: FlinkApp, db?: Db) { log.info("Initializing OIDC Plugin..."); flinkApp = app as FlinkApp; try { if (!db) { throw new Error("OIDC Plugin: Database connection is required"); } // Initialize repositories const sessionsCollectionName = options.sessionsCollectionName || "oidc_sessions"; const connectionsCollectionName = options.connectionsCollectionName || "oidc_connections"; sessionRepo = new OidcSessionRepo(sessionsCollectionName, db); connectionRepo = new OidcConnectionRepo(connectionsCollectionName, db); flinkApp.addRepo("oidcSessionRepo", sessionRepo as any); flinkApp.addRepo("oidcConnectionRepo", connectionRepo as any); // Create TTL index for session expiration const sessionTTL = options.sessionTTL || 600; // Default 10 minutes await db.collection(sessionsCollectionName).createIndex({ createdAt: 1 }, { expireAfterSeconds: sessionTTL }); log.info(`OIDC Plugin: Created TTL index on ${sessionsCollectionName} with ${sessionTTL}s expiration`); // Initialize provider registry providerRegistry = new ProviderRegistry(options.providers, options.providerLoader); // Store provider registry in app context for handlers to access (flinkApp.ctx as any).oidcProviderRegistry = providerRegistry; // Register OIDC handlers // Only register handlers if registerRoutes is enabled (default: true) if (options.registerRoutes !== false) { flinkApp.addHandler(InitiateOidc); flinkApp.addHandler(CallbackOidc); } log.info(`OIDC Plugin initialized with providers: ${configuredProviders.join(", ")}`); } catch (error) { log.error("Failed to initialize OIDC Plugin:", error); throw error; } } /** * Get OIDC connection for a user */ async function getConnection(userId: string, provider: string): Promise { if (!connectionRepo) { throw new Error("OIDC Plugin: Plugin not initialized"); } const connection = await connectionRepo.findByUserAndProvider(userId, provider); if (!connection) { return null; } // Decrypt tokens before returning if (connection.accessToken && encryptionKey) { connection.accessToken = decryptToken(connection.accessToken, encryptionKey); } if (connection.idToken && encryptionKey) { connection.idToken = decryptToken(connection.idToken, encryptionKey); } if (connection.refreshToken && encryptionKey) { connection.refreshToken = decryptToken(connection.refreshToken, encryptionKey); } return connection; } /** * Get all OIDC connections for a user */ async function getConnections(userId: string): Promise { if (!connectionRepo) { throw new Error("OIDC Plugin: Plugin not initialized"); } const connections = await connectionRepo.findByUserId(userId); // Decrypt tokens in each connection return connections.map((connection) => { if (connection.accessToken && encryptionKey) { connection.accessToken = decryptToken(connection.accessToken, encryptionKey); } if (connection.idToken && encryptionKey) { connection.idToken = decryptToken(connection.idToken, encryptionKey); } if (connection.refreshToken && encryptionKey) { connection.refreshToken = decryptToken(connection.refreshToken, encryptionKey); } return connection; }); } /** * Delete OIDC connection for a user */ async function deleteConnection(userId: string, provider: string): Promise { if (!connectionRepo) { throw new Error("OIDC Plugin: Plugin not initialized"); } await connectionRepo.deleteByUserAndProvider(userId, provider); log.info(`OIDC Plugin: Deleted ${provider} connection for user ${userId}`); } /** * Plugin context exposed via ctx.plugins.oidc */ const pluginCtx: OidcPluginContext["oidc"] = { getConnection, getConnections, deleteConnection, options: Object.freeze({ ...options }), }; return { id: "oidc", db: { useHostDb: true, }, ctx: pluginCtx, init, }; }