← back to Watches

api/auth/github.js

280 lines

/**
 * GitHub OAuth Authentication Handler
 * Implements GitHub Sign-In for the Omega Watch Platform
 */

import { v4 as uuidv4 } from 'uuid';
import pool from '../../database/connection.js';

// GitHub OAuth configuration
const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID;
const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET;
const GITHUB_REDIRECT_URI = process.env.GITHUB_REDIRECT_URI || 'http://45.61.58.125:7600/api/auth/github/callback';

// GitHub OAuth URLs
const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize';
const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token';
const GITHUB_USER_URL = 'https://api.github.com/user';
const GITHUB_EMAILS_URL = 'https://api.github.com/user/emails';

/**
 * Generate GitHub OAuth authorization URL
 * @param {string} state - CSRF protection state token
 * @returns {string} Authorization URL
 */
export function getGitHubAuthUrl(state) {
  const params = new URLSearchParams({
    client_id: GITHUB_CLIENT_ID,
    redirect_uri: GITHUB_REDIRECT_URI,
    scope: 'read:user user:email',
    state: state,
    allow_signup: 'true'
  });

  return `${GITHUB_AUTH_URL}?${params.toString()}`;
}

/**
 * Exchange authorization code for access token
 * @param {string} code - Authorization code from GitHub
 * @returns {string} Access token
 */
export async function exchangeCodeForToken(code) {
  const response = await fetch(GITHUB_TOKEN_URL, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Accept': 'application/json'
    },
    body: JSON.stringify({
      client_id: GITHUB_CLIENT_ID,
      client_secret: GITHUB_CLIENT_SECRET,
      code: code,
      redirect_uri: GITHUB_REDIRECT_URI
    })
  });

  if (!response.ok) {
    throw new Error('Failed to exchange GitHub code');
  }

  const data = await response.json();

  if (data.error) {
    throw new Error(data.error_description || data.error);
  }

  return data.access_token;
}

/**
 * Get user info from GitHub
 * @param {string} accessToken - GitHub access token
 * @returns {object} User info
 */
export async function getGitHubUser(accessToken) {
  const response = await fetch(GITHUB_USER_URL, {
    headers: {
      'Authorization': `Bearer ${accessToken}`,
      'Accept': 'application/vnd.github.v3+json',
      'User-Agent': 'OmegaWatchPlatform'
    }
  });

  if (!response.ok) {
    throw new Error('Failed to get GitHub user');
  }

  return response.json();
}

/**
 * Get user's primary email from GitHub
 * @param {string} accessToken - GitHub access token
 * @returns {string|null} Primary email or null
 */
export async function getGitHubEmail(accessToken) {
  const response = await fetch(GITHUB_EMAILS_URL, {
    headers: {
      'Authorization': `Bearer ${accessToken}`,
      'Accept': 'application/vnd.github.v3+json',
      'User-Agent': 'OmegaWatchPlatform'
    }
  });

  if (!response.ok) {
    return null;
  }

  const emails = await response.json();

  // Find primary verified email
  const primary = emails.find(e => e.primary && e.verified);
  if (primary) return primary.email;

  // Fallback to any verified email
  const verified = emails.find(e => e.verified);
  if (verified) return verified.email;

  // Last resort: any email
  return emails[0]?.email || null;
}

/**
 * Find or create user from GitHub profile
 * @param {object} githubUser - GitHub user profile
 * @param {string} email - User's email
 * @returns {object} User record
 */
export async function findOrCreateGitHubUser(githubUser, email) {
  const { id: githubId, login, name, avatar_url } = githubUser;

  // First, try to find existing user by GitHub ID
  let result = await pool.query(
    `SELECT * FROM users WHERE provider = 'github' AND provider_id = $1`,
    [githubId.toString()]
  );

  if (result.rows.length > 0) {
    // Update last login and avatar
    await pool.query(
      `UPDATE users
       SET last_login_at = NOW(), login_count = login_count + 1, avatar_url = $1
       WHERE id = $2`,
      [avatar_url, result.rows[0].id]
    );
    return result.rows[0];
  }

  // Check if email already exists
  if (email) {
    result = await pool.query(
      `SELECT * FROM users WHERE email = $1`,
      [email]
    );

    if (result.rows.length > 0) {
      // Link GitHub account to existing user
      const existingUser = result.rows[0];
      await pool.query(
        `UPDATE users
         SET provider = 'github', provider_id = $1, avatar_url = $2,
             last_login_at = NOW(), login_count = login_count + 1,
             email_verified = true
         WHERE id = $3`,
        [githubId.toString(), avatar_url, existingUser.id]
      );
      return { ...existingUser, provider: 'github', provider_id: githubId.toString() };
    }
  }

  // Create new user
  const userName = name || login;
  const userEmail = email || `${login}@github.local`; // Fallback if no email

  const newUser = await pool.query(
    `INSERT INTO users (email, name, avatar_url, provider, provider_id, email_verified)
     VALUES ($1, $2, $3, 'github', $4, $5)
     RETURNING *`,
    [userEmail, userName, avatar_url, githubId.toString(), !!email]
  );

  return newUser.rows[0];
}

/**
 * Create session for user
 * @param {object} user - User record
 * @param {object} req - Express request
 * @returns {object} Session tokens
 */
export async function createSession(user, req) {
  const sessionToken = uuidv4();
  const refreshToken = uuidv4();
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
  const refreshExpiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);

  const userAgent = req.headers['user-agent'] || '';
  const ipAddress = req.ip || req.connection?.remoteAddress;

  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, 'desktop', expiresAt, refreshExpiresAt]
  );

  return { sessionToken, refreshToken, expiresAt };
}

/**
 * Express route handler for initiating GitHub OAuth
 */
export function initiateGitHubAuth(req, res) {
  const state = uuidv4();

  res.cookie('oauth_state', state, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    maxAge: 10 * 60 * 1000
  });

  const authUrl = getGitHubAuthUrl(state);
  res.redirect(authUrl);
}

/**
 * Express route handler for GitHub OAuth callback
 */
export async function handleGitHubCallback(req, res) {
  try {
    const { code, state } = req.query;
    const storedState = req.cookies?.oauth_state;

    if (!state || state !== storedState) {
      return res.status(400).json({ error: 'Invalid state parameter' });
    }

    res.clearCookie('oauth_state');

    // Exchange code for token
    const accessToken = await exchangeCodeForToken(code);

    // Get user info
    const githubUser = await getGitHubUser(accessToken);

    // Get email (might be private)
    const email = githubUser.email || await getGitHubEmail(accessToken);

    // Find or create user
    const user = await findOrCreateGitHubUser(githubUser, email);

    // Create session
    const session = await createSession(user, req);

    res.cookie('session_token', session.sessionToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      maxAge: 7 * 24 * 60 * 60 * 1000,
      sameSite: 'lax'
    });

    res.redirect('/?auth=success');

  } catch (error) {
    console.error('GitHub OAuth error:', error);
    res.redirect('/?auth=error&message=' + encodeURIComponent(error.message));
  }
}

export default {
  getGitHubAuthUrl,
  exchangeCodeForToken,
  getGitHubUser,
  getGitHubEmail,
  findOrCreateGitHubUser,
  createSession,
  initiateGitHubAuth,
  handleGitHubCallback
};