import { Router, type Router as RouterType } from 'express'; import { prisma } from '@agent-fox/shared'; import { generateTokenPair } from '../lib/jwt.js'; import { buildAuthUrl, exchangeCodeForToken, fetchProviderUser, validateState } from '../lib/oauth-providers.js'; const router: RouterType = Router(); const VALID_PROVIDERS = ['google', 'github', 'apple']; const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:5173'; // GET /auth/oauth/:provider — redirect to provider's authorization page router.get('/:provider', (req, res) => { const { provider } = req.params; if (!VALID_PROVIDERS.includes(provider)) { res.status(400).json({ success: false, error: { code: 'INVALID_PROVIDER', message: `Unknown provider: ${provider}` } }); return; } try { const url = buildAuthUrl(provider); res.redirect(url); } catch (err) { res.status(500).json({ success: false, error: { code: 'OAUTH_ERROR', message: err instanceof Error ? err.message : 'Failed to build auth URL' } }); } }); // GET /auth/oauth/:provider/callback — handle provider callback router.get('/:provider/callback', async (req, res) => { const { provider } = req.params; const { code, state, error: oauthError } = req.query as Record; if (oauthError) { res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent(oauthError)}`); return; } if (!code || !state) { res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent('Missing code or state')}`); return; } if (!validateState(state, provider)) { res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent('Invalid or expired state')}`); return; } try { const token = await exchangeCodeForToken(provider, code); const providerUser = await fetchProviderUser(provider, token); if (!providerUser.email) { res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent('No email returned from provider')}`); return; } const user = await findOrCreateUser(provider, providerUser); const tokens = generateTokenPair({ userId: user.id, email: user.email }); res.redirect(`${FRONTEND_URL}/login/callback?accessToken=${tokens.accessToken}&refreshToken=${tokens.refreshToken}`); } catch (err) { console.error(`OAuth callback error (${provider}):`, err); res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent('Authentication failed')}`); } }); // Apple sends callback as POST (form_post response mode) router.post('/:provider/callback', async (req, res) => { const { provider } = req.params; const { code, state, error: oauthError } = req.body; if (oauthError) { res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent(oauthError)}`); return; } if (!code || !state) { res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent('Missing code or state')}`); return; } if (!validateState(state, provider)) { res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent('Invalid or expired state')}`); return; } try { const token = await exchangeCodeForToken(provider, code); const providerUser = await fetchProviderUser(provider, token); if (!providerUser.email) { res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent('No email returned from provider')}`); return; } const user = await findOrCreateUser(provider, providerUser); const tokens = generateTokenPair({ userId: user.id, email: user.email }); res.redirect(`${FRONTEND_URL}/login/callback?accessToken=${tokens.accessToken}&refreshToken=${tokens.refreshToken}`); } catch (err) { console.error(`OAuth POST callback error (${provider}):`, err); res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent('Authentication failed')}`); } }); async function findOrCreateUser( provider: string, providerUser: { id: string; email: string; name: string; avatarUrl: string | null }, ) { // 1. Check existing OAuthAccount const existingOAuth = await prisma.oAuthAccount.findUnique({ where: { provider_providerAccountId: { provider, providerAccountId: providerUser.id } }, include: { user: true }, }); if (existingOAuth) { if (providerUser.avatarUrl && providerUser.avatarUrl !== existingOAuth.user.avatarUrl) { await prisma.user.update({ where: { id: existingOAuth.user.id }, data: { avatarUrl: providerUser.avatarUrl }, }); } return existingOAuth.user; } // 2. Check existing user by email — link OAuth account const existingUser = await prisma.user.findUnique({ where: { email: providerUser.email } }); if (existingUser) { await prisma.oAuthAccount.create({ data: { userId: existingUser.id, provider, providerAccountId: providerUser.id }, }); if (providerUser.avatarUrl && !existingUser.avatarUrl) { await prisma.user.update({ where: { id: existingUser.id }, data: { avatarUrl: providerUser.avatarUrl }, }); } return existingUser; } // 3. Create new user + OAuth account const newUser = await prisma.user.create({ data: { email: providerUser.email, name: providerUser.name, avatarUrl: providerUser.avatarUrl, passwordHash: null, oauthAccounts: { create: { provider, providerAccountId: providerUser.id }, }, }, }); return newUser; } export default router;