- 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
404 lines
12 KiB
JavaScript
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
|