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:
@@ -10,3 +10,9 @@ SERVER_PORT=3000
|
|||||||
MCP_PORT=3001
|
MCP_PORT=3001
|
||||||
WEB_PORT=5173
|
WEB_PORT=5173
|
||||||
REDIS_URL=redis://localhost:6379
|
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
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import authRouter from './routes/auth.js';
|
import authRouter from './routes/auth.js';
|
||||||
|
import oauthRouter from './routes/oauth.js';
|
||||||
import projectRouter from './routes/projects.js';
|
import projectRouter from './routes/projects.js';
|
||||||
import importRouter from './routes/import.js';
|
import importRouter from './routes/import.js';
|
||||||
import moduleRouter from './routes/modules.js';
|
import moduleRouter from './routes/modules.js';
|
||||||
@@ -9,12 +10,14 @@ import endpointRouter from './routes/endpoints.js';
|
|||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json({ limit: '10mb' }));
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
app.get('/api/health', (_req, res) => {
|
app.get('/api/health', (_req, res) => {
|
||||||
res.json({ success: true, data: { status: 'ok' } });
|
res.json({ success: true, data: { status: 'ok' } });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use('/api/auth', authRouter);
|
app.use('/api/auth', authRouter);
|
||||||
|
app.use('/api/auth/oauth', oauthRouter);
|
||||||
app.use('/api/projects', projectRouter);
|
app.use('/api/projects', projectRouter);
|
||||||
app.use('/api/projects', importRouter);
|
app.use('/api/projects', importRouter);
|
||||||
app.use('/api/projects', moduleRouter);
|
app.use('/api/projects', moduleRouter);
|
||||||
|
|||||||
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