import { NextRequest, NextResponse } from 'next/server'; import { cookies } from 'next/headers'; import { logger } from '@/lib/logger'; import { encryptSession, decryptSession } from '@/lib/auth/crypto'; import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie'; import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { JmapAuthVerificationError, normalizeJmapServerUrl, validateProxyAuthHeader, verifyJmapAuth, } from '@/lib/auth/verify-jmap-auth'; import { clearStalwartAuthContextInStore, setStalwartAuthContextInStore, } from '@/lib/stalwart/auth-context'; import { configManager } from '@/lib/admin/config-manager'; import { isPublicHttpUrl } from '@/lib/security/url-guard'; import { recordLogin } from '@/lib/telemetry/login-tracker'; import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers'; import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; function sessionCookieOptions() { return { ...getCookieOptions(), maxAge: SESSION_COOKIE_MAX_AGE, }; } function getSlot(request: NextRequest): number { const raw = request.nextUrl.searchParams.get('slot'); if (raw === null) return 0; const slot = parseInt(raw, 10); if (isNaN(slot) || slot < 0 || slot >= MAX_ACCOUNT_SLOTS) return 0; return slot; } export async function POST(request: NextRequest) { try { const oauthEnabled = configManager.get('oauthEnabled', false); const oauthOnly = configManager.get('oauthOnly', false); if (oauthEnabled && oauthOnly) { return NextResponse.json({ error: 'Basic authentication is disabled' }, { status: 403 }); } const { serverUrl, username, password, slot: bodySlot } = await request.json(); if (!serverUrl || !username || !password) { return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); } // Pin the upstream URL to a configured JMAP server so an unauthenticated // caller cannot point this route at internal hosts. We accept the global // `jmapServerUrl` and any entry from `jmapServers`. When neither matches, // we fall back to the request URL only if `allowCustomJmapEndpoint` is on // - and even then the URL must resolve to a public address. await configManager.ensureLoaded(); const configuredServerUrl = configManager.get('jmapServerUrl', '') || process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || ''; const allowCustomEndpoint = configManager.get('allowCustomJmapEndpoint', false); const serverList = parseJmapServers(configManager.get('jmapServers', [])); const trustedUrl = resolveTrustedJmapUrl(serverUrl, configuredServerUrl, serverList); let upstreamUrl: string; let upstreamTrusted: boolean; if (trustedUrl) { upstreamUrl = trustedUrl; upstreamTrusted = true; } else if (allowCustomEndpoint) { if (!(await isPublicHttpUrl(serverUrl))) { return NextResponse.json({ error: 'Server URL is not allowed' }, { status: 400 }); } upstreamUrl = serverUrl; upstreamTrusted = false; } else { return NextResponse.json({ error: 'JMAP server not configured' }, { status: 500 }); } const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : getSlot(request); const cookieName = sessionCookieName(slot); const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; // Trusted (admin-configured) URLs skip the upstream re-fetch: the cookie // we write here is only ever consumed for requests on behalf of this same // user, so bogus credentials would just yield 401s downstream rather than // privilege escalation. Untrusted custom endpoints still verify upstream. const normalizedServerUrl = upstreamTrusted ? (validateProxyAuthHeader(authHeader), normalizeJmapServerUrl(upstreamUrl)) : await verifyJmapAuth(upstreamUrl, authHeader, { trusted: false }); const token = encryptSession(normalizedServerUrl, username, password); const cookieStore = await cookies(); cookieStore.set(cookieName, token, sessionCookieOptions()); setStalwartAuthContextInStore(cookieStore, slot, { serverUrl: normalizedServerUrl, username, authHeader, }); void recordLogin(username, normalizedServerUrl); return NextResponse.json({ ok: true }); } catch (error) { if (error instanceof JmapAuthVerificationError) { return NextResponse.json({ error: error.message }, { status: error.status }); } logger.error('Session store error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } } export async function GET(request: NextRequest) { try { const slot = getSlot(request); const cookieName = sessionCookieName(slot); const cookieStore = await cookies(); const token = cookieStore.get(cookieName)?.value; if (!token) { return NextResponse.json({ error: 'No session' }, { status: 401 }); } const credentials = decryptSession(token); if (!credentials) { cookieStore.delete(cookieName); clearStalwartAuthContextInStore(cookieStore, slot); return NextResponse.json({ error: 'Invalid session' }, { status: 401 }); } setStalwartAuthContextInStore(cookieStore, slot, { serverUrl: credentials.serverUrl, username: credentials.username, authHeader: `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`, }); // Only return non-sensitive fields. Use PUT to retrieve full credentials. const { serverUrl, username } = credentials; return NextResponse.json( { serverUrl, username }, { headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' } }, ); } catch (error) { logger.error('Session read error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } } /** * PUT - retrieve full credentials (including password) for session restoration. * Protected by multiple Sec-Fetch-* headers to ensure only same-origin * browser fetch() requests succeed. Non-browser clients cannot forge these. */ export async function PUT(request: NextRequest) { try { // Require all Sec-Fetch-* headers to match a same-origin fetch() call. // Browsers set these automatically and they cannot be overridden by JS. const secFetchSite = request.headers.get('sec-fetch-site'); const secFetchMode = request.headers.get('sec-fetch-mode'); const secFetchDest = request.headers.get('sec-fetch-dest'); if (secFetchSite !== 'same-origin' || secFetchMode !== 'cors' || secFetchDest !== 'empty') { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } const slot = getSlot(request); const cookieName = sessionCookieName(slot); const cookieStore = await cookies(); const token = cookieStore.get(cookieName)?.value; if (!token) { return NextResponse.json({ error: 'No session' }, { status: 401 }); } const credentials = decryptSession(token); if (!credentials) { cookieStore.delete(cookieName); clearStalwartAuthContextInStore(cookieStore, slot); return NextResponse.json({ error: 'Invalid session' }, { status: 401 }); } setStalwartAuthContextInStore(cookieStore, slot, { serverUrl: credentials.serverUrl, username: credentials.username, authHeader: `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`, }); return NextResponse.json(credentials, { headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' }, }); } catch (error) { logger.error('Session read error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } } export async function DELETE(request: NextRequest) { try { const cookieStore = await cookies(); const all = request.nextUrl.searchParams.get('all') === 'true'; if (all) { // Delete all session cookies across every slot. for (let i = 0; i < MAX_ACCOUNT_SLOTS; i++) { cookieStore.delete(sessionCookieName(i)); clearStalwartAuthContextInStore(cookieStore, i); } } else { const slot = getSlot(request); cookieStore.delete(sessionCookieName(slot)); clearStalwartAuthContextInStore(cookieStore, slot); } return NextResponse.json({ ok: true }); } catch (error) { logger.error('Session clear error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } }