← back to Watches
api/auth/google.js
254 lines
/**
* Google OAuth 2.0 Authentication Handler
* Implements Google Sign-In for the Omega Watch Platform
*/
import { v4 as uuidv4 } from 'uuid';
import pool from '../../database/connection.js';
// Google OAuth configuration
const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID;
const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET;
const GOOGLE_REDIRECT_URI = process.env.GOOGLE_REDIRECT_URI || 'http://45.61.58.125:7600/api/auth/google/callback';
// Google OAuth URLs
const GOOGLE_AUTH_URL = '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';
/**
* Generate Google OAuth authorization URL
* @param {string} state - CSRF protection state token
* @returns {string} Authorization URL
*/
export function getGoogleAuthUrl(state) {
const params = new URLSearchParams({
client_id: GOOGLE_CLIENT_ID,
redirect_uri: GOOGLE_REDIRECT_URI,
response_type: 'code',
scope: 'openid email profile',
state: state,
access_type: 'offline',
prompt: 'consent'
});
return `${GOOGLE_AUTH_URL}?${params.toString()}`;
}
/**
* Exchange authorization code for tokens
* @param {string} code - Authorization code from Google
* @returns {object} Token response
*/
export async function exchangeCodeForTokens(code) {
const response = await fetch(GOOGLE_TOKEN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
client_id: GOOGLE_CLIENT_ID,
client_secret: GOOGLE_CLIENT_SECRET,
code: code,
grant_type: 'authorization_code',
redirect_uri: GOOGLE_REDIRECT_URI
})
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Failed to exchange code: ${error}`);
}
return response.json();
}
/**
* Get user info from Google
* @param {string} accessToken - Google access token
* @returns {object} User info
*/
export async function getGoogleUserInfo(accessToken) {
const response = await fetch(GOOGLE_USERINFO_URL, {
headers: {
'Authorization': `Bearer ${accessToken}`
}
});
if (!response.ok) {
throw new Error('Failed to get user info from Google');
}
return response.json();
}
/**
* Find or create user from Google profile
* @param {object} googleUser - Google user profile
* @returns {object} User record
*/
export async function findOrCreateGoogleUser(googleUser) {
const { sub: googleId, email, name, picture } = googleUser;
// First, try to find existing user by Google ID
let result = await pool.query(
`SELECT * FROM users WHERE provider = 'google' AND provider_id = $1`,
[googleId]
);
if (result.rows.length > 0) {
// Update last login
await pool.query(
`UPDATE users SET last_login_at = NOW(), login_count = login_count + 1 WHERE id = $1`,
[result.rows[0].id]
);
return result.rows[0];
}
// Check if email already exists with different provider
result = await pool.query(
`SELECT * FROM users WHERE email = $1`,
[email]
);
if (result.rows.length > 0) {
// Link Google account to existing user
const existingUser = result.rows[0];
await pool.query(
`UPDATE users
SET provider = 'google', provider_id = $1, avatar_url = $2,
last_login_at = NOW(), login_count = login_count + 1,
email_verified = true
WHERE id = $3`,
[googleId, picture, existingUser.id]
);
return { ...existingUser, provider: 'google', provider_id: googleId };
}
// Create new user
const newUser = await pool.query(
`INSERT INTO users (email, name, avatar_url, provider, provider_id, email_verified)
VALUES ($1, $2, $3, 'google', $4, true)
RETURNING *`,
[email, name, picture, googleId]
);
return newUser.rows[0];
}
/**
* Create session for user
* @param {object} user - User record
* @param {object} req - Express request for device info
* @returns {object} Session with tokens
*/
export async function createSession(user, req) {
const sessionToken = uuidv4();
const refreshToken = uuidv4();
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
const refreshExpiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
const userAgent = req.headers['user-agent'] || '';
const ipAddress = req.ip || req.connection.remoteAddress;
const deviceType = detectDeviceType(userAgent);
await pool.query(
`INSERT INTO user_sessions
(user_id, session_token, refresh_token, user_agent, ip_address, device_type, expires_at, refresh_expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[user.id, sessionToken, refreshToken, userAgent, ipAddress, deviceType, expiresAt, refreshExpiresAt]
);
return {
sessionToken,
refreshToken,
expiresAt
};
}
/**
* Detect device type from user agent
* @param {string} userAgent - User agent string
* @returns {string} Device type
*/
function detectDeviceType(userAgent) {
const ua = userAgent.toLowerCase();
if (/mobile|android|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(ua)) {
return /ipad|tablet/i.test(ua) ? 'tablet' : 'mobile';
}
return 'desktop';
}
/**
* Express route handler for initiating Google OAuth
*/
export function initiateGoogleAuth(req, res) {
// Generate state token for CSRF protection
const state = uuidv4();
// Store state in cookie for verification
res.cookie('oauth_state', state, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 10 * 60 * 1000 // 10 minutes
});
const authUrl = getGoogleAuthUrl(state);
res.redirect(authUrl);
}
/**
* Express route handler for Google OAuth callback
*/
export async function handleGoogleCallback(req, res) {
try {
const { code, state } = req.query;
const storedState = req.cookies?.oauth_state;
// Verify state to prevent CSRF
if (!state || state !== storedState) {
return res.status(400).json({ error: 'Invalid state parameter' });
}
// Clear state cookie
res.clearCookie('oauth_state');
// Exchange code for tokens
const tokens = await exchangeCodeForTokens(code);
// Get user info from Google
const googleUser = await getGoogleUserInfo(tokens.access_token);
// Find or create user in database
const user = await findOrCreateGoogleUser(googleUser);
// Create session
const session = await createSession(user, req);
// Set session cookie
res.cookie('session_token', session.sessionToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
sameSite: 'lax'
});
// Redirect to frontend with success
res.redirect('/?auth=success');
} catch (error) {
console.error('Google OAuth error:', error);
res.redirect('/?auth=error&message=' + encodeURIComponent(error.message));
}
}
export default {
getGoogleAuthUrl,
exchangeCodeForTokens,
getGoogleUserInfo,
findOrCreateGoogleUser,
createSession,
initiateGoogleAuth,
handleGoogleCallback
};