/** * OAuthController * * @description Controller for OAuth authentication (Google, Facebook, Twitter, Apple, GitHub). * Extracted from auth.controller.ts (P0-009: Auth Controller split). * * Routes: * - GET /auth/oauth/:provider - Get OAuth authorization URL * - GET /auth/callback/:provider - Handle OAuth callback * - POST /auth/oauth/:provider/verify - Verify OAuth token (mobile/SPA) * - GET /auth/accounts - Get linked OAuth accounts * - DELETE /auth/accounts/:provider - Unlink OAuth account * * @see EmailAuthController - Email/password authentication * @see TwoFactorController - 2FA operations * @see oauthStateStore - Redis-based state storage (P0-010) */ import { Request, Response, NextFunction } from 'express'; import { oauthService } from '../services/oauth.service'; import { oauthStateStore } from '../stores/oauth-state.store'; import { config } from '../../../config'; import { logger } from '../../../shared/utils/logger'; import type { AuthProvider } from '../types/auth.types'; /** * Gets client info from request */ const getClientInfo = (req: Request) => ({ userAgent: req.headers['user-agent'], ipAddress: req.ip || req.socket.remoteAddress, }); /** * GET /auth/oauth/:provider * * Get OAuth authorization URL for provider */ export const getOAuthUrl = async (req: Request, res: Response, next: NextFunction) => { try { const provider = req.params.provider as AuthProvider; const { returnUrl } = req.query; const state = oauthService.generateState(); let codeVerifier: string | undefined; let authUrl: string; switch (provider) { case 'google': authUrl = oauthService.getGoogleAuthUrl(state); break; case 'facebook': authUrl = oauthService.getFacebookAuthUrl(state); break; case 'twitter': { codeVerifier = oauthService.generateCodeVerifier(); const codeChallenge = oauthService.generateCodeChallenge(codeVerifier); authUrl = oauthService.getTwitterAuthUrl(state, codeChallenge); break; } case 'apple': authUrl = oauthService.getAppleAuthUrl(state); break; case 'github': authUrl = oauthService.getGitHubAuthUrl(state); break; default: return res.status(400).json({ success: false, error: 'Invalid OAuth provider', }); } // Store state in Redis (P0-010: OAuth state → Redis) await oauthStateStore.set(state, { provider, codeVerifier, returnUrl: returnUrl as string, }); res.json({ success: true, data: { authUrl }, }); } catch (error) { next(error); } }; /** * GET /auth/callback/:provider * * Handle OAuth callback from provider */ export const handleOAuthCallback = async (req: Request, res: Response, _next: NextFunction) => { try { const provider = req.params.provider as AuthProvider; const { code, state } = req.query; const { userAgent, ipAddress } = getClientInfo(req); // Verify and retrieve state from Redis (P0-010) const stateData = await oauthStateStore.getAndDelete(state as string); if (!stateData) { return res.redirect(`${config.app.frontendUrl}/login?error=invalid_state`); } let oauthData; switch (provider) { case 'google': oauthData = await oauthService.verifyGoogleToken(code as string); break; case 'facebook': oauthData = await oauthService.verifyFacebookToken(code as string); break; case 'twitter': if (!stateData.codeVerifier) { return res.redirect(`${config.app.frontendUrl}/login?error=missing_code_verifier`); } oauthData = await oauthService.verifyTwitterToken(code as string, stateData.codeVerifier); break; case 'apple': oauthData = await oauthService.verifyAppleToken(code as string, req.query.id_token as string); break; case 'github': oauthData = await oauthService.verifyGitHubToken(code as string); break; default: return res.redirect(`${config.app.frontendUrl}/login?error=invalid_provider`); } if (!oauthData) { return res.redirect(`${config.app.frontendUrl}/login?error=oauth_failed`); } // Handle OAuth login/registration const result = await oauthService.handleOAuthCallback(oauthData, userAgent, ipAddress); // Redirect with tokens const params = new URLSearchParams({ accessToken: result.tokens.accessToken, refreshToken: result.tokens.refreshToken, isNewUser: result.isNewUser?.toString() || 'false', }); const returnUrl = stateData.returnUrl || '/dashboard'; res.redirect(`${config.app.frontendUrl}/auth/callback?${params}&returnUrl=${encodeURIComponent(returnUrl)}`); } catch (error) { logger.error('OAuth callback error', { error }); res.redirect(`${config.app.frontendUrl}/login?error=oauth_error`); } }; /** * POST /auth/oauth/:provider/verify * * Verify OAuth token directly (for mobile/SPA) */ export const verifyOAuthToken = async (req: Request, res: Response, next: NextFunction) => { try { const provider = req.params.provider as AuthProvider; const { token } = req.body; const { userAgent, ipAddress } = getClientInfo(req); let oauthData; switch (provider) { case 'google': // For mobile, we receive an ID token directly oauthData = await oauthService.verifyGoogleIdToken(token); break; // Other providers would need their mobile SDKs default: return res.status(400).json({ success: false, error: 'Provider not supported for direct token verification', }); } if (!oauthData) { return res.status(401).json({ success: false, error: 'Invalid OAuth token', }); } const result = await oauthService.handleOAuthCallback(oauthData, userAgent, ipAddress); res.json({ success: true, data: result, }); } catch (error) { next(error); } }; /** * GET /auth/accounts * * Get all linked OAuth accounts for authenticated user */ export const getLinkedAccounts = async (req: Request, res: Response, next: NextFunction) => { try { const userId = req.user!.id; const accounts = await oauthService.getLinkedAccounts(userId); res.json({ success: true, data: accounts, }); } catch (error) { next(error); } }; /** * DELETE /auth/accounts/:provider * * Unlink an OAuth account from user profile */ export const unlinkAccount = async (req: Request, res: Response, next: NextFunction) => { try { const userId = req.user!.id; const provider = req.params.provider as AuthProvider; await oauthService.unlinkOAuthAccount(userId, provider); res.json({ success: true, message: `${provider} account unlinked`, }); } catch (error) { next(error); } };