import { FlinkApp, FlinkPlugin, log } from "@flink-app/flink"; import { Db } from "mongodb"; import * as CallbackOAuth from "./handlers/CallbackOAuth"; import * as InitiateOAuth from "./handlers/InitiateOAuth"; import { OAuthInternalContext } from "./OAuthInternalContext"; import { OAuthPluginContext } from "./OAuthPluginContext"; import { OAuthPluginOptions } from "./OAuthPluginOptions"; import OAuthConnectionRepo from "./repos/OAuthConnectionRepo"; import OAuthSessionRepo from "./repos/OAuthSessionRepo"; import OAuthConnection from "./schemas/OAuthConnection"; import { decryptToken, validateEncryptionSecret } from "./utils/encryption-utils"; /** * OAuth Plugin Factory Function * * Creates a Flink plugin for OAuth 2.0 authentication with GitHub and Google providers. * Integrates with JWT Auth Plugin for token generation. * * @param options - OAuth plugin configuration options * @returns FlinkPlugin instance * * @example * ```typescript * import { jwtAuthPlugin } from '@flink-app/jwt-auth-plugin'; * import { oauthPlugin } from '@flink-app/oauth-plugin'; * * const app = new FlinkApp({ * plugins: [ * // JWT Auth Plugin must be registered first * 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'] * } * }), * * // OAuth Plugin uses JWT Auth Plugin in callbacks * oauthPlugin({ * providers: { * github: { * clientId: process.env.GITHUB_CLIENT_ID!, * clientSecret: process.env.GITHUB_CLIENT_SECRET!, * callbackUrl: 'https://myapp.com/oauth/github/callback' * } * }, * onAuthSuccess: async ({ profile, provider }, ctx) => { * // Find or create user * let user = await ctx.repos.userRepo.getOne({ email: profile.email }); * * if (!user) { * user = await ctx.repos.userRepo.create({ * email: profile.email, * name: profile.name, * oauthProvider: provider, * oauthProviderId: profile.id * }); * } * * // Generate JWT token using JWT Auth Plugin * const token = await ctx.plugins.jwtAuth.createToken( * { userId: user._id, email: user.email }, * ['user'] * ); * * return { * user, * token, * redirectUrl: 'https://myapp.com/dashboard' * }; * } * }) * ] * }); * ``` */ export function oauthPlugin(options: OAuthPluginOptions): FlinkPlugin { // Validation if (!options.providers || Object.keys(options.providers).length === 0) { throw new Error("OAuth Plugin: At least one provider must be configured"); } // Validate provider configurations const configuredProviders = Object.keys(options.providers) as ("github" | "google")[]; for (const providerName of configuredProviders) { const providerConfig = options.providers[providerName]; if (!providerConfig) continue; if (!providerConfig.clientId) { throw new Error(`OAuth Plugin: ${providerName} clientId is required`); } if (!providerConfig.clientSecret) { throw new Error(`OAuth Plugin: ${providerName} clientSecret is required`); } if (!providerConfig.callbackUrl) { throw new Error(`OAuth Plugin: ${providerName} callbackUrl is required`); } } if (!options.onAuthSuccess) { throw new Error("OAuth 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( "OAuth 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("OAuth Plugin: Encryption key must be at least 32 characters"); } // Validate encryption key validateEncryptionSecret(encryptionKey); let flinkApp: FlinkApp; let sessionRepo: OAuthSessionRepo; let connectionRepo: OAuthConnectionRepo; /** * Plugin initialization */ async function init(app: FlinkApp, db?: Db) { log.info("Initializing OAuth Plugin..."); flinkApp = app as FlinkApp; try { if (!db) { throw new Error("OAuth Plugin: Database connection is required"); } // Initialize repositories const sessionsCollectionName = options.sessionsCollectionName || "oauth_sessions"; const connectionsCollectionName = options.connectionsCollectionName || "oauth_connections"; sessionRepo = new OAuthSessionRepo(sessionsCollectionName, db); connectionRepo = new OAuthConnectionRepo(connectionsCollectionName, db); flinkApp.addRepo("oauthSessionRepo", sessionRepo); flinkApp.addRepo("oauthConnectionRepo", connectionRepo); // 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(`OAuth Plugin: Created TTL index on ${sessionsCollectionName} with ${sessionTTL}s expiration`); // Register OAuth handlers // Only register handlers if registerRoutes is enabled (default: true) if (options.registerRoutes !== false) { flinkApp.addHandler(InitiateOAuth); flinkApp.addHandler(CallbackOAuth); } log.info(`OAuth Plugin initialized with providers: ${configuredProviders.join(", ")}`); } catch (error) { log.error("Failed to initialize OAuth Plugin:", error); throw error; } } /** * Get OAuth connection for a user */ async function getConnection(userId: string, provider: "github" | "google"): Promise { if (!connectionRepo) { throw new Error("OAuth 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.refreshToken && encryptionKey) { connection.refreshToken = decryptToken(connection.refreshToken, encryptionKey); } return connection; } /** * Get all OAuth connections for a user */ async function getConnections(userId: string): Promise { if (!connectionRepo) { throw new Error("OAuth 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.refreshToken && encryptionKey) { connection.refreshToken = decryptToken(connection.refreshToken, encryptionKey); } return connection; }); } /** * Delete OAuth connection for a user */ async function deleteConnection(userId: string, provider: "github" | "google"): Promise { if (!connectionRepo) { throw new Error("OAuth Plugin: Plugin not initialized"); } await connectionRepo.deleteByUserAndProvider(userId, provider); log.info(`OAuth Plugin: Deleted ${provider} connection for user ${userId}`); } /** * Plugin context exposed via ctx.plugins.oauth */ const pluginCtx: OAuthPluginContext["oauth"] = { getConnection, getConnections, deleteConnection, options: Object.freeze({ ...options }), }; return { id: "oauth", db: { useHostDb: true, }, ctx: pluginCtx, init, }; }