google sign in with oauth 2.0. no more console errors or warnings
This commit is contained in:
@@ -146,11 +146,11 @@ const validateLogin = [
|
||||
|
||||
// Google auth validation
|
||||
const validateGoogleAuth = [
|
||||
body("idToken")
|
||||
body("code")
|
||||
.notEmpty()
|
||||
.withMessage("Google ID token is required")
|
||||
.isLength({ max: 2048 })
|
||||
.withMessage("Invalid token format"),
|
||||
.withMessage("Authorization code is required")
|
||||
.isLength({ max: 512 })
|
||||
.withMessage("Invalid authorization code format"),
|
||||
|
||||
handleValidationErrors,
|
||||
];
|
||||
|
||||
@@ -13,7 +13,11 @@ const { csrfProtection, getCSRFToken } = require("../middleware/csrf");
|
||||
const { loginLimiter, registerLimiter } = require("../middleware/rateLimiter");
|
||||
const router = express.Router();
|
||||
|
||||
const googleClient = new OAuth2Client(process.env.GOOGLE_CLIENT_ID);
|
||||
const googleClient = new OAuth2Client(
|
||||
process.env.GOOGLE_CLIENT_ID,
|
||||
process.env.GOOGLE_CLIENT_SECRET,
|
||||
process.env.GOOGLE_REDIRECT_URI || "http://localhost:3000/auth/google/callback"
|
||||
);
|
||||
|
||||
// Get CSRF token endpoint
|
||||
router.get("/csrf-token", (req, res) => {
|
||||
@@ -214,15 +218,21 @@ router.post(
|
||||
validateGoogleAuth,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { idToken } = req.body;
|
||||
const { code } = req.body;
|
||||
|
||||
if (!idToken) {
|
||||
return res.status(400).json({ error: "ID token is required" });
|
||||
if (!code) {
|
||||
return res.status(400).json({ error: "Authorization code is required" });
|
||||
}
|
||||
|
||||
// Verify the Google ID token
|
||||
// Exchange authorization code for tokens
|
||||
const { tokens } = await googleClient.getToken({
|
||||
code,
|
||||
redirect_uri: process.env.GOOGLE_REDIRECT_URI || "http://localhost:3000/auth/google/callback",
|
||||
});
|
||||
|
||||
// Verify the ID token from the token response
|
||||
const ticket = await googleClient.verifyIdToken({
|
||||
idToken,
|
||||
idToken: tokens.id_token,
|
||||
audience: process.env.GOOGLE_CLIENT_ID,
|
||||
});
|
||||
|
||||
@@ -315,26 +325,21 @@ router.post(
|
||||
// Don't send token in response body for security
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.message && error.message.includes("Token used too late")) {
|
||||
if (error.message && error.message.includes("invalid_grant")) {
|
||||
return res
|
||||
.status(401)
|
||||
.json({ error: "Google token has expired. Please try again." });
|
||||
.json({ error: "Invalid or expired authorization code. Please try again." });
|
||||
}
|
||||
if (error.message && error.message.includes("Invalid token")) {
|
||||
return res
|
||||
.status(401)
|
||||
.json({ error: "Invalid Google token. Please try again." });
|
||||
}
|
||||
if (error.message && error.message.includes("Wrong number of segments")) {
|
||||
if (error.message && error.message.includes("redirect_uri_mismatch")) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Malformed Google token. Please try again." });
|
||||
.json({ error: "Redirect URI mismatch. Please contact support." });
|
||||
}
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Google auth error", {
|
||||
reqLogger.error("Google OAuth error", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
tokenInfo: logger.sanitize({ idToken: req.body.idToken })
|
||||
codePresent: !!req.body.code
|
||||
});
|
||||
res
|
||||
.status(500)
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css"
|
||||
/>
|
||||
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
|
||||
@@ -6,6 +6,7 @@ import Footer from './components/Footer';
|
||||
import Home from './pages/Home';
|
||||
import Login from './pages/Login';
|
||||
import Register from './pages/Register';
|
||||
import GoogleCallback from './pages/GoogleCallback';
|
||||
import ItemList from './pages/ItemList';
|
||||
import ItemDetail from './pages/ItemDetail';
|
||||
import EditItem from './pages/EditItem';
|
||||
@@ -36,6 +37,7 @@ function App() {
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/auth/google/callback" element={<GoogleCallback />} />
|
||||
<Route path="/items" element={<ItemList />} />
|
||||
<Route path="/items/:id" element={<ItemDetail />} />
|
||||
<Route path="/users/:id" element={<PublicProfile />} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import PasswordStrengthMeter from "./PasswordStrengthMeter";
|
||||
|
||||
@@ -20,9 +20,8 @@ const AuthModal: React.FC<AuthModalProps> = ({
|
||||
const [lastName, setLastName] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const googleButtonRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { login, register, googleLogin, updateUser } = useAuth();
|
||||
const { login, register } = useAuth();
|
||||
|
||||
// Update mode when modal is opened with different initialMode
|
||||
useEffect(() => {
|
||||
@@ -31,43 +30,29 @@ const AuthModal: React.FC<AuthModalProps> = ({
|
||||
}
|
||||
}, [show, initialMode]);
|
||||
|
||||
// Initialize Google Sign-In
|
||||
useEffect(() => {
|
||||
if (show && window.google && process.env.REACT_APP_GOOGLE_CLIENT_ID) {
|
||||
try {
|
||||
window.google.accounts.id.initialize({
|
||||
client_id: process.env.REACT_APP_GOOGLE_CLIENT_ID,
|
||||
callback: handleGoogleResponse,
|
||||
auto_select: false,
|
||||
cancel_on_tap_outside: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error initializing Google Sign-In:", error);
|
||||
}
|
||||
}
|
||||
}, [show]);
|
||||
const resetModal = () => {
|
||||
setError("");
|
||||
setEmail("");
|
||||
setPassword("");
|
||||
setFirstName("");
|
||||
setLastName("");
|
||||
};
|
||||
|
||||
const handleGoogleResponse = async (response: any) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
const handleGoogleLogin = () => {
|
||||
const clientId = process.env.REACT_APP_GOOGLE_CLIENT_ID;
|
||||
const redirectUri = `${window.location.origin}/auth/google/callback`;
|
||||
const scope = 'email profile';
|
||||
const responseType = 'code';
|
||||
|
||||
if (!response?.credential) {
|
||||
throw new Error("No credential received from Google");
|
||||
}
|
||||
const googleAuthUrl = `https://accounts.google.com/o/oauth2/v2/auth?` +
|
||||
`client_id=${encodeURIComponent(clientId || '')}` +
|
||||
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
||||
`&response_type=${responseType}` +
|
||||
`&scope=${encodeURIComponent(scope)}` +
|
||||
`&access_type=offline` +
|
||||
`&prompt=consent`;
|
||||
|
||||
await googleLogin(response.credential);
|
||||
onHide();
|
||||
resetModal();
|
||||
} catch (err: any) {
|
||||
setError(
|
||||
err.response?.data?.error ||
|
||||
err.message ||
|
||||
"Failed to sign in with Google"
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
window.location.href = googleAuthUrl;
|
||||
};
|
||||
|
||||
const handleEmailSubmit = async (e: React.FormEvent) => {
|
||||
@@ -96,27 +81,6 @@ const AuthModal: React.FC<AuthModalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSocialLogin = (provider: string) => {
|
||||
if (provider === "google") {
|
||||
if (window.google) {
|
||||
try {
|
||||
window.google.accounts.id.prompt();
|
||||
} catch (error) {
|
||||
setError("Failed to open Google Sign-In. Please try again.");
|
||||
}
|
||||
} else {
|
||||
setError("Google Sign-In is not available. Please try again later.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resetModal = () => {
|
||||
setError("");
|
||||
setEmail("");
|
||||
setPassword("");
|
||||
setFirstName("");
|
||||
setLastName("");
|
||||
};
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
@@ -226,8 +190,9 @@ const AuthModal: React.FC<AuthModalProps> = ({
|
||||
{/* Social Login Options */}
|
||||
<button
|
||||
className="btn btn-outline-dark w-100 mb-2 py-3 d-flex align-items-center justify-content-center"
|
||||
onClick={() => handleSocialLogin("google")}
|
||||
onClick={handleGoogleLogin}
|
||||
disabled={loading}
|
||||
type="button"
|
||||
>
|
||||
<i className="bi bi-google me-2"></i>
|
||||
Continue with Google
|
||||
|
||||
@@ -3,6 +3,7 @@ import React, {
|
||||
useState,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import { User } from "../types";
|
||||
@@ -18,7 +19,7 @@ interface AuthContextType {
|
||||
loading: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (data: any) => Promise<void>;
|
||||
googleLogin: (idToken: string) => Promise<void>;
|
||||
googleLogin: (code: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
updateUser: (user: User) => void;
|
||||
checkAuth: () => Promise<void>;
|
||||
@@ -41,6 +42,7 @@ interface AuthProviderProps {
|
||||
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const isAuthenticating = useRef(false);
|
||||
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
@@ -62,9 +64,18 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||
// Initialize authentication
|
||||
const initializeAuth = async () => {
|
||||
try {
|
||||
await fetchCSRFToken();
|
||||
// Check if user is already authenticated
|
||||
await checkAuth();
|
||||
// Skip CSRF token fetch and auth check if we're on the OAuth callback page
|
||||
// The callback page will handle both CSRF token fetch and authentication
|
||||
const isOAuthCallback = window.location.pathname === '/auth/google/callback';
|
||||
|
||||
if (!isOAuthCallback) {
|
||||
await fetchCSRFToken();
|
||||
|
||||
// Skip auth check if we're in the middle of an authentication operation
|
||||
if (!isAuthenticating.current) {
|
||||
await checkAuth();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize authentication:", error);
|
||||
} finally {
|
||||
@@ -89,11 +100,16 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||
await fetchCSRFToken();
|
||||
};
|
||||
|
||||
const googleLogin = async (idToken: string) => {
|
||||
const response = await authAPI.googleLogin({ idToken });
|
||||
setUser(response.data.user);
|
||||
// Fetch new CSRF token after Google login
|
||||
await fetchCSRFToken();
|
||||
const googleLogin = async (code: string) => {
|
||||
isAuthenticating.current = true;
|
||||
try {
|
||||
const response = await authAPI.googleLogin(code);
|
||||
setUser(response.data.user);
|
||||
// Fetch new CSRF token after Google login
|
||||
await fetchCSRFToken();
|
||||
} finally {
|
||||
isAuthenticating.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
|
||||
98
frontend/src/pages/GoogleCallback.tsx
Normal file
98
frontend/src/pages/GoogleCallback.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { fetchCSRFToken } from '../services/api';
|
||||
|
||||
const GoogleCallback: React.FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { googleLogin } = useAuth();
|
||||
const [error, setError] = useState<string>('');
|
||||
const [processing, setProcessing] = useState(true);
|
||||
const hasProcessed = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCallback = async () => {
|
||||
// Prevent double execution in React StrictMode
|
||||
if (hasProcessed.current) {
|
||||
return;
|
||||
}
|
||||
hasProcessed.current = true;
|
||||
|
||||
try {
|
||||
const code = searchParams.get('code');
|
||||
const errorParam = searchParams.get('error');
|
||||
|
||||
if (errorParam) {
|
||||
setError('Google Sign-In was cancelled or failed. Please try again.');
|
||||
setProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
setError('No authorization code received from Google.');
|
||||
setProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch CSRF token before making auth request
|
||||
const csrfToken = await fetchCSRFToken();
|
||||
|
||||
if (!csrfToken) {
|
||||
console.error('Failed to fetch CSRF token');
|
||||
setError('Failed to initialize security token. Please try again.');
|
||||
setProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Exchange code for user session
|
||||
await googleLogin(code);
|
||||
|
||||
// Redirect to home page on success
|
||||
navigate('/', { replace: true });
|
||||
} catch (err: any) {
|
||||
console.error('Google OAuth callback error:', err);
|
||||
setError(err.response?.data?.error || 'Failed to sign in with Google. Please try again.');
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
handleCallback();
|
||||
}, [searchParams, googleLogin, navigate]);
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="row justify-content-center mt-5">
|
||||
<div className="col-md-6">
|
||||
<div className="card">
|
||||
<div className="card-body text-center py-5">
|
||||
{processing ? (
|
||||
<>
|
||||
<div className="spinner-border text-primary mb-3" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<h5>Completing Google Sign-In...</h5>
|
||||
<p className="text-muted">Please wait while we log you in.</p>
|
||||
</>
|
||||
) : error ? (
|
||||
<>
|
||||
<i className="bi bi-exclamation-circle text-danger" style={{ fontSize: '3rem' }}></i>
|
||||
<h5 className="mt-3">Sign-In Failed</h5>
|
||||
<p className="text-danger">{error}</p>
|
||||
<button
|
||||
className="btn btn-primary mt-3"
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
Return to Home
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GoogleCallback;
|
||||
@@ -58,10 +58,12 @@ api.interceptors.request.use(async (config) => {
|
||||
if (["POST", "PUT", "DELETE", "PATCH"].includes(method)) {
|
||||
// If we don't have a CSRF token yet, try to fetch it
|
||||
if (!csrfToken) {
|
||||
// Skip fetching for auth endpoints to avoid circular dependency
|
||||
// Skip fetching for most auth endpoints to avoid circular dependency
|
||||
// Exception: /auth/google needs CSRF token and should auto-fetch as fallback
|
||||
const isAuthEndpoint =
|
||||
config.url?.includes("/auth/") &&
|
||||
!config.url?.includes("/auth/refresh");
|
||||
!config.url?.includes("/auth/refresh") &&
|
||||
!config.url?.includes("/auth/google");
|
||||
if (!isAuthEndpoint) {
|
||||
await fetchCSRFToken();
|
||||
}
|
||||
@@ -90,6 +92,12 @@ api.interceptors.response.use(
|
||||
errorData?.code === "CSRF_TOKEN_MISMATCH" &&
|
||||
!originalRequest._csrfRetry
|
||||
) {
|
||||
// Don't retry OAuth endpoints - the authorization code is single-use
|
||||
const isOAuthEndpoint = originalRequest.url?.includes("/auth/google");
|
||||
if (isOAuthEndpoint) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
originalRequest._csrfRetry = true;
|
||||
|
||||
// Try to fetch a new CSRF token and retry
|
||||
@@ -153,7 +161,7 @@ api.interceptors.response.use(
|
||||
export const authAPI = {
|
||||
register: (data: any) => api.post("/auth/register", data),
|
||||
login: (data: any) => api.post("/auth/login", data),
|
||||
googleLogin: (data: any) => api.post("/auth/google", data),
|
||||
googleLogin: (code: string) => api.post("/auth/google", { code }),
|
||||
logout: () => api.post("/auth/logout"),
|
||||
refresh: () => api.post("/auth/refresh"),
|
||||
getCSRFToken: () => api.get("/auth/csrf-token"),
|
||||
|
||||
Reference in New Issue
Block a user