trading-platform/apps/backend/src/modules/auth/controllers/oauth.controller.ts

249 lines
7.0 KiB
TypeScript

/**
* 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);
}
};