diff --git a/client/src/App.tsx b/client/src/App.tsx index 9c4ff8e..80a1201 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -11,6 +11,7 @@ import DashboardHeader from "./components/common/Header"; import RequireAuth from "./components/common/RequireAuth"; import LoginPage from "./pages/LoginPage"; import SignupPage from "./pages/SignupPage"; +import AuthCallback from "./pages/AuthCallback"; import DashboardLayout from "./components/common/DashboardLayout"; function App() { @@ -38,6 +39,7 @@ function App() { {/* Public routes */} } /> } /> + } /> {/* Protected routes */} }> diff --git a/server/.env.example b/server/.env.example index 7e35b9b..da77433 100644 --- a/server/.env.example +++ b/server/.env.example @@ -18,6 +18,17 @@ DATABASE_URL=postgres://:@localhost:5432/ # JWT Configuration JWT_SECRET=your-super-secret-jwt-key-change-this-in-production +# Frontend URL (used for redirects after OAuth); falls back to CORS_ORIGIN if not set +FRONTEND_URL=http://localhost:5173 + # CORS Configuration (frontend url) CORS_ORIGIN=http://localhost:5173 +# Google OAuth +# Create OAuth 2.0 Client ID (Web) at https://console.cloud.google.com/apis/credentials +# Authorized redirect URI must include: +# http://localhost:8080/api/auth/google/callback +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_OAUTH_TIMEOUT_MS=10000 + diff --git a/server/controllers/auth.controller.js b/server/controllers/auth.controller.js index 731af43..099385f 100644 --- a/server/controllers/auth.controller.js +++ b/server/controllers/auth.controller.js @@ -1,8 +1,9 @@ import jwt from 'jsonwebtoken'; -import { signupUser, loginUser, getPublicUser } from '../services/auth.service.js'; +import { signupUser, loginUser, getPublicUser, upsertOAuthUser } from '../services/auth.service.js'; import { asyncHandler } from '../utils/asyncHandler.js'; import { BadRequestError } from '../utils/errors.js'; import isStrongPassword from 'validator/lib/isStrongPassword.js'; +import { buildGoogleAuthUrl, exchangeGoogleCodeForProfile } from '../services/oauth.service.js'; const TOKEN_COOKIE_NAME = 'token'; @@ -51,3 +52,42 @@ export const logout = asyncHandler(async (_req, res) => { res.clearCookie(TOKEN_COOKIE_NAME, { path: '/', sameSite: 'lax' }); return res.status(204).send(); }); + +const getFrontendBase = () => process.env.FRONTEND_URL || process.env.CORS_ORIGIN || 'http://localhost:5173'; + +const oauthCookieOpts = () => { + const isProd = process.env.NODE_ENV === 'production'; + return { httpOnly: true, secure: isProd, sameSite: isProd ? 'none' : 'lax', path: '/', maxAge: 10 * 60 * 1000 }; +} + +export const googleAuth = asyncHandler(async (req, res) => { + const redirectUri = `${req.protocol}://${req.get('host')}/api/auth/google/callback`; + const { url, state } = buildGoogleAuthUrl({ redirectUri }); + res.cookie('g_state', state, oauthCookieOpts()); + return res.redirect(url); +}); + +export const googleCallback = asyncHandler(async (req, res) => { + const { code, state } = req.query; + const savedState = req.cookies?.g_state; + if (!code || !state || !savedState || state !== savedState) { + throw new BadRequestError('Invalid OAuth state'); + } + res.clearCookie('g_state', { path: '/' }); + + const redirectUri = `${req.protocol}://${req.get('host')}/api/auth/google/callback`; + const profile = await exchangeGoogleCodeForProfile({ code, redirectUri }); + const email = profile?.email; + if (!email) throw new BadRequestError('Email not available from Google'); + + const publicUser = await upsertOAuthUser({ + email, + given_name: profile?.given_name, + family_name: profile?.family_name, + name: profile?.name, + }); + + setAuthCookie(res, { user_id: publicUser.user_id, email: publicUser.email }); + const frontend = getFrontendBase(); + return res.redirect(302, `${frontend.replace(/\/$/, '')}/auth/callback`); +}); diff --git a/server/package-lock.json b/server/package-lock.json index 2525759..828b9d6 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -19,6 +19,7 @@ "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", "pg": "^8.11.3", + "undici": "^7.15.0", "validator": "^13.11.0" }, "devDependencies": { @@ -2474,6 +2475,15 @@ "dev": true, "license": "MIT" }, + "node_modules/undici": { + "version": "7.15.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.15.0.tgz", + "integrity": "sha512-7oZJCPvvMvTd0OlqWsIxTuItTpJBpU1tcbVl24FMn3xt3+VSunwUasmfPJRE57oNO1KsZ4PgA1xTdAX4hq8NyQ==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/server/package.json b/server/package.json index c5b9cdc..9cb92c7 100644 --- a/server/package.json +++ b/server/package.json @@ -22,15 +22,16 @@ "license": "ISC", "dependencies": { "@prisma/client": "^6.14.0", + "bcrypt": "^5.1.1", + "cookie-parser": "^1.4.6", "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", "helmet": "^7.1.0", + "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", "pg": "^8.11.3", - "bcrypt": "^5.1.1", - "jsonwebtoken": "^9.0.2", - "cookie-parser": "^1.4.6", + "undici": "^7.15.0", "validator": "^13.11.0" }, "devDependencies": { diff --git a/server/routes/auth.route.js b/server/routes/auth.route.js index df4032c..9fcb0d3 100644 --- a/server/routes/auth.route.js +++ b/server/routes/auth.route.js @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { signup, login, logout, me } from '../controllers/auth.controller.js'; +import { signup, login, logout, me, googleAuth, googleCallback } from '../controllers/auth.controller.js'; import { requireAuth } from '../middleware/auth.middleware.js'; import { validateSignup, validateLogin } from '../middleware/validate.js'; @@ -9,5 +9,7 @@ router.post('/signup', validateSignup, signup); router.post('/login', validateLogin, login); router.post('/logout', logout); router.get('/me', requireAuth, me); +router.get('/google', googleAuth); +router.get('/google/callback', googleCallback); export default router; diff --git a/server/server.js b/server/server.js index 2f2d534..075361a 100644 --- a/server/server.js +++ b/server/server.js @@ -1,3 +1,4 @@ +import dns from 'node:dns'; import express from 'express'; import cors from 'cors'; import dotenv from 'dotenv'; @@ -8,9 +9,19 @@ import { PrismaClient } from '@prisma/client'; import incomeRoutes from './routes/income.route.js'; import authRoutes from './routes/auth.route.js'; import categoryRoutes from './routes/category.route.js'; +import { configureNetwork } from './utils/network.js'; dotenv.config(); +// Prefer IPv4 to mitigate environments where IPv6 routes time out +try { + dns.setDefaultResultOrder('ipv4first'); +} catch {} + +try { + await configureNetwork(); +} catch {} + const app = express(); const PORT = process.env.PORT || 8080; @@ -32,7 +43,6 @@ app.use('/api/auth', authRoutes); app.use('/api/incomes', requireAuth, incomeRoutes); app.use('/api/categories', categoryRoutes); - // Initialize a single Prisma client instance const prisma = new PrismaClient(); diff --git a/server/services/auth.service.js b/server/services/auth.service.js index e5d40eb..689baed 100644 --- a/server/services/auth.service.js +++ b/server/services/auth.service.js @@ -52,3 +52,37 @@ export const getPublicUser = async (userId) => { if (!user) throw new NotFoundError('User not found'); return user; }; + +export const upsertOAuthUser = async ({ email, given_name, family_name, name }) => { + const existing = await prisma.user.findUnique({ where: { email } }); + if (existing) { + return { + user_id: existing.user_id, + email: existing.email, + username: existing.username, + firstname: existing.firstname, + lastname: existing.lastname, + created_at: existing.created_at, + }; + } + + const randomPass = `oauth-${Math.random().toString(36).slice(2)}-${Date.now()}`; + const hashed_password = await bcrypt.hash(randomPass, 10); + const preferred = (given_name && String(given_name).trim()) + || (name && String(name).trim()) + || (email && String(email).split('@')[0]) + || 'user'; + const username = preferred.slice(0, 50); + + const user = await prisma.user.create({ + data: { + email, + hashed_password, + username, + firstname: given_name || name || '', + lastname: family_name || '', + }, + select: publicUserSelect, + }); + return user; +}; diff --git a/server/services/oauth.service.js b/server/services/oauth.service.js new file mode 100644 index 0000000..41b6eec --- /dev/null +++ b/server/services/oauth.service.js @@ -0,0 +1,72 @@ +import crypto from 'crypto'; +import { BadRequestError } from '../utils/errors.js'; +import { fetchWithTimeout } from '../utils/http.js'; + +const GOOGLE_AUTH_BASE = 'https://accounts.google.com/o/oauth2/v2/auth'; +const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; +const GOOGLE_USERINFO_URL = 'https://www.googleapis.com/oauth2/v3/userinfo'; + +export const buildGoogleAuthUrl = ({ redirectUri }) => { + const clientId = process.env.GOOGLE_CLIENT_ID; + const clientSecret = process.env.GOOGLE_CLIENT_SECRET; // only to validate presence + if (!clientId || !clientSecret) throw new BadRequestError('Google OAuth not configured'); + + const state = crypto.randomBytes(16).toString('hex'); + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: 'openid email profile', + state, + access_type: 'offline', + prompt: 'consent', + }); + return { url: `${GOOGLE_AUTH_BASE}?${params.toString()}`, state }; +}; + +export const exchangeGoogleCodeForProfile = async ({ code, redirectUri }) => { + const clientId = process.env.GOOGLE_CLIENT_ID; + const clientSecret = process.env.GOOGLE_CLIENT_SECRET; + if (!clientId || !clientSecret) throw new BadRequestError('Google OAuth not configured'); + + const body = new URLSearchParams({ + code: String(code), + client_id: clientId, + client_secret: clientSecret, + redirect_uri: redirectUri, + grant_type: 'authorization_code', + }).toString(); + + let tokenResp; + try { + tokenResp = await fetchWithTimeout(GOOGLE_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + } catch (e) { + throw new BadRequestError(`Token exchange network error: ${(e && e.message) || e}`); + } + if (!tokenResp.ok) { + const t = await tokenResp.text().catch(() => ''); + throw new BadRequestError(`Token exchange failed: ${t || tokenResp.status}`); + } + const tokenJson = await tokenResp.json(); + const accessToken = tokenJson.access_token; + if (!accessToken) throw new BadRequestError('Missing access token'); + + let userResp; + try { + userResp = await fetchWithTimeout(GOOGLE_USERINFO_URL, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + } catch (e) { + throw new BadRequestError(`Userinfo network error: ${(e && e.message) || e}`); + } + if (!userResp.ok) { + const t = await userResp.text().catch(() => ''); + throw new BadRequestError(`Failed to fetch userinfo: ${t || userResp.status}`); + } + const profile = await userResp.json(); + return profile; +}; diff --git a/server/utils/http.js b/server/utils/http.js new file mode 100644 index 0000000..40950ac --- /dev/null +++ b/server/utils/http.js @@ -0,0 +1,11 @@ +// Generic fetch helper with timeout and clear error messages +export const fetchWithTimeout = async (input, init = {}) => { + const timeoutMs = Number(process.env.GOOGLE_OAUTH_TIMEOUT_MS || 10000); + const controller = new AbortController(); + const id = setTimeout(() => controller.abort(new Error('Request timed out')), timeoutMs); + try { + return await fetch(input, { ...init, signal: controller.signal }); + } finally { + clearTimeout(id); + } +}; diff --git a/server/utils/network.js b/server/utils/network.js new file mode 100644 index 0000000..65c4909 --- /dev/null +++ b/server/utils/network.js @@ -0,0 +1,15 @@ +// Configure global fetch (Undici) to prefer IPv4 and honor HTTPS_PROXY/HTTP_PROXY if available. +// Works even if 'undici' package isn't installed: it will no-op gracefully. +export const configureNetwork = async () => { + try { + const { setGlobalDispatcher, Agent, ProxyAgent } = await import('undici'); + const proxyUrl = process.env.HTTPS_PROXY || process.env.HTTP_PROXY; + if (proxyUrl && ProxyAgent) { + setGlobalDispatcher(new ProxyAgent(proxyUrl)); + } else { + setGlobalDispatcher(new Agent({ connect: { family: 4 } })); + } + } catch { + // undici not available; skip configuration + } +};