Files
Emailsorter/server/routes/oauth.mjs
ANDJ ecae89a79d fix(dev): Vite-API-Proxy, Auth, Stripe-Mails und Backend-Erweiterungen
- Client: API-Basis-URL (joinApiUrl, /v1-Falle), Vite strictPort + Proxy 127.0.0.1, Nicht-JSON-Fehler

- Server: /api-404 ohne Wildcard-Bug, SPA-Fallback, Auth-Middleware, Cron, Mailer, Crypto

- Routen: OAuth-State, Email/Stripe/Analytics; client/.env.example

Made-with: Cursor
2026-04-03 00:23:01 +02:00

404 lines
12 KiB
JavaScript

/**
* 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, AuthorizationError } 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'
import { requireAuth } from '../middleware/auth.mjs'
import { buildOAuthState, parseOAuthState } from '../utils/oauth-state.mjs'
const router = express.Router()
function requireAuthUnlessOAuthPublic(req, res, next) {
const p = req.path || ''
if (['/gmail/callback', '/outlook/callback', '/status'].includes(p)) {
return next()
}
return requireAuth(req, res, next)
}
router.use(requireAuthUnlessOAuthPublic)
// 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) => {
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: buildOAuthState(req.appwriteUser.id),
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 = parseOAuthState(state)
userId = stateData.userId
} catch (e) {
log.error('Gmail OAuth: State konnte nicht geparst werden', { state, error: e.message })
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.userId !== req.appwriteUser.id) {
throw new AuthorizationError('No permission for this account')
}
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) => {
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: buildOAuthState(req.appwriteUser.id),
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')
}
let userId
try {
userId = parseOAuthState(state).userId
} catch (e) {
log.error('Outlook OAuth: invalid state', { error: e.message })
return respond.redirect(res, `${config.frontendUrl}/settings?error=invalid_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.userId !== req.appwriteUser.id) {
throw new AuthorizationError('No permission for this account')
}
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