-
-
- Or sign up with
+
+
+
+ Continue with
-
-
@@ -312,4 +369,4 @@ const SignUpPage = () => {
)
}
-export default SignUpPage
\ No newline at end of file
+export default SignUpPage
diff --git a/client/src/routes.jsx b/client/src/routes.jsx
index 702f48a..0f69431 100644
--- a/client/src/routes.jsx
+++ b/client/src/routes.jsx
@@ -4,6 +4,7 @@ import Layout from './components/Layout'
import LandingPage from './pages/LandingPage'
import LoginPage from './pages/LoginPage'
import SignUpPage from './pages/SignUpPage'
+import AuthCallbackPage from './pages/AuthCallbackPage'
import QuestionsFeed from './pages/QuestionsFeed'
import AskQuestionPage from './pages/AskQuestionPage'
import QuestionDetailPage from './pages/QuestionDetailPage'
@@ -13,6 +14,7 @@ import TagManagementPage from './pages/TagManagementPage'
import SettingsPage from './pages/SettingsPage'
import ProtectedRoute from './components/ProtectedRoute'
import AdminRoute from './components/AdminRoute'
+import ProfileRedirect from './components/ProfileRedirect'
const routes = [
{
@@ -35,6 +37,10 @@ const routes = [
path: 'signup',
element:
},
+ {
+ path: 'auth/callback',
+ element:
+ },
{
path: 'questions',
element:
@@ -47,6 +53,10 @@ const routes = [
path: 'ask',
element:
},
+ {
+ path: 'profile',
+ element:
+ },
{
path: 'profile/:username',
element:
@@ -67,4 +77,4 @@ const routes = [
}
]
-export default routes
\ No newline at end of file
+export default routes
diff --git a/client/src/utils/api.js b/client/src/utils/api.js
index 4d43f6e..0ae6bc6 100644
--- a/client/src/utils/api.js
+++ b/client/src/utils/api.js
@@ -1,7 +1,9 @@
import axios from 'axios'
+export const API_BASE_URL = import.meta.env.VITE_API_URL || '/api'
+
const api = axios.create({
- baseURL: '/api',
+ baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
@@ -11,20 +13,13 @@ const api = axios.create({
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token')
- console.log('🔍 API Debug - Token exists:', !!token)
- console.log('🔍 API Debug - Request URL:', config.url)
- console.log('🔍 API Debug - Request method:', config.method)
-
+
if (token) {
config.headers.Authorization = `Bearer ${token}`
- console.log('🔍 API Debug - Authorization header set:', `Bearer ${token.substring(0, 20)}...`)
- } else {
- console.log('⚠️ API Debug - No token found in localStorage')
}
return config
},
(error) => {
- console.log('❌ API Debug - Request interceptor error:', error)
return Promise.reject(error)
}
)
@@ -33,17 +28,15 @@ api.interceptors.request.use(
api.interceptors.response.use(
(response) => response,
(error) => {
- console.log('🔍 API Debug - Response error:', error.response?.status, error.response?.data)
-
if (error.response?.status === 401) {
const errorMessage = error.response?.data?.message || 'Authentication failed'
- console.log('🔍 API Debug - 401 error message:', errorMessage)
-
+
// Only redirect if it's a token-related error, not other auth issues
if (errorMessage.includes('token') || errorMessage.includes('expired') || errorMessage.includes('Invalid')) {
- console.log('🔍 API Debug - Token error detected, clearing localStorage and redirecting')
localStorage.removeItem('token')
-
+ localStorage.removeItem('tokenExpiry')
+ localStorage.removeItem('cachedUser')
+
// Use a flag to prevent multiple redirects
if (!window.isRedirecting) {
window.isRedirecting = true
@@ -58,4 +51,4 @@ api.interceptors.response.use(
}
)
-export default api
\ No newline at end of file
+export default api
diff --git a/server/env.example b/server/env.example
index 39385aa..1e14426 100644
--- a/server/env.example
+++ b/server/env.example
@@ -1,6 +1,7 @@
# Server Configuration
PORT=5000
NODE_ENV=development
+SERVER_URL=http://localhost:5000
# Database
MONGODB_URI=mongodb://localhost:27017/stackit
@@ -12,9 +13,21 @@ JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
# Client URL
CLIENT_URL=http://localhost:3000
+# Google OAuth
+# Authorized redirect URI in Google Cloud must match GOOGLE_CALLBACK_URL.
+GOOGLE_CLIENT_ID=your-google-oauth-client-id
+GOOGLE_CLIENT_SECRET=your-google-oauth-client-secret
+GOOGLE_CALLBACK_URL=http://localhost:5000/api/auth/google/callback
+
+# Twitter/X OAuth
+# Callback URI in the X Developer Portal must match TWITTER_CALLBACK_URL.
+TWITTER_CLIENT_ID=your-twitter-oauth-client-id
+TWITTER_CLIENT_SECRET=your-twitter-oauth-client-secret
+TWITTER_CALLBACK_URL=http://localhost:5000/api/auth/twitter/callback
+
# Rate Limiting (optional - overrides NODE_ENV behavior)
# RATE_LIMIT_ENABLED=false # Set to false to disable rate limiting completely
# RATE_LIMIT_MAX=100 # Override the default max requests per window
# Optional: MongoDB Atlas (for production)
-# MONGODB_URI=mongodb+srv://username:password@cluster.mongodb.net/stackit?retryWrites=true&w=majority
\ No newline at end of file
+# MONGODB_URI=mongodb+srv://username:password@cluster.mongodb.net/stackit?retryWrites=true&w=majority
diff --git a/server/index.js b/server/index.js
index 3d89760..3a831a7 100644
--- a/server/index.js
+++ b/server/index.js
@@ -12,7 +12,7 @@ require('dotenv').config({ path: path.resolve(__dirname, '.env') })
// Fallback: manually set JWT_SECRET if .env loading failed
if (!process.env.JWT_SECRET) {
- console.log('🔧 JWT_SECRET not loaded from .env, setting manually')
+ console.warn('JWT_SECRET not loaded from .env, using development fallback')
process.env.JWT_SECRET = 'stackit-super-secret-jwt-key-2024-change-in-production'
process.env.PORT = process.env.PORT || '5000'
process.env.NODE_ENV = process.env.NODE_ENV || 'development'
@@ -20,9 +20,6 @@ if (!process.env.JWT_SECRET) {
process.env.CLIENT_URL = process.env.CLIENT_URL || 'http://localhost:3000'
}
-console.log('✅ JWT_SECRET loaded:', !!process.env.JWT_SECRET)
-console.log('✅ JWT_SECRET length:', process.env.JWT_SECRET ? process.env.JWT_SECRET.length : 0)
-
const connectDB = require('./config/db')
const authRoutes = require('./routes/auth')
const questionRoutes = require('./routes/questions')
@@ -35,6 +32,8 @@ const notificationRoutes = require('./routes/notifications')
const { authenticateSocket } = require('./middleware/auth')
const app = express()
+app.set('trust proxy', 1)
+
const server = createServer(app)
// Socket.io setup
@@ -51,7 +50,7 @@ const shouldEnableRateLimit = () => {
if (process.env.RATE_LIMIT_ENABLED !== undefined) {
return process.env.RATE_LIMIT_ENABLED === 'true'
}
-
+
// Production: always enabled
// Development: disabled by default
return process.env.NODE_ENV === 'production'
@@ -62,7 +61,7 @@ const getRateLimitMax = () => {
if (process.env.RATE_LIMIT_MAX) {
return parseInt(process.env.RATE_LIMIT_MAX)
}
-
+
// Production: 100 requests per 15 minutes
// Development: 1,000,000 requests (effectively unlimited)
return process.env.NODE_ENV === 'production' ? 100 : 1000000
@@ -76,11 +75,8 @@ const limiter = rateLimit({
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
})
-// Log rate limiting configuration
if (shouldEnableRateLimit()) {
- console.log(`🔒 Rate limiting: ${getRateLimitMax()} requests per 15 minutes`)
-} else {
- console.log('🔓 Rate limiting: DISABLED')
+ console.log(`Rate limiting enabled: ${getRateLimitMax()} requests per 15 minutes`)
}
// Middleware
@@ -89,7 +85,9 @@ app.use(cors({
origin: process.env.CLIENT_URL || "http://localhost:3000",
credentials: true
}))
-app.use(morgan('combined'))
+if (process.env.NODE_ENV === 'production' || process.env.REQUEST_LOGGING_ENABLED === 'true') {
+ app.use(morgan('combined'))
+}
// Apply rate limiting conditionally
if (shouldEnableRateLimit()) {
@@ -99,12 +97,6 @@ if (shouldEnableRateLimit()) {
app.use(express.json({ limit: '10mb' }))
app.use(express.urlencoded({ extended: true }))
-// Debug middleware to log all requests
-app.use((req, res, next) => {
- console.log(`🔍 ${req.method} ${req.originalUrl} - ${new Date().toISOString()}`);
- next();
-});
-
// Routes
app.use('/api/auth', authRoutes)
app.use('/api/questions', questionRoutes)
@@ -124,8 +116,6 @@ app.get('/api/health', (req, res) => {
io.use(authenticateSocket)
io.on('connection', (socket) => {
- console.log('User connected:', socket.userId)
-
// Join user to their personal room
socket.join(`user:${socket.userId}`)
@@ -157,18 +147,13 @@ io.on('connection', (socket) => {
socket.on('leave-question', (questionId) => {
socket.leave(`question:${questionId}`)
})
-
- socket.on('disconnect', () => {
- console.log('User disconnected:', socket.userId)
- })
})
// Global error handling middleware
app.use((err, req, res, next) => {
- console.error('❌ Global error handler caught:', err)
- console.error('❌ Error stack:', err.stack)
- res.status(500).json({
- message: 'Internal Server Error',
+ console.error('Unhandled server error:', err)
+ res.status(500).json({
+ message: 'Internal Server Error',
error: process.env.NODE_ENV === 'development' ? err.message : 'Something went wrong'
})
})
@@ -183,21 +168,9 @@ const PORT = process.env.PORT || 5000
const startServer = async () => {
try {
await connectDB()
-
- // Debug environment variables
- console.log('🔍 Server Debug - JWT_SECRET exists:', !!process.env.JWT_SECRET)
- console.log('🔍 Server Debug - JWT_SECRET length:', process.env.JWT_SECRET ? process.env.JWT_SECRET.length : 0)
- console.log('🔍 Server Debug - NODE_ENV:', process.env.NODE_ENV)
- console.log('🔍 Server Debug - PORT:', process.env.PORT || 5000)
-
+
server.listen(PORT, () => {
- console.log(`✅ Server running at http://localhost:${PORT}`)
- console.log('✅ Ensure Vite proxy forwards /api calls correctly to the server.')
- console.log('✅ Available routes:')
- console.log(' - POST /api/answers/:questionId (create answer)')
- console.log(' - GET /api/answers/question/:questionId (get answers)')
- console.log(' - PUT /api/answers/:answerId (update answer)')
- console.log(' - DELETE /api/answers/:answerId (delete answer)')
+ console.log(`Server running at http://localhost:${PORT}`)
})
} catch (error) {
console.error('Failed to start server:', error)
@@ -207,4 +180,4 @@ const startServer = async () => {
startServer()
-module.exports = { app, io }
\ No newline at end of file
+module.exports = { app, io }
diff --git a/server/middleware/auth.js b/server/middleware/auth.js
index f5ce681..0375483 100644
--- a/server/middleware/auth.js
+++ b/server/middleware/auth.js
@@ -4,46 +4,33 @@ const User = require('../models/User')
const authenticateToken = async (req, res, next) => {
try {
const authHeader = req.headers['authorization']
- console.log('🔍 Auth Debug - Header:', authHeader)
-
const token = authHeader && authHeader.split(' ')[1]
- console.log('🔍 Auth Debug - Token:', token ? token.substring(0, 20) + '...' : 'No token')
if (!token) {
- console.log('❌ No token provided')
return res.status(401).json({ message: 'Access token required' })
}
- console.log('🔍 Auth Debug - JWT_SECRET exists:', !!process.env.JWT_SECRET)
- console.log('🔍 Auth Debug - JWT_SECRET length:', process.env.JWT_SECRET ? process.env.JWT_SECRET.length : 0)
-
if (!process.env.JWT_SECRET) {
- console.log('❌ JWT_SECRET is not configured')
+ console.error('JWT_SECRET is not configured')
return res.status(500).json({ message: 'Server configuration error' })
}
-
+
const decoded = jwt.verify(token, process.env.JWT_SECRET)
- console.log('🔍 Auth Debug - Decoded token:', { userId: decoded.userId, exp: decoded.exp })
-
+
// Check if token is expired
if (decoded.exp && Date.now() >= decoded.exp * 1000) {
- console.log('❌ Token expired')
return res.status(401).json({ message: 'Token expired' })
}
-
+
const user = await User.findById(decoded.userId).select('-password')
-
+
if (!user) {
- console.log('❌ User not found for token')
return res.status(401).json({ message: 'Invalid token' })
}
- console.log('✅ Auth successful for user:', user.username)
req.user = user
next()
} catch (error) {
- console.log('❌ Auth error:', error.name, error.message)
-
if (error.name === 'JsonWebTokenError') {
return res.status(401).json({ message: 'Invalid token' })
}
@@ -53,8 +40,8 @@ const authenticateToken = async (req, res, next) => {
if (error.name === 'NotBeforeError') {
return res.status(401).json({ message: 'Token not active' })
}
-
- console.log('❌ Unexpected auth error:', error)
+
+ console.error('Unexpected auth error:', error)
res.status(500).json({ message: 'Server error' })
}
}
@@ -93,4 +80,4 @@ module.exports = {
authenticateToken,
authenticateSocket,
requireAdmin
-}
\ No newline at end of file
+}
diff --git a/server/models/User.js b/server/models/User.js
index 9ecce02..eacd351 100644
--- a/server/models/User.js
+++ b/server/models/User.js
@@ -23,6 +23,16 @@ const userSchema = new mongoose.Schema({
required: true,
minlength: 6
},
+ oauthProviders: {
+ google: {
+ id: String,
+ email: String
+ },
+ twitter: {
+ id: String,
+ username: String
+ }
+ },
bio: {
type: String,
maxlength: 500,
@@ -81,6 +91,8 @@ const userSchema = new mongoose.Schema({
userSchema.index({ username: 1 })
userSchema.index({ email: 1 })
userSchema.index({ reputation: -1 })
+userSchema.index({ 'oauthProviders.google.id': 1 }, { sparse: true })
+userSchema.index({ 'oauthProviders.twitter.id': 1 }, { sparse: true })
// Virtual for question count
userSchema.virtual('questionCount', {
@@ -101,7 +113,7 @@ userSchema.virtual('answerCount', {
// Hash password before saving
userSchema.pre('save', async function(next) {
if (!this.isModified('password')) return next()
-
+
try {
const salt = await bcrypt.genSalt(12)
this.password = await bcrypt.hash(this.password, salt)
@@ -146,4 +158,4 @@ userSchema.methods.toJSON = function() {
return user
}
-module.exports = mongoose.model('User', userSchema)
\ No newline at end of file
+module.exports = mongoose.model('User', userSchema)
diff --git a/server/render.yaml b/server/render.yaml
index 20731a9..2556fe9 100644
--- a/server/render.yaml
+++ b/server/render.yaml
@@ -14,6 +14,20 @@ services:
sync: false
- key: JWT_SECRET
sync: false
+ - key: SERVER_URL
+ sync: false
- key: CLIENT_URL
sync: false
- healthCheckPath: /api/health
\ No newline at end of file
+ - key: GOOGLE_CLIENT_ID
+ sync: false
+ - key: GOOGLE_CLIENT_SECRET
+ sync: false
+ - key: GOOGLE_CALLBACK_URL
+ sync: false
+ - key: TWITTER_CLIENT_ID
+ sync: false
+ - key: TWITTER_CLIENT_SECRET
+ sync: false
+ - key: TWITTER_CALLBACK_URL
+ sync: false
+ healthCheckPath: /api/health
diff --git a/server/routes/auth.js b/server/routes/auth.js
index d94ce7f..b0db3a1 100644
--- a/server/routes/auth.js
+++ b/server/routes/auth.js
@@ -1,5 +1,7 @@
const express = require('express')
const jwt = require('jsonwebtoken')
+const crypto = require('crypto')
+const axios = require('axios')
const { body, validationResult } = require('express-validator')
const User = require('../models/User')
const { authenticateToken } = require('../middleware/auth')
@@ -13,6 +15,319 @@ const generateToken = (userId) => {
})
}
+const getServerBaseUrl = (req) => {
+ return (process.env.SERVER_URL || `${req.protocol}://${req.get('host')}`).replace(/\/$/, '')
+}
+
+const getClientBaseUrl = () => {
+ return (process.env.CLIENT_URL || 'http://localhost:3000').replace(/\/$/, '')
+}
+
+const getRedirectUri = (provider, req) => {
+ const envKey = `${provider.toUpperCase()}_CALLBACK_URL`
+ return process.env[envKey] || `${getServerBaseUrl(req)}/api/auth/${provider}/callback`
+}
+
+const createOAuthState = (provider, extra = {}) => {
+ return jwt.sign({
+ provider,
+ nonce: crypto.randomBytes(16).toString('hex'),
+ ...extra
+ }, process.env.JWT_SECRET || 'your-secret-key', {
+ expiresIn: '10m'
+ })
+}
+
+const verifyOAuthState = (state, provider) => {
+ const decoded = jwt.verify(state, process.env.JWT_SECRET || 'your-secret-key')
+ if (decoded.provider !== provider) {
+ throw new Error('OAuth state provider mismatch')
+ }
+ return decoded
+}
+
+const base64Url = (buffer) => {
+ return buffer
+ .toString('base64')
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/=+$/, '')
+}
+
+const createPkcePair = () => {
+ const verifier = base64Url(crypto.randomBytes(32))
+ const challenge = base64Url(crypto.createHash('sha256').update(verifier).digest())
+ return { verifier, challenge }
+}
+
+const getProviderConfig = (provider, req) => {
+ const configs = {
+ google: {
+ clientId: process.env.GOOGLE_CLIENT_ID,
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET,
+ redirectUri: getRedirectUri('google', req),
+ authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
+ tokenUrl: 'https://oauth2.googleapis.com/token',
+ scope: 'openid email profile'
+ },
+ twitter: {
+ clientId: process.env.TWITTER_CLIENT_ID,
+ clientSecret: process.env.TWITTER_CLIENT_SECRET,
+ redirectUri: getRedirectUri('twitter', req),
+ authorizationUrl: 'https://x.com/i/oauth2/authorize',
+ tokenUrl: 'https://api.x.com/2/oauth2/token',
+ scope: 'users.read tweet.read'
+ }
+ }
+
+ return configs[provider]
+}
+
+const requireProviderConfig = (provider, req, res) => {
+ const config = getProviderConfig(provider, req)
+ if (!config || !config.clientId || !config.clientSecret) {
+ const callbackUrl = getRedirectUri(provider, req)
+ console.error(`OAuth ${provider} is missing credentials. Expected callback URL: ${callbackUrl}`)
+ res.status(503).json({
+ message: `${provider} OAuth is not configured`,
+ required: [
+ `${provider.toUpperCase()}_CLIENT_ID`,
+ `${provider.toUpperCase()}_CLIENT_SECRET`,
+ `${provider.toUpperCase()}_CALLBACK_URL`
+ ],
+ callbackUrl
+ })
+ return null
+ }
+ return config
+}
+
+const sanitizeUsername = (value, fallback) => {
+ const source = value || fallback || 'oauth_user'
+ let username = source
+ .toString()
+ .split('@')[0]
+ .replace(/[^a-zA-Z0-9_]/g, '_')
+ .replace(/_+/g, '_')
+ .replace(/^_+|_+$/g, '')
+ .slice(0, 20)
+
+ if (username.length < 3) {
+ username = `user_${username}`.slice(0, 20)
+ }
+
+ return username || `user_${crypto.randomBytes(4).toString('hex')}`
+}
+
+const getAvailableUsername = async (baseUsername) => {
+ const base = sanitizeUsername(baseUsername)
+
+ for (let index = 0; index < 100; index += 1) {
+ const suffix = index === 0 ? '' : `_${index}`
+ const candidate = `${base.slice(0, 20 - suffix.length)}${suffix}`
+ const existingUser = await User.findOne({ username: candidate })
+
+ if (!existingUser) {
+ return candidate
+ }
+ }
+
+ return `user_${crypto.randomBytes(7).toString('hex')}`.slice(0, 20)
+}
+
+const findOrCreateOAuthUser = async (provider, profile) => {
+ const providerIdPath = `oauthProviders.${provider}.id`
+ let user = await User.findOne({ [providerIdPath]: profile.id })
+
+ if (!user && profile.email && !profile.email.endsWith('@oauth.stackit.local')) {
+ user = await User.findOne({ email: profile.email.toLowerCase() })
+ }
+
+ if (!user) {
+ const oauthProviders = provider === 'google'
+ ? { google: { id: profile.id, email: profile.email } }
+ : { twitter: { id: profile.id, username: profile.username } }
+
+ user = await User.create({
+ username: await getAvailableUsername(profile.username || profile.name || profile.email),
+ email: profile.email.toLowerCase(),
+ password: crypto.randomBytes(32).toString('hex'),
+ avatar: profile.avatar || '',
+ isVerified: !!profile.isVerified,
+ oauthProviders
+ })
+ return user
+ }
+
+ user.oauthProviders = user.oauthProviders || {}
+ if (provider === 'google') {
+ user.oauthProviders.google = {
+ id: profile.id,
+ email: profile.email
+ }
+ } else {
+ user.oauthProviders.twitter = {
+ id: profile.id,
+ username: profile.username
+ }
+ }
+
+ if (!user.avatar && profile.avatar) {
+ user.avatar = profile.avatar
+ }
+
+ if (profile.isVerified) {
+ user.isVerified = true
+ }
+
+ user.lastSeen = new Date()
+ await user.save()
+ return user
+}
+
+const exchangeGoogleCode = async (config, code) => {
+ const tokenResponse = await axios.post(config.tokenUrl, new URLSearchParams({
+ code,
+ client_id: config.clientId,
+ client_secret: config.clientSecret,
+ redirect_uri: config.redirectUri,
+ grant_type: 'authorization_code'
+ }), {
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
+ })
+
+ const profileResponse = await axios.get('https://www.googleapis.com/oauth2/v2/userinfo', {
+ headers: { Authorization: `Bearer ${tokenResponse.data.access_token}` }
+ })
+
+ const profile = profileResponse.data
+ return {
+ id: profile.id,
+ email: profile.email,
+ name: profile.name,
+ username: profile.email || profile.name,
+ avatar: profile.picture,
+ isVerified: profile.verified_email
+ }
+}
+
+const exchangeTwitterCode = async (config, code, codeVerifier) => {
+ const basicAuth = Buffer
+ .from(`${config.clientId}:${config.clientSecret}`)
+ .toString('base64')
+
+ const tokenResponse = await axios.post(config.tokenUrl, new URLSearchParams({
+ client_id: config.clientId,
+ code,
+ grant_type: 'authorization_code',
+ redirect_uri: config.redirectUri,
+ code_verifier: codeVerifier
+ }), {
+ headers: {
+ Authorization: `Basic ${basicAuth}`,
+ 'Content-Type': 'application/x-www-form-urlencoded'
+ }
+ })
+
+ const profileResponse = await axios.get('https://api.x.com/2/users/me', {
+ params: {
+ 'user.fields': 'profile_image_url'
+ },
+ headers: { Authorization: `Bearer ${tokenResponse.data.access_token}` }
+ })
+
+ const profile = profileResponse.data.data
+ return {
+ id: profile.id,
+ email: `twitter_${profile.id}@oauth.stackit.local`,
+ name: profile.name,
+ username: profile.username,
+ avatar: profile.profile_image_url,
+ isVerified: false
+ }
+}
+
+const redirectWithOAuthError = (res, provider, error) => {
+ const callbackUrl = new URL('/auth/callback', getClientBaseUrl())
+ callbackUrl.searchParams.set('provider', provider)
+ callbackUrl.searchParams.set('error', error)
+ return res.redirect(callbackUrl.toString())
+}
+
+// @route GET /api/auth/:provider
+// @desc Start OAuth login with Google or Twitter/X
+// @access Public
+router.get('/:provider(google|twitter)', (req, res) => {
+ const { provider } = req.params
+ const config = requireProviderConfig(provider, req, res)
+ if (!config) return
+
+ const pkce = provider === 'twitter' ? createPkcePair() : null
+ const state = createOAuthState(provider, pkce ? { codeVerifier: pkce.verifier } : {})
+ const authorizationUrl = new URL(config.authorizationUrl)
+
+ authorizationUrl.searchParams.set('client_id', config.clientId)
+ authorizationUrl.searchParams.set('redirect_uri', config.redirectUri)
+ authorizationUrl.searchParams.set('response_type', 'code')
+ authorizationUrl.searchParams.set('scope', config.scope)
+ authorizationUrl.searchParams.set('state', state)
+
+ if (provider === 'google') {
+ authorizationUrl.searchParams.set('access_type', 'offline')
+ authorizationUrl.searchParams.set('prompt', 'select_account')
+ }
+
+ if (pkce) {
+ authorizationUrl.searchParams.set('code_challenge', pkce.challenge)
+ authorizationUrl.searchParams.set('code_challenge_method', 'S256')
+ }
+
+ res.redirect(authorizationUrl.toString().replace(/\+/g, '%20'))
+})
+
+// @route GET /api/auth/:provider/callback
+// @desc Complete OAuth login with Google or Twitter/X
+// @access Public
+router.get('/:provider(google|twitter)/callback', async (req, res) => {
+ const { provider } = req.params
+ const { code, state, error } = req.query
+
+ if (error) {
+ return redirectWithOAuthError(res, provider, error)
+ }
+
+ if (!code || !state) {
+ return redirectWithOAuthError(res, provider, 'missing_oauth_response')
+ }
+
+ const config = requireProviderConfig(provider, req, res)
+ if (!config) return
+
+ try {
+ const decodedState = verifyOAuthState(state, provider)
+ const profile = provider === 'google'
+ ? await exchangeGoogleCode(config, code)
+ : await exchangeTwitterCode(config, code, decodedState.codeVerifier)
+
+ if (!profile.email) {
+ return redirectWithOAuthError(res, provider, 'missing_email')
+ }
+
+ const user = await findOrCreateOAuthUser(provider, profile)
+
+ if (user.isBanned) {
+ return redirectWithOAuthError(res, provider, 'account_banned')
+ }
+
+ const token = generateToken(user._id)
+ const callbackUrl = `${getClientBaseUrl()}/auth/callback#token=${encodeURIComponent(token)}&provider=${encodeURIComponent(provider)}`
+ return res.redirect(callbackUrl)
+ } catch (callbackError) {
+ console.error(`${provider} OAuth callback error:`, callbackError.response?.data || callbackError)
+ return redirectWithOAuthError(res, provider, 'oauth_callback_failed')
+ }
+})
+
// @route POST /api/auth/signup
// @desc Register a new user
// @access Public
@@ -32,7 +347,7 @@ router.post('/signup', [
try {
const errors = validationResult(req)
if (!errors.isEmpty()) {
- return res.status(400).json({
+ return res.status(400).json({
message: 'Validation failed',
errors: errors.array()
})
@@ -100,7 +415,7 @@ router.post('/login', [
try {
const errors = validationResult(req)
if (!errors.isEmpty()) {
- return res.status(400).json({
+ return res.status(400).json({
message: 'Validation failed',
errors: errors.array()
})
@@ -111,12 +426,12 @@ router.post('/login', [
// Check if user exists
const user = await User.findOne({ email })
if (!user) {
- return res.status(401).json({ message: 'Invalid credentials' })
+ return res.status(401).json({ message: 'Email not registered' })
}
// Check if user is banned
if (user.isBanned) {
- return res.status(403).json({
+ return res.status(403).json({
message: 'Account is banned',
reason: user.banReason
})
@@ -125,7 +440,7 @@ router.post('/login', [
// Check password
const isMatch = await user.comparePassword(password)
if (!isMatch) {
- return res.status(401).json({ message: 'Invalid credentials' })
+ return res.status(401).json({ message: 'Password is incorrect' })
}
// Update last seen
@@ -218,7 +533,7 @@ router.put('/profile', [
try {
const errors = validationResult(req)
if (!errors.isEmpty()) {
- return res.status(400).json({
+ return res.status(400).json({
message: 'Validation failed',
errors: errors.array()
})
@@ -288,7 +603,7 @@ router.post('/change-password', [
try {
const errors = validationResult(req)
if (!errors.isEmpty()) {
- return res.status(400).json({
+ return res.status(400).json({
message: 'Validation failed',
errors: errors.array()
})
@@ -318,4 +633,4 @@ router.post('/change-password', [
}
})
-module.exports = router
\ No newline at end of file
+module.exports = router