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
+ }
+};