From 767b6acfbf6f7f53267365480b962548801b4d9e Mon Sep 17 00:00:00 2001 From: Pushpak Tiwari Date: Tue, 7 Jul 2026 00:15:52 +0530 Subject: [PATCH] feat(auth): implement Wikimedia OAuth 2.0 authentication Resolves T431227 This commit introduces 'Sign in with Wikimedia' functionality using the OAuth 2.0 Authorization Code grant flow, integrating alongside the existing local JWT authentication. Key changes: - Backend Auth Controller: Added logic to redirect users to the Wikimedia authorization endpoint and handle the callback code to exchange for access tokens. - User Model: Updated schema to support wikimedia_id and wikimedia_username, and made the password field optional for OAuth users. - Frontend Auth Page: Added a 'Sign in with Wikimedia' button seamlessly integrated into the UI. - Frontend Callback Page: Implemented a new /auth/callback page that safely extracts tokens from the URL and authenticates the user in the React context without double-rendering issues. - Environment: Added necessary WIKIMEDIA environment variables to .env.example for open-source contributors. --- backend/.env.example | 7 +- backend/src/controllers/authController.js | 91 ++++++++++++++++++++++- backend/src/models/User.js | 15 +++- backend/src/routes/authRoutes.js | 6 +- frontend/src/App.tsx | 2 + frontend/src/lib/auth-context.tsx | 22 +++++- frontend/src/pages/AuthPage.tsx | 37 ++++++++- frontend/src/pages/OAuthCallback.tsx | 50 +++++++++++++ frontend/src/pages/index.ts | 1 + 9 files changed, 224 insertions(+), 7 deletions(-) create mode 100644 frontend/src/pages/OAuthCallback.tsx diff --git a/backend/.env.example b/backend/.env.example index 87215cc..0fe61c0 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -3,4 +3,9 @@ MONGODB_URI=mongodb+srv://:@cluster0.thi5oil.mongodb.net/ { } }; +export const wikimediaLogin = async (req, res, next) => { + try { + const params = new URLSearchParams({ + response_type: 'code', + client_id: process.env.WIKIMEDIA_CLIENT_ID, + redirect_uri: process.env.WIKIMEDIA_CALLBACK_URL + }); + + res.redirect(`https://meta.wikimedia.org/w/rest.php/oauth2/authorize?${params.toString()}`); + } catch (error) { + next(error); + } +}; + +export const wikimediaCallback = async (req, res, next) => { + try { + const { code, error: oauthError } = req.query; + + if (oauthError || !code) { + return res.redirect(`${process.env.FRONTEND_URL}/auth?error=${oauthError || 'No code provided'}`); + } + + const userAgent = process.env.WIKIMEDIA_USER_AGENT || 'WikiSourceVerifier/1.0 (Open Source Application; admin@localhost)'; + + const tokenResponse = await fetch('https://meta.wikimedia.org/w/rest.php/oauth2/access_token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': userAgent + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + client_id: process.env.WIKIMEDIA_CLIENT_ID, + client_secret: process.env.WIKIMEDIA_CLIENT_SECRET, + redirect_uri: process.env.WIKIMEDIA_CALLBACK_URL + }) + }); + + const tokenData = await tokenResponse.json(); + if (!tokenResponse.ok) { + console.error('Token Error:', tokenData); + return res.redirect(`${process.env.FRONTEND_URL}/auth?error=Authentication failed`); + } + + const profileResponse = await fetch('https://meta.wikimedia.org/w/rest.php/oauth2/resource/profile', { + headers: { + Authorization: `Bearer ${tokenData.access_token}`, + 'User-Agent': userAgent + } + }); + const profile = await profileResponse.json(); + if (!profileResponse.ok) { + console.error('Profile Error:', profile); + return res.redirect(`${process.env.FRONTEND_URL}/auth?error=Failed to fetch profile`); + } + + let user = await User.findOne({ wikimedia_id: profile.sub }); + + if (!user) { + let finalUsername = profile.username; + const existingUser = await User.findOne({ username: profile.username }); + if (existingUser) { + finalUsername = `${profile.username}_${Math.random().toString(36).substring(7)}`; + } + + user = await User.create({ + username: finalUsername, + email: profile.email || `${finalUsername}@example.com`, + country: 'Global', + wikimedia_id: profile.sub, + wikimedia_username: profile.username, + isActive: true + }); + } else if (!user.isActive) { + return res.redirect(`${process.env.FRONTEND_URL}/auth?error=Account deactivated`); + } + + const accessToken = generateAccessToken(user._id); + const refreshToken = generateRefreshToken(user._id); + + res.redirect(`${process.env.FRONTEND_URL}/auth/callback?accessToken=${accessToken}&refreshToken=${refreshToken}`); + + } catch (error) { + console.error('Wikimedia OAuth catch error:', error); + res.redirect(`${process.env.FRONTEND_URL}/auth?error=Server error`); + } +}; + diff --git a/backend/src/models/User.js b/backend/src/models/User.js index 62fa51b..191f97a 100644 --- a/backend/src/models/User.js +++ b/backend/src/models/User.js @@ -20,10 +20,21 @@ const userSchema = new mongoose.Schema({ }, password: { type: String, - required: [true, 'Password is required'], + required: [ + function() { return !this.wikimedia_id; }, + 'Password is required' + ], minlength: [6, 'Password must be at least 6 characters'], select: false }, + wikimedia_id: { + type: String, + unique: true, + sparse: true + }, + wikimedia_username: { + type: String + }, country: { type: String, required: [true, 'Country is required'] @@ -63,7 +74,7 @@ const userSchema = new mongoose.Schema({ // Hash password before saving userSchema.pre('save', async function(next) { - if (!this.isModified('password')) { + if (!this.isModified('password') || !this.password) { return next(); } diff --git a/backend/src/routes/authRoutes.js b/backend/src/routes/authRoutes.js index 9e00533..91c5e1a 100644 --- a/backend/src/routes/authRoutes.js +++ b/backend/src/routes/authRoutes.js @@ -6,7 +6,9 @@ import { getMe, refreshToken, updateProfile, - changePassword + changePassword, + wikimediaLogin, + wikimediaCallback } from '../controllers/authController.js'; import { protect } from '../middleware/auth.js'; import { registerValidation, loginValidation, validate } from '../middleware/validator.js'; @@ -15,6 +17,8 @@ const router = express.Router(); router.post('/register', registerValidation, validate, register); router.post('/login', loginValidation, validate, login); +router.get('/wikimedia', wikimediaLogin); +router.get('/wikimedia/callback', wikimediaCallback); router.post('/logout', protect, logout); router.get('/me', protect, getMe); router.post('/refresh', refreshToken); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 419bd1f..f08afc1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ import { PublicDirectory, UserProfile, CountryPage, + OAuthCallback, } from "./pages"; import { Toaster } from './components/ui/sonner'; import { TooltipProvider } from './components/ui/tooltip'; @@ -48,6 +49,7 @@ function AppContent() { } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/lib/auth-context.tsx b/frontend/src/lib/auth-context.tsx index d8b593a..67e5d8a 100644 --- a/frontend/src/lib/auth-context.tsx +++ b/frontend/src/lib/auth-context.tsx @@ -18,6 +18,7 @@ interface AuthContextType { user: User | null; loading: boolean; login: (username: string, password: string) => Promise; + loginWithTokens: (accessToken: string, refreshToken: string) => Promise; logout: () => void; register: (username: string, email: string, password: string, country: string) => Promise; updateUser: (updates: Partial) => void; @@ -66,6 +67,25 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children } }; + const loginWithTokens = async (accessToken: string, refreshToken: string): Promise => { + try { + api.setTokens(accessToken, refreshToken); + const response = await authApi.getMe(); + if (response.success && response.user) { + setUser(response.user); + toast.success('Login successful!'); + return true; + } + api.clearAuth(); + toast.error('Failed to get user profile'); + return false; + } catch (error) { + api.clearAuth(); + toast.error('Login failed'); + return false; + } + }; + const logout = async () => { try { await authApi.logout(); @@ -122,7 +142,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }; return ( - + {children} ); diff --git a/frontend/src/pages/AuthPage.tsx b/frontend/src/pages/AuthPage.tsx index e1f3a8d..eba63d2 100644 --- a/frontend/src/pages/AuthPage.tsx +++ b/frontend/src/pages/AuthPage.tsx @@ -9,13 +9,29 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '. import { useAuth } from '../lib/auth-context'; import { COUNTRIES } from '../lib/mock-data'; import { toast } from 'sonner'; -import { Loader2 } from 'lucide-react'; +import { Loader2, Globe } from 'lucide-react'; +import { useEffect } from 'react'; export const AuthPage: React.FC = () => { const navigate = useNavigate(); const { login, register } = useAuth(); const [loading, setLoading] = useState(false); + useEffect(() => { + // Check for errors from OAuth redirect + const params = new URLSearchParams(window.location.search); + const error = params.get('error'); + if (error) { + toast.error(error); + } + }, []); + + const handleWikimediaLogin = () => { + window.location.href = import.meta.env.VITE_API_URL + ? `${import.meta.env.VITE_API_URL}/auth/wikimedia` + : 'http://localhost:5000/api/auth/wikimedia'; + }; + // Login form state const [loginUsername, setLoginUsername] = useState(''); const [loginPassword, setLoginPassword] = useState(''); @@ -72,6 +88,25 @@ export const AuthPage: React.FC = () => {

Sign in or create an account to get started

+
+ + +
+
+ +
+
+ Or continue with +
+
+
+ Login diff --git a/frontend/src/pages/OAuthCallback.tsx b/frontend/src/pages/OAuthCallback.tsx new file mode 100644 index 0000000..85a4f01 --- /dev/null +++ b/frontend/src/pages/OAuthCallback.tsx @@ -0,0 +1,50 @@ +import React, { useEffect, useRef } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useAuth } from '../lib/auth-context'; +import { Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; + +export const OAuthCallback: React.FC = () => { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const { loginWithTokens } = useAuth(); + + // Use a ref to prevent double execution in strict mode + const processed = useRef(false); + + useEffect(() => { + if (processed.current) return; + processed.current = true; + + const accessToken = searchParams.get('accessToken'); + const refreshToken = searchParams.get('refreshToken'); + const error = searchParams.get('error'); + + if (error) { + toast.error(`Authentication failed: ${error}`); + navigate('/auth'); + return; + } + + if (accessToken && refreshToken) { + loginWithTokens(accessToken, refreshToken).then(success => { + if (success) { + navigate('/'); + } else { + navigate('/auth'); + } + }); + } else { + toast.error('Authentication failed: Missing tokens'); + navigate('/auth'); + } + }, [searchParams, navigate, loginWithTokens]); + + return ( +
+ +

Completing sign in...

+

Please wait while we redirect you.

+
+ ); +}; diff --git a/frontend/src/pages/index.ts b/frontend/src/pages/index.ts index ae27df5..b9e1c45 100644 --- a/frontend/src/pages/index.ts +++ b/frontend/src/pages/index.ts @@ -7,3 +7,4 @@ export { AdminDashboard } from './AdminDashboard'; export { PublicDirectory } from './PublicDirectory'; export { UserProfile } from './UserProfile'; export { CountryPage } from './CountryPage'; +export { OAuthCallback } from './OAuthCallback';