diff --git a/.env.example b/.env.example index adda320..ef7bfe1 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,9 @@ SERVER_PORT=3000 MCP_PORT=3001 WEB_PORT=5173 REDIS_URL=redis://localhost:6379 +APPLE_CLIENT_ID= +APPLE_TEAM_ID= +APPLE_KEY_ID= +APPLE_PRIVATE_KEY= +OAUTH_CALLBACK_BASE_URL=http://localhost:3000 +FRONTEND_URL=http://localhost:5173 diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index d94db1a..31616d4 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -1,6 +1,7 @@ import express from 'express'; import cors from 'cors'; import authRouter from './routes/auth.js'; +import oauthRouter from './routes/oauth.js'; import projectRouter from './routes/projects.js'; import importRouter from './routes/import.js'; import moduleRouter from './routes/modules.js'; @@ -9,12 +10,14 @@ import endpointRouter from './routes/endpoints.js'; const app = express(); app.use(cors()); app.use(express.json({ limit: '10mb' })); +app.use(express.urlencoded({ extended: true })); app.get('/api/health', (_req, res) => { res.json({ success: true, data: { status: 'ok' } }); }); app.use('/api/auth', authRouter); +app.use('/api/auth/oauth', oauthRouter); app.use('/api/projects', projectRouter); app.use('/api/projects', importRouter); app.use('/api/projects', moduleRouter); diff --git a/packages/server/src/routes/oauth.ts b/packages/server/src/routes/oauth.ts new file mode 100644 index 0000000..0d8326b --- /dev/null +++ b/packages/server/src/routes/oauth.ts @@ -0,0 +1,152 @@ +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;