/** * Multiple Provider Linking Example * * This example demonstrates: * - Linking multiple OAuth providers (GitHub + Google) to a single user account * - Handling conflicts when multiple providers return the same email * - Showing linked providers in user profile * - Unlinking providers */ import { FlinkApp, Handler, GetHandler, RouteProps } from "@flink-app/flink"; import { jwtAuthPlugin } from "@flink-app/jwt-auth-plugin"; import { oauthPlugin } from "@flink-app/oauth-plugin"; import { FlinkContext, FlinkRepo } from "@flink-app/flink"; // User with multiple OAuth providers interface User { _id?: string; email: string; name?: string; avatarUrl?: string; primaryProvider: "github" | "google"; oauthProviders: Array<{ provider: "github" | "google"; providerId: string; linkedAt: Date; }>; roles: string[]; createdAt: Date; updatedAt: Date; } class UserRepo extends FlinkRepo { async findByEmail(email: string) { return this.getOne({ email }); } async findByOAuthProvider(provider: string, providerId: string) { return this.getOne({ "oauthProviders.provider": provider, "oauthProviders.providerId": providerId, }); } } interface AppContext extends FlinkContext { repos: { userRepo: UserRepo; }; plugins: { jwtAuth: any; oauth: any; }; } async function start() { const app = new FlinkApp({ name: "Multi-Provider OAuth Example", port: 3334, db: { uri: process.env.MONGODB_URI || "mongodb://localhost:27017/oauth-multi-example", }, auth: jwtAuthPlugin({ secret: process.env.JWT_SECRET || "your-super-secret-jwt-key", getUser: async (tokenData) => { const user = await app.ctx.repos.userRepo.getById(tokenData.userId); if (!user) throw new Error("User not found"); return { id: user._id, email: user.email, roles: user.roles, }; }, rolePermissions: { user: ["read:own", "write:own"], admin: ["read:all", "write:all"], }, expiresIn: "7d", }), plugins: [ oauthPlugin({ providers: { github: { clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, callbackUrl: process.env.GITHUB_CALLBACK_URL || "http://localhost:3334/oauth/github/callback", }, google: { clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET!, callbackUrl: process.env.GOOGLE_CALLBACK_URL || "http://localhost:3334/oauth/google/callback", }, }, storeTokens: false, onAuthSuccess: async ({ profile, provider }, ctx) => { console.log( `\n=== OAuth Success: ${provider} (${profile.email}) ===` ); // Strategy 1: Find user by email (same email = same person) let user = await ctx.repos.userRepo.findByEmail(profile.email); // Strategy 2: Find user by provider ID (for cases where email changed) if (!user) { user = await ctx.repos.userRepo.findByOAuthProvider( provider, profile.id ); } if (user) { console.log(`Found existing user: ${user.email}`); // Check if this provider is already linked const existingProvider = user.oauthProviders.find( (p) => p.provider === provider ); if (existingProvider) { // Provider already linked if (existingProvider.providerId !== profile.id) { console.log( `WARNING: Provider ID changed for ${provider}. Updating...` ); // Update provider ID (rare case where user's provider ID changed) user = await ctx.repos.userRepo.updateOne(user._id!, { oauthProviders: user.oauthProviders.map((p) => p.provider === provider ? { ...p, providerId: profile.id } : p ), updatedAt: new Date(), }); } else { console.log(`${provider} already linked to this user`); } } else { // Link new provider to existing user console.log(`Linking ${provider} to existing user ${user.email}`); user = await ctx.repos.userRepo.updateOne(user._id!, { oauthProviders: [ ...user.oauthProviders, { provider, providerId: profile.id, linkedAt: new Date(), }, ], // Update avatar if not set or from same provider avatarUrl: profile.avatarUrl || user.avatarUrl, // Update name if from primary provider or not set name: provider === user.primaryProvider || !user.name ? profile.name || user.name : user.name, updatedAt: new Date(), }); console.log( `Successfully linked ${provider}. User now has ${user.oauthProviders.length} provider(s)` ); } } else { // New user - create account with first provider console.log(`Creating new user from ${provider}: ${profile.email}`); user = await ctx.repos.userRepo.create({ email: profile.email, name: profile.name, avatarUrl: profile.avatarUrl, primaryProvider: provider, oauthProviders: [ { provider, providerId: profile.id, linkedAt: new Date(), }, ], roles: ["user"], createdAt: new Date(), updatedAt: new Date(), }); console.log(`New user created with ${provider} as primary provider`); } // Generate JWT token const token = await ctx.plugins.jwtAuth.createToken( { userId: user._id, email: user.email, }, user.roles ); console.log( `JWT token generated. User can now login with ${user.oauthProviders.length} provider(s):` ); user.oauthProviders.forEach((p) => { console.log(` - ${p.provider} (linked ${p.linkedAt})`); }); return { user: { _id: user._id, email: user.email, name: user.name, avatarUrl: user.avatarUrl, primaryProvider: user.primaryProvider, linkedProviders: user.oauthProviders.map((p) => ({ provider: p.provider, linkedAt: p.linkedAt, })), roles: user.roles, }, token, redirectUrl: "/dashboard", }; }, onAuthError: async ({ error, provider }) => { console.error(`OAuth error for ${provider}:`, error); return { redirectUrl: `/login?error=${error.code}`, }; }, }), ], cors: { origin: process.env.CORS_ORIGIN || "http://localhost:3000", credentials: true, }, }); await app.start(); console.log("\n==========================================="); console.log("Multi-Provider OAuth Example Server"); console.log("==========================================="); console.log(`Server: http://localhost:3334`); console.log(`\nTry this flow:`); console.log(` 1. Login with GitHub: http://localhost:3334/oauth/github/initiate`); console.log(` 2. Then login with Google (same email): http://localhost:3334/oauth/google/initiate`); console.log(` 3. Both providers will be linked to the same user account`); console.log("===========================================\n"); } start().catch((error) => { console.error("Failed to start application:", error); process.exit(1); }); /** * Example Handler: Get User Profile with Linked Providers */ interface UserProfileResponse { user: { _id: string; email: string; name?: string; avatarUrl?: string; primaryProvider: string; linkedProviders: Array<{ provider: string; linkedAt: Date; }>; }; } // GET /profile - Get current user's profile with linked providers const GetProfileHandler: GetHandler = async ({ ctx, req, }) => { const userId = req.user._id; const user = await ctx.repos.userRepo.getById(userId); if (!user) { throw new Error("User not found"); } return { data: { user: { _id: user._id!, email: user.email, name: user.name, avatarUrl: user.avatarUrl, primaryProvider: user.primaryProvider, linkedProviders: user.oauthProviders.map((p) => ({ provider: p.provider, linkedAt: p.linkedAt, })), }, }, }; }; /** * Example Handler: Unlink OAuth Provider */ interface UnlinkProviderRequest { provider: "github" | "google"; } // DELETE /profile/providers/:provider - Unlink an OAuth provider const DeleteProviderHandler: Handler< AppContext, UnlinkProviderRequest, { success: boolean }, { provider: string } > = async ({ ctx, req }) => { const userId = req.user._id; const provider = req.params.provider as "github" | "google"; const user = await ctx.repos.userRepo.getById(userId); if (!user) { throw new Error("User not found"); } // Prevent unlinking last provider if (user.oauthProviders.length === 1) { throw new Error("Cannot unlink last authentication provider"); } // Remove provider from list const updatedProviders = user.oauthProviders.filter( (p) => p.provider !== provider ); // If unlinking primary provider, set a new primary const newPrimaryProvider = user.primaryProvider === provider ? updatedProviders[0].provider : user.primaryProvider; await ctx.repos.userRepo.updateOne(userId, { oauthProviders: updatedProviders, primaryProvider: newPrimaryProvider, updatedAt: new Date(), }); // Also delete OAuth connection if tokens were stored await ctx.plugins.oauth.deleteConnection(userId, provider); console.log(`Unlinked ${provider} from user ${user.email}`); return { data: { success: true, }, }; }; /** * Usage Notes: * * Multiple Provider Linking Strategies: * * 1. Email-based linking (implemented above): * - Same email = same person * - Automatically links providers with matching email * - Works well for most use cases * * 2. Manual linking (alternative): * - User must be logged in to link additional providers * - Requires authenticated session before OAuth * - More control but requires extra UI flow * * 3. Primary provider concept: * - First provider used to create account is "primary" * - Primary provider's profile data takes precedence * - Useful for resolving conflicts in profile data * * Conflict Resolution: * * - If email exists but with different provider ID: * Links new provider to existing user * * - If provider ID exists but with different email: * Updates user's email (assumes provider is authoritative) * * - If both email and provider ID exist separately: * Merge accounts (advanced - requires user confirmation) * * Security Considerations: * * - Always verify email before linking * - Consider requiring email verification * - Log all provider linking events * - Allow users to unlink providers (but keep at least one) * - Notify users when new providers are linked */