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:
2026-04-03 13:16:06 +08:00
parent 0a48152e0f
commit 9316795e4f
3 changed files with 161 additions and 0 deletions

View File

@@ -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);

View 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;