feat: add OAuth routes for Google, GitHub, and Apple login
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
152
packages/server/src/routes/oauth.ts
Normal file
152
packages/server/src/routes/oauth.ts
Normal file
@@ -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<string, string>;
|
||||
|
||||
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;
|
||||
Reference in New Issue
Block a user