import { NextRequest, NextResponse } from 'next/server'; import { cookies } from 'next/headers'; import { logger } from '@/lib/logger'; import { encryptSession } from '@/lib/auth/crypto'; import { sessionCookieName } from '@/lib/auth/session-cookie'; import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { normalizeJmapServerUrl } from '@/lib/auth/verify-jmap-auth'; import { setStalwartAuthContextInStore } from '@/lib/stalwart/auth-context'; import { recordLogin } from '@/lib/telemetry/login-tracker'; import { ImpersonationJwtError, impersonationReplayCache, verifyImpersonationJwt, } from '@/lib/impersonation/jwt'; import { readImpersonationConfig, resolveImpersonationServerUrl, } from '@/lib/impersonation/master-config'; export const runtime = 'nodejs'; const IMPERSONATION_SLOT = 0; /** * Impersonation cookies deliberately omit Max-Age so the browser treats * them as session cookies - the impersonated session ends when the user * closes the browser, not 30 days later. Impersonation is a temporary * support handoff; a normal password login is the only thing that should * survive a browser restart. */ function impersonationCookieOptions() { const { maxAge: _maxAge, ...rest } = getCookieOptions(); return rest; } /** * GET /api/auth/impersonate?token= * * Master-user impersonation via signed JWT. The token carries the target * mailbox; Bulwark verifies the signature, resolves the configured Stalwart * master credentials from env, then mints the same session cookies the * password-login path produces. The browser is redirected to "/" and the * SPA hydrates as if the user had just logged in with master@target%master. * * Returns 404 when the feature is not configured so an unconfigured * deployment does not advertise the endpoint. */ export async function GET(request: NextRequest) { const config = readImpersonationConfig(); if (!config) { // Not configured - behave exactly like an unknown route. return new NextResponse('Not found', { status: 404 }); } const token = request.nextUrl.searchParams.get('token'); if (!token) { return NextResponse.json({ error: 'Missing token' }, { status: 400 }); } let claims; try { claims = verifyImpersonationJwt(token, config.jwtSecret, { expectedIssuer: config.expectedIssuer, }); } catch (err) { if (err instanceof ImpersonationJwtError) { logger.warn('Impersonation JWT rejected', { code: err.code }); return NextResponse.json({ error: err.message }, { status: err.status }); } logger.error('Impersonation JWT error', { error: err instanceof Error ? err.message : 'Unknown', }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } if (!impersonationReplayCache.consume(claims.jti, claims.exp)) { logger.warn('Impersonation JWT replay rejected', { jti: claims.jti }); return NextResponse.json({ error: 'Token already used' }, { status: 401 }); } const serverUrl = await resolveImpersonationServerUrl(); if (!serverUrl) { logger.error('Impersonation requested but jmapServerUrl is not configured'); return NextResponse.json({ error: 'JMAP server not configured' }, { status: 500 }); } let normalizedServerUrl: string; try { normalizedServerUrl = normalizeJmapServerUrl(serverUrl); } catch { return NextResponse.json({ error: 'Invalid JMAP server URL' }, { status: 500 }); } // Stalwart master-user impersonation: username = "%", // password = . Per Stalwart docs: // https://stalw.art/docs/auth/authorization/administrator/ const impersonatedUsername = `${claims.mailbox}%${config.masterUser}`; const authHeader = `Basic ${Buffer.from( `${impersonatedUsername}:${config.masterPassword}`, ).toString('base64')}`; const cookieStore = await cookies(); const sessionToken = encryptSession( normalizedServerUrl, impersonatedUsername, config.masterPassword, ); cookieStore.set(sessionCookieName(IMPERSONATION_SLOT), sessionToken, impersonationCookieOptions()); setStalwartAuthContextInStore(cookieStore, IMPERSONATION_SLOT, { serverUrl: normalizedServerUrl, username: impersonatedUsername, authHeader, }); // Structured audit log - operators rely on this for security review. logger.info('Impersonation session granted', { event: 'impersonation_granted', jti: claims.jti, mailbox: claims.mailbox, tenant_id: claims.tenant_id, actor_user_id: claims.actor_user_id, iss: claims.iss, ip: request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || request.headers.get('x-real-ip') || null, referer: request.headers.get('referer'), user_agent: request.headers.get('user-agent'), }); void recordLogin(impersonatedUsername, normalizedServerUrl); // Use a relative Location header so the browser resolves it against the // public request URL. NextResponse.redirect(new URL('/', request.url)) // would absolutise to the container's internal bind (http://0.0.0.0:3000) // when running behind a reverse proxy that doesn't set X-Forwarded-Host. return new NextResponse(null, { status: 303, headers: { Location: '/' }, }); }