← 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
};