Skip to content
Closed
2 changes: 2 additions & 0 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -38,6 +39,7 @@ function App() {
{/* Public routes */}
<Route path="/login" element={<LoginPage />} />
<Route path="/signup" element={<SignupPage />} />
<Route path="/auth/callback" element={<AuthCallback />} />

{/* Protected routes */}
<Route element={<RequireAuth />}>
Expand Down
11 changes: 11 additions & 0 deletions server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@ DATABASE_URL=postgres://<username>:<password>@localhost:5432/<database>
# 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

42 changes: 41 additions & 1 deletion server/controllers/auth.controller.js
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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`);
});
10 changes: 10 additions & 0 deletions server/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
4 changes: 3 additions & 1 deletion server/routes/auth.route.js
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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;
12 changes: 11 additions & 1 deletion server/server.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import dns from 'node:dns';
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
Expand All @@ -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;

Expand All @@ -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();

Expand Down
34 changes: 34 additions & 0 deletions server/services/auth.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
72 changes: 72 additions & 0 deletions server/services/oauth.service.js
Original file line number Diff line number Diff line change
@@ -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;
};
11 changes: 11 additions & 0 deletions server/utils/http.js
Original file line number Diff line number Diff line change
@@ -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);
}
};
15 changes: 15 additions & 0 deletions server/utils/network.js
Original file line number Diff line number Diff line change
@@ -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
}
};
Loading