249 lines
7.0 KiB
TypeScript
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);
|
|
}
|
|
};
|