/** * OAuth Routes * Gmail and Outlook OAuth2 authentication */ import express from 'express' import { OAuth2Client } from 'google-auth-library' import { ConfidentialClientApplication } from '@azure/msal-node' import { asyncHandler, ValidationError, AppError } from '../middleware/errorHandler.mjs' import { respond } from '../utils/response.mjs' import { emailAccounts } from '../services/database.mjs' import { config, features } from '../config/index.mjs' import { log } from '../middleware/logger.mjs' const router = express.Router() // Google OAuth client (lazy initialization) let googleClient = null function getGoogleClient() { if (!googleClient && features.gmail()) { googleClient = new OAuth2Client( config.google.clientId, config.google.clientSecret, config.google.redirectUri ) } return googleClient } // Microsoft OAuth client (lazy initialization) let msalClient = null function getMsalClient() { if (!msalClient && features.outlook()) { msalClient = new ConfidentialClientApplication({ auth: { clientId: config.microsoft.clientId, clientSecret: config.microsoft.clientSecret, authority: 'https://login.microsoftonline.com/common', }, }) } return msalClient } /** * Gmail OAuth scopes */ const GMAIL_SCOPES = [ 'https://www.googleapis.com/auth/gmail.modify', 'https://www.googleapis.com/auth/gmail.labels', 'https://www.googleapis.com/auth/userinfo.email', ] /** * Microsoft Graph scopes */ const OUTLOOK_SCOPES = [ 'Mail.ReadWrite', 'User.Read', 'offline_access', ] // ═══════════════════════════════════════════════════════════════════════════ // GMAIL OAUTH // ═══════════════════════════════════════════════════════════════════════════ /** * GET /api/oauth/gmail/connect * Initiate Gmail OAuth flow */ router.get('/gmail/connect', asyncHandler(async (req, res) => { const { userId } = req.query if (!userId) { throw new ValidationError('userId ist erforderlich') } if (!features.gmail()) { throw new AppError('Gmail OAuth ist nicht konfiguriert', 503, 'FEATURE_DISABLED') } const client = getGoogleClient() const authUrl = client.generateAuthUrl({ access_type: 'offline', scope: GMAIL_SCOPES, prompt: 'consent', state: JSON.stringify({ userId }), include_granted_scopes: true, }) respond.success(res, { url: authUrl }) })) /** * GET /api/oauth/gmail/callback * Gmail OAuth callback */ router.get('/gmail/callback', asyncHandler(async (req, res) => { const { code, state, error, error_description } = req.query log.info('Gmail OAuth Callback erhalten', { hasCode: !!code, hasState: !!state, error: error || 'none' }) if (error) { log.warn('Gmail OAuth abgelehnt', { error, error_description }) return res.redirect(`${config.frontendUrl}/settings?error=oauth_denied&message=${encodeURIComponent(error_description || error)}`) } if (!code || !state) { log.error('Gmail OAuth: Code oder State fehlt') return res.redirect(`${config.frontendUrl}/settings?error=missing_params`) } let userId try { const stateData = JSON.parse(state) userId = stateData.userId } catch (e) { log.error('Gmail OAuth: State konnte nicht geparst werden', { state }) return res.redirect(`${config.frontendUrl}/settings?error=invalid_state`) } // Create a FRESH OAuth client for this request const client = new OAuth2Client( config.google.clientId, config.google.clientSecret, config.google.redirectUri ) try { log.info('Gmail OAuth: Tausche Code gegen Token...') // Exchange code for tokens const { tokens } = await client.getToken(code) log.info('Gmail OAuth: Token erhalten', { hasAccessToken: !!tokens.access_token, hasRefreshToken: !!tokens.refresh_token, expiresIn: tokens.expiry_date }) client.setCredentials(tokens) // Get user email const { google } = await import('googleapis') const oauth2 = google.oauth2({ version: 'v2', auth: client }) const { data: userInfo } = await oauth2.userinfo.get() log.info('Gmail OAuth: User Info erhalten', { email: userInfo.email }) // Check if account already exists const existingAccounts = await emailAccounts.getByUser(userId) const alreadyConnected = existingAccounts.find(a => a.email === userInfo.email) if (alreadyConnected) { // Update existing account with new tokens const { db, Collections } = await import('../services/database.mjs') await db.update(Collections.EMAIL_ACCOUNTS, alreadyConnected.$id, { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || alreadyConnected.refreshToken, expiresAt: tokens.expiry_date || 0, isActive: true, }) log.success(`Gmail aktualisiert: ${userInfo.email}`) } else { // Save new account to database await emailAccounts.create({ userId, provider: 'gmail', email: userInfo.email, accessToken: tokens.access_token, refreshToken: tokens.refresh_token || '', expiresAt: tokens.expiry_date || 0, isActive: true, }) log.success(`Gmail verbunden: ${userInfo.email}`) } res.redirect(`${config.frontendUrl}/settings?gmail=connected&email=${encodeURIComponent(userInfo.email)}`) } catch (tokenError) { log.error('Gmail OAuth Token-Fehler', { error: tokenError.message, code: tokenError.code, response: tokenError.response?.data }) // Provide more specific error messages let errorMessage = 'token_error' if (tokenError.message.includes('invalid_grant')) { errorMessage = 'invalid_grant' } else if (tokenError.message.includes('invalid_client')) { errorMessage = 'invalid_client' } res.redirect(`${config.frontendUrl}/settings?error=${errorMessage}&details=${encodeURIComponent(tokenError.message)}`) } })) /** * POST /api/oauth/gmail/refresh * Refresh Gmail access token */ router.post('/gmail/refresh', asyncHandler(async (req, res) => { const { accountId } = req.body if (!accountId) { throw new ValidationError('accountId ist erforderlich') } const account = await emailAccounts.get(accountId) if (account.provider !== 'gmail') { throw new ValidationError('Kein Gmail-Konto') } if (!account.refreshToken) { throw new AppError('Kein Refresh Token verfügbar. Konto erneut verbinden.', 400, 'NO_REFRESH_TOKEN') } const client = getGoogleClient() client.setCredentials({ refresh_token: account.refreshToken }) const { credentials } = await client.refreshAccessToken() // Update tokens const { db, Collections } = await import('../services/database.mjs') await db.update(Collections.EMAIL_ACCOUNTS, accountId, { accessToken: credentials.access_token, expiresAt: credentials.expiry_date || 0, }) respond.success(res, { accessToken: credentials.access_token, expiresAt: credentials.expiry_date, }) })) // ═══════════════════════════════════════════════════════════════════════════ // OUTLOOK OAUTH // ═══════════════════════════════════════════════════════════════════════════ /** * GET /api/oauth/outlook/connect * Initiate Outlook OAuth flow */ router.get('/outlook/connect', asyncHandler(async (req, res) => { const { userId } = req.query if (!userId) { throw new ValidationError('userId ist erforderlich') } if (!features.outlook()) { throw new AppError('Outlook OAuth ist nicht konfiguriert', 503, 'FEATURE_DISABLED') } const client = getMsalClient() const authUrl = await client.getAuthCodeUrl({ scopes: OUTLOOK_SCOPES, redirectUri: config.microsoft.redirectUri, state: JSON.stringify({ userId }), prompt: 'select_account', }) respond.success(res, { url: authUrl }) })) /** * GET /api/oauth/outlook/callback * Outlook OAuth callback */ router.get('/outlook/callback', asyncHandler(async (req, res) => { const { code, state, error, error_description } = req.query if (error) { log.warn('Outlook OAuth abgelehnt', { error, error_description }) return respond.redirect(res, `${config.frontendUrl}/settings?error=oauth_denied`) } if (!code || !state) { throw new ValidationError('Code und State sind erforderlich') } const { userId } = JSON.parse(state) const client = getMsalClient() // Exchange code for tokens const tokenResponse = await client.acquireTokenByCode({ code, scopes: OUTLOOK_SCOPES, redirectUri: config.microsoft.redirectUri, }) // Get user email from Graph API const graphResponse = await fetch('https://graph.microsoft.com/v1.0/me', { headers: { Authorization: `Bearer ${tokenResponse.accessToken}`, }, }) const userInfo = await graphResponse.json() // Save to database await emailAccounts.create({ userId, provider: 'outlook', email: userInfo.mail || userInfo.userPrincipalName, accessToken: tokenResponse.accessToken, refreshToken: '', // MSAL handles token caching differently expiresAt: tokenResponse.expiresOn ? new Date(tokenResponse.expiresOn).getTime() : 0, isActive: true, }) log.success(`Outlook verbunden: ${userInfo.mail || userInfo.userPrincipalName}`) respond.redirect(res, `${config.frontendUrl}/settings?outlook=connected`) })) /** * POST /api/oauth/outlook/refresh * Refresh Outlook access token */ router.post('/outlook/refresh', asyncHandler(async (req, res) => { const { accountId } = req.body if (!accountId) { throw new ValidationError('accountId ist erforderlich') } const account = await emailAccounts.get(accountId) if (account.provider !== 'outlook') { throw new ValidationError('Kein Outlook-Konto') } const client = getMsalClient() // MSAL handles token refresh silently via cache const tokenResponse = await client.acquireTokenSilent({ scopes: OUTLOOK_SCOPES, account: { homeAccountId: account.email, }, }).catch(async () => { // If silent refresh fails, need to re-authenticate throw new AppError('Token abgelaufen. Konto erneut verbinden.', 401, 'TOKEN_EXPIRED') }) // Update tokens const { db, Collections } = await import('../services/database.mjs') await db.update(Collections.EMAIL_ACCOUNTS, accountId, { accessToken: tokenResponse.accessToken, expiresAt: tokenResponse.expiresOn ? new Date(tokenResponse.expiresOn).getTime() : 0, }) respond.success(res, { accessToken: tokenResponse.accessToken, expiresAt: tokenResponse.expiresOn, }) })) // ═══════════════════════════════════════════════════════════════════════════ // STATUS CHECK // ═══════════════════════════════════════════════════════════════════════════ /** * GET /api/oauth/status * Check which OAuth providers are configured */ router.get('/status', (req, res) => { respond.success(res, { gmail: { enabled: features.gmail(), scopes: features.gmail() ? GMAIL_SCOPES : [], }, outlook: { enabled: features.outlook(), scopes: features.outlook() ? OUTLOOK_SCOPES : [], }, }) }) export default router