import { NextRequest, NextResponse } from 'next/server'; import { cookies } from 'next/headers'; import { logger } from '@/lib/logger'; import { encryptSession, decryptSession, sessionAuthHeader, type SessionCredentials } from '@/lib/auth/crypto'; import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie'; import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { JmapAuthVerificationError, resolveJmapIdentity } 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'; import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; import { insecureCookieHint, verificationFailureBody } from '@/lib/auth/verification-failure'; import { readImpersonationConfig } from '@/lib/impersonation/master-config'; import { revokeImpersonationCredential } from '@/lib/impersonation/app-password'; import { IMPERSONATION_GRANT_COOKIE, openImpersonationGrant } from '@/lib/impersonation/grant-cookie'; import { revokeWopiTokens } from '@/lib/wopi/revocation'; import { clearPairReauthInStore } from '@/lib/auth/pair-reauth'; import { loginClientAddress } from '@/lib/auth/client-address'; import { LOGIN_BUDGET_RETRY_AFTER_SECONDS, reserveLoginAttempt } from '@/lib/auth/verify-budget'; import { isPastMaxAge, limitsEnforcedSince, maxSessionHoursFor, SESSION_TOO_OLD } from '@/lib/auth/session-age'; function sessionCookieOptions() { return { ...getCookieOptions(), maxAge: SESSION_COOKIE_MAX_AGE, }; } /** * A session sealed around the impersonation master password. Handoffs made * before impersonation switched to per-mailbox app passwords stored it in * the session cookie; it opens every mailbox, so it is never handed back to * a browser, whichever path stored it. */ function holdsMasterPassword(credentials: SessionCredentials): boolean { const config = readImpersonationConfig(); return !!config && credentials.password === config.masterPassword; } async function revokeImpersonationGrant( cookieStore: Awaited>, ): Promise { const grant = openImpersonationGrant(cookieStore.get(IMPERSONATION_GRANT_COOKIE)?.value); cookieStore.delete(IMPERSONATION_GRANT_COOKIE); const config = readImpersonationConfig(); if (!grant || !config) return; try { await revokeImpersonationCredential({ serverUrl: grant.serverUrl, mailbox: grant.mailbox, masterUser: config.masterUser, masterPassword: config.masterPassword, id: grant.credentialId, }); } catch (error) { // The app password still expires on its own; sign-out must not fail. logger.warn('Impersonation credential revocation failed', { error: error instanceof Error ? error.message : 'Unknown error', }); } } 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; } /** * The organisation's maximum session length for a remembered session * (lib/auth/session-age.ts). A cookie from before sign-in times were * recorded counts as signed in when the server first enforced the limits, * a fixed time, so a kept copy of it runs out like any other. Returns the * sign-in time, or the limit when the session is too old: its cookies are * cleared then. */ async function checkSessionAge( cookieStore: Awaited>, slot: number, credentials: SessionCredentials, ): Promise<{ signedInAt: number } | { maxHours: number }> { const signedInAt = credentials.signedInAt ?? await limitsEnforcedSince(); // An impersonation login is `target%master`; the policy is the target's. const maxHours = await maxSessionHoursFor(credentials.username.split('%', 1)[0]); if (isPastMaxAge(signedInAt, maxHours)) { cookieStore.delete(sessionCookieName(slot)); clearStalwartAuthContextInStore(cookieStore, slot); return { maxHours }; } return { signedInAt }; } function sessionTooOld(maxHours: number) { return NextResponse.json( { error: SESSION_TOO_OLD, maxHours }, { status: 401, headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' } }, ); } export async function POST(request: NextRequest) { // CSRF gate (GHSA-qvr9-m8cq-7wvg): cookies written here are SameSite=Lax, // so a cross-site top-level POST would otherwise reach this handler. const crossOrigin = rejectCrossOriginRequest(request); if (crossOrigin) return crossOrigin; let upstreamUrl = ''; let upstreamTrusted = false; 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 }); } // A password signs in with Basic auth, an access token (token login) with // Bearer: exactly one of them. const { serverUrl, username, password, token: accessToken, slot: bodySlot } = await request.json(); if (!serverUrl || !username || typeof (password || accessToken) !== 'string' || !!password === !!accessToken) { 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); 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 = accessToken ? `Bearer ${accessToken}` : `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; // Always verify the credential upstream (GHSA-wxcm-j4jc-9fxq). The cookies // written here are not only replayed as credentials (where a bogus // password would just 401 downstream): the encrypted auth context is also // accepted as proof of identity by routes that never contact the mail // server, such as settings sync. Skipping the check for admin-configured // servers let anyone mint a cookie for any username with a made-up // password. `trusted` only relaxes the public-address requirement. // Wrong credentials tried from here count against this server's address // upstream, so each client gets a failure budget (lib/auth/verify-budget.ts). const clientIp = loginClientAddress(request); const refund = reserveLoginAttempt(clientIp); if (!refund) { return NextResponse.json( { error: 'too_many_attempts', hint: 'Too many failed sign-in attempts. Try again later.' }, { status: 429, headers: { 'Retry-After': String(LOGIN_BUDGET_RETRY_AFTER_SECONDS) } }, ); } // The context cookie also records which account the credential belongs // to: a Bearer login may claim a bare local part (`john`) that several // accounts on a multi-domain server answer to, and per-user data such as // synced settings must be keyed on the account, not on the claim. let normalizedServerUrl: string; let accountName: string; try { ({ serverUrl: normalizedServerUrl, accountName } = await resolveJmapIdentity(upstreamUrl, authHeader, username, { trusted: upstreamTrusted, clientIp, })); } catch (error) { // Only a credential the server rejected spends the budget; an outage // or a mismatched claim says nothing about the password. if (!(error instanceof JmapAuthVerificationError && error.status === 401)) refund(); throw error; } refund(); // A remembered sign-in starts the maximum session length. const signedInAt = Date.now(); const token = encryptSession(normalizedServerUrl, username, accessToken ? { token: accessToken } : password, signedInAt); const cookieStore = await cookies(); cookieStore.set(cookieName, token, sessionCookieOptions()); setStalwartAuthContextInStore(cookieStore, slot, { serverUrl: normalizedServerUrl, username, authHeader, ...(accountName !== username ? { accountName } : {}), }); void recordLogin(username, normalizedServerUrl); const warning = insecureCookieHint(request); if (warning) logger.warn(`session: ${warning}`); // The browser counts the maximum session length from the server's time. return NextResponse.json(warning ? { ok: true, warning, signedInAt } : { ok: true, signedInAt }); } catch (error) { if (error instanceof JmapAuthVerificationError) { return NextResponse.json( verificationFailureBody('session', error, upstreamUrl, upstreamTrusted), { 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 || holdsMasterPassword(credentials)) { cookieStore.delete(cookieName); clearStalwartAuthContextInStore(cookieStore, slot); return NextResponse.json({ error: 'Invalid session' }, { status: 401 }); } const age = await checkSessionAge(cookieStore, slot, credentials); if ('maxHours' in age) return sessionTooOld(age.maxHours); setStalwartAuthContextInStore(cookieStore, slot, { serverUrl: credentials.serverUrl, username: credentials.username, authHeader: sessionAuthHeader(credentials), }); // Only return non-sensitive fields. Use PUT to retrieve full credentials. const { serverUrl, username } = credentials; return NextResponse.json( { serverUrl, username, signedInAt: age.signedInAt }, { 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 (password or access token) 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 || holdsMasterPassword(credentials)) { cookieStore.delete(cookieName); clearStalwartAuthContextInStore(cookieStore, slot); return NextResponse.json({ error: 'Invalid session' }, { status: 401 }); } const age = await checkSessionAge(cookieStore, slot, credentials); if ('maxHours' in age) return sessionTooOld(age.maxHours); setStalwartAuthContextInStore(cookieStore, slot, { serverUrl: credentials.serverUrl, username: credentials.username, authHeader: sessionAuthHeader(credentials), }); // The browser counts the maximum session length from the server's time. return NextResponse.json({ ...credentials, signedInAt: age.signedInAt }, { 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) { // CSRF gate (GHSA-qvr9-m8cq-7wvg): cookies written here are SameSite=Lax, // so a cross-site top-level POST would otherwise reach this handler. const crossOrigin = rejectCrossOriginRequest(request); if (crossOrigin) return crossOrigin; try { const cookieStore = await cookies(); const all = request.nextUrl.searchParams.get('all') === 'true'; // Office editors opened from here stop working with the session. await revokeWopiTokens(cookieStore, all ? 'all' : getSlot(request)); 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); } await revokeImpersonationGrant(cookieStore); // A phone that already paired keeps its own sign-in; a QR still on // screen stops working. clearPairReauthInStore(cookieStore); } else { const slot = getSlot(request); cookieStore.delete(sessionCookieName(slot)); clearStalwartAuthContextInStore(cookieStore, slot); if (slot === 0) await revokeImpersonationGrant(cookieStore); clearPairReauthInStore(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 }); } }