From 85b5b3c4f1cf6c963a50e8f021bc41b3f0358659 Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Sat, 14 Mar 2026 15:55:59 +0100 Subject: [PATCH] feat: refactor authentication handling to use centralized Stalwart credentials management --- .env.example | 5 ++ app/api/account/stalwart/auth/route.ts | 38 ++---------- app/api/account/stalwart/crypto/route.ts | 32 ++-------- app/api/account/stalwart/password/route.ts | 29 ++------- app/api/account/stalwart/principal/route.ts | 32 ++-------- app/api/account/stalwart/probe/route.ts | 27 +-------- lib/stalwart/credentials.ts | 67 +++++++++++++++++++++ 7 files changed, 94 insertions(+), 136 deletions(-) create mode 100644 lib/stalwart/credentials.ts diff --git a/.env.example b/.env.example index 31ee2b4b..08b7ea09 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,11 @@ JMAP_SERVER_URL=https://your-jmap-server.com # Set to "false" to disable if using a non-Stalwart JMAP server. # STALWART_FEATURES=true +# If your reverse proxy doesn't forward Stalwart management API paths +# (/api/account/*, /api/principal/*), set this to the URL where Stalwart's +# HTTP listener is directly reachable. Defaults to JMAP_SERVER_URL if not set. +# STALWART_API_URL=https://admin.example.com + # ============================================================================= # OAuth / OpenID Connect (optional) # ============================================================================= diff --git a/app/api/account/stalwart/auth/route.ts b/app/api/account/stalwart/auth/route.ts index 077f3bef..d1ee34a3 100644 --- a/app/api/account/stalwart/auth/route.ts +++ b/app/api/account/stalwart/auth/route.ts @@ -1,34 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; -import { cookies } from 'next/headers'; import { logger } from '@/lib/logger'; -import { decryptSession } from '@/lib/auth/crypto'; -import { SESSION_COOKIE } from '@/lib/auth/session-cookie'; - -/** - * Extract the user's JMAP server URL and auth header from the session cookie - * or from the Authorization header passed by the client. - */ -async function getCredentials(request: NextRequest): Promise<{ serverUrl: string; authHeader: string; username: string } | null> { - // Try Authorization header first (for bearer/basic auth forwarding) - const authHeader = request.headers.get('Authorization'); - const serverUrl = request.headers.get('X-JMAP-Server-URL'); - const username = request.headers.get('X-JMAP-Username'); - - if (authHeader && serverUrl && username) { - return { serverUrl, authHeader, username }; - } - - // Fall back to session cookie - const cookieStore = await cookies(); - const token = cookieStore.get(SESSION_COOKIE)?.value; - if (!token) return null; - - const credentials = decryptSession(token); - if (!credentials) return null; - - const basic = `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`; - return { serverUrl: credentials.serverUrl, authHeader: basic, username: credentials.username }; -} +import { getStalwartCredentials } from '@/lib/stalwart/credentials'; /** * GET /api/account/stalwart/auth @@ -36,12 +8,12 @@ async function getCredentials(request: NextRequest): Promise<{ serverUrl: string */ export async function GET(request: NextRequest) { try { - const creds = await getCredentials(request); + const creds = await getStalwartCredentials(request); if (!creds) { return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); } - const response = await fetch(`${creds.serverUrl}/api/account/auth`, { + const response = await fetch(`${creds.apiUrl}/api/account/auth`, { method: 'GET', headers: { 'Authorization': creds.authHeader }, }); @@ -69,14 +41,14 @@ export async function GET(request: NextRequest) { */ export async function POST(request: NextRequest) { try { - const creds = await getCredentials(request); + const creds = await getStalwartCredentials(request); if (!creds) { return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); } const body = await request.json(); - const response = await fetch(`${creds.serverUrl}/api/account/auth`, { + const response = await fetch(`${creds.apiUrl}/api/account/auth`, { method: 'POST', headers: { 'Authorization': creds.authHeader, diff --git a/app/api/account/stalwart/crypto/route.ts b/app/api/account/stalwart/crypto/route.ts index c4377d3e..6c5f3502 100644 --- a/app/api/account/stalwart/crypto/route.ts +++ b/app/api/account/stalwart/crypto/route.ts @@ -1,28 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; -import { cookies } from 'next/headers'; import { logger } from '@/lib/logger'; -import { decryptSession } from '@/lib/auth/crypto'; -import { SESSION_COOKIE } from '@/lib/auth/session-cookie'; - -async function getCredentials(request: NextRequest): Promise<{ serverUrl: string; authHeader: string; username: string } | null> { - const authHeader = request.headers.get('Authorization'); - const serverUrl = request.headers.get('X-JMAP-Server-URL'); - const username = request.headers.get('X-JMAP-Username'); - - if (authHeader && serverUrl && username) { - return { serverUrl, authHeader, username }; - } - - const cookieStore = await cookies(); - const token = cookieStore.get(SESSION_COOKIE)?.value; - if (!token) return null; - - const credentials = decryptSession(token); - if (!credentials) return null; - - const basic = `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`; - return { serverUrl: credentials.serverUrl, authHeader: basic, username: credentials.username }; -} +import { getStalwartCredentials } from '@/lib/stalwart/credentials'; /** * GET /api/account/stalwart/crypto @@ -30,12 +8,12 @@ async function getCredentials(request: NextRequest): Promise<{ serverUrl: string */ export async function GET(request: NextRequest) { try { - const creds = await getCredentials(request); + const creds = await getStalwartCredentials(request); if (!creds) { return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); } - const response = await fetch(`${creds.serverUrl}/api/account/crypto`, { + const response = await fetch(`${creds.apiUrl}/api/account/crypto`, { method: 'GET', headers: { 'Authorization': creds.authHeader }, }); @@ -63,14 +41,14 @@ export async function GET(request: NextRequest) { */ export async function POST(request: NextRequest) { try { - const creds = await getCredentials(request); + const creds = await getStalwartCredentials(request); if (!creds) { return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); } const body = await request.json(); - const response = await fetch(`${creds.serverUrl}/api/account/crypto`, { + const response = await fetch(`${creds.apiUrl}/api/account/crypto`, { method: 'POST', headers: { 'Authorization': creds.authHeader, diff --git a/app/api/account/stalwart/password/route.ts b/app/api/account/stalwart/password/route.ts index ec87cad5..795a8d3a 100644 --- a/app/api/account/stalwart/password/route.ts +++ b/app/api/account/stalwart/password/route.ts @@ -1,8 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; import { cookies } from 'next/headers'; import { logger } from '@/lib/logger'; -import { decryptSession, encryptSession } from '@/lib/auth/crypto'; +import { encryptSession } from '@/lib/auth/crypto'; import { SESSION_COOKIE, SESSION_COOKIE_MAX_AGE } from '@/lib/auth/session-cookie'; +import { getStalwartCredentials } from '@/lib/stalwart/credentials'; const COOKIE_OPTIONS = { httpOnly: true, @@ -12,28 +13,6 @@ const COOKIE_OPTIONS = { maxAge: SESSION_COOKIE_MAX_AGE, }; -async function getCredentials(request: NextRequest): Promise<{ serverUrl: string; authHeader: string; username: string; hasSessionCookie: boolean } | null> { - const authHeader = request.headers.get('Authorization'); - const serverUrl = request.headers.get('X-JMAP-Server-URL'); - const username = request.headers.get('X-JMAP-Username'); - - if (authHeader && serverUrl && username) { - const cookieStore = await cookies(); - const hasSessionCookie = !!cookieStore.get(SESSION_COOKIE)?.value; - return { serverUrl, authHeader, username, hasSessionCookie }; - } - - const cookieStore = await cookies(); - const token = cookieStore.get(SESSION_COOKIE)?.value; - if (!token) return null; - - const credentials = decryptSession(token); - if (!credentials) return null; - - const basic = `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`; - return { serverUrl: credentials.serverUrl, authHeader: basic, username: credentials.username, hasSessionCookie: true }; -} - /** * POST /api/account/stalwart/password * Change user password via Stalwart PATCH /api/principal/{name} @@ -42,7 +21,7 @@ async function getCredentials(request: NextRequest): Promise<{ serverUrl: string */ export async function POST(request: NextRequest) { try { - const creds = await getCredentials(request); + const creds = await getStalwartCredentials(request); if (!creds) { return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); } @@ -69,7 +48,7 @@ export async function POST(request: NextRequest) { } // Change password via Stalwart principal API - const response = await fetch(`${creds.serverUrl}/api/principal/${encodeURIComponent(creds.username)}`, { + const response = await fetch(`${creds.apiUrl}/api/principal/${encodeURIComponent(creds.username)}`, { method: 'PATCH', headers: { 'Authorization': creds.authHeader, diff --git a/app/api/account/stalwart/principal/route.ts b/app/api/account/stalwart/principal/route.ts index 62bf6a92..5b7131cb 100644 --- a/app/api/account/stalwart/principal/route.ts +++ b/app/api/account/stalwart/principal/route.ts @@ -1,28 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; -import { cookies } from 'next/headers'; import { logger } from '@/lib/logger'; -import { decryptSession } from '@/lib/auth/crypto'; -import { SESSION_COOKIE } from '@/lib/auth/session-cookie'; - -async function getCredentials(request: NextRequest): Promise<{ serverUrl: string; authHeader: string; username: string } | null> { - const authHeader = request.headers.get('Authorization'); - const serverUrl = request.headers.get('X-JMAP-Server-URL'); - const username = request.headers.get('X-JMAP-Username'); - - if (authHeader && serverUrl && username) { - return { serverUrl, authHeader, username }; - } - - const cookieStore = await cookies(); - const token = cookieStore.get(SESSION_COOKIE)?.value; - if (!token) return null; - - const credentials = decryptSession(token); - if (!credentials) return null; - - const basic = `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`; - return { serverUrl: credentials.serverUrl, authHeader: basic, username: credentials.username }; -} +import { getStalwartCredentials } from '@/lib/stalwart/credentials'; /** * GET /api/account/stalwart/principal @@ -30,12 +8,12 @@ async function getCredentials(request: NextRequest): Promise<{ serverUrl: string */ export async function GET(request: NextRequest) { try { - const creds = await getCredentials(request); + const creds = await getStalwartCredentials(request); if (!creds) { return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); } - const response = await fetch(`${creds.serverUrl}/api/principal/${encodeURIComponent(creds.username)}`, { + const response = await fetch(`${creds.apiUrl}/api/principal/${encodeURIComponent(creds.username)}`, { method: 'GET', headers: { 'Authorization': creds.authHeader }, }); @@ -64,7 +42,7 @@ export async function GET(request: NextRequest) { */ export async function PATCH(request: NextRequest) { try { - const creds = await getCredentials(request); + const creds = await getStalwartCredentials(request); if (!creds) { return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); } @@ -79,7 +57,7 @@ export async function PATCH(request: NextRequest) { } } - const response = await fetch(`${creds.serverUrl}/api/principal/${encodeURIComponent(creds.username)}`, { + const response = await fetch(`${creds.apiUrl}/api/principal/${encodeURIComponent(creds.username)}`, { method: 'PATCH', headers: { 'Authorization': creds.authHeader, diff --git a/app/api/account/stalwart/probe/route.ts b/app/api/account/stalwart/probe/route.ts index 8dff3fbb..e7dbe525 100644 --- a/app/api/account/stalwart/probe/route.ts +++ b/app/api/account/stalwart/probe/route.ts @@ -1,27 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; -import { cookies } from 'next/headers'; import { logger } from '@/lib/logger'; -import { decryptSession } from '@/lib/auth/crypto'; -import { SESSION_COOKIE } from '@/lib/auth/session-cookie'; - -async function getCredentials(request: NextRequest): Promise<{ serverUrl: string; authHeader: string } | null> { - const authHeader = request.headers.get('Authorization'); - const serverUrl = request.headers.get('X-JMAP-Server-URL'); - - if (authHeader && serverUrl) { - return { serverUrl, authHeader }; - } - - const cookieStore = await cookies(); - const token = cookieStore.get(SESSION_COOKIE)?.value; - if (!token) return null; - - const credentials = decryptSession(token); - if (!credentials) return null; - - const basic = `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`; - return { serverUrl: credentials.serverUrl, authHeader: basic }; -} +import { getStalwartCredentials } from '@/lib/stalwart/credentials'; /** * GET /api/account/stalwart/probe @@ -29,7 +8,7 @@ async function getCredentials(request: NextRequest): Promise<{ serverUrl: string */ export async function GET(request: NextRequest) { try { - const creds = await getCredentials(request); + const creds = await getStalwartCredentials(request); if (!creds) { return NextResponse.json({ isStalwart: false }); } @@ -38,7 +17,7 @@ export async function GET(request: NextRequest) { const timeout = setTimeout(() => controller.abort(), 5000); try { - const response = await fetch(`${creds.serverUrl}/api/account/auth`, { + const response = await fetch(`${creds.apiUrl}/api/account/auth`, { method: 'GET', headers: { 'Authorization': creds.authHeader }, signal: controller.signal, diff --git a/lib/stalwart/credentials.ts b/lib/stalwart/credentials.ts new file mode 100644 index 00000000..e17913f9 --- /dev/null +++ b/lib/stalwart/credentials.ts @@ -0,0 +1,67 @@ +import { cookies } from 'next/headers'; +import { NextRequest } from 'next/server'; +import { decryptSession } from '@/lib/auth/crypto'; +import { SESSION_COOKIE } from '@/lib/auth/session-cookie'; + +export interface StalwartCredentials { + /** URL for Stalwart management API calls (uses STALWART_API_URL if set, otherwise serverUrl) */ + apiUrl: string; + /** URL of the JMAP server (for JMAP operations like password verification) */ + serverUrl: string; + authHeader: string; + username: string; + hasSessionCookie: boolean; +} + +/** + * Resolve the base URL for Stalwart management API requests. + * + * When the JMAP server sits behind a reverse proxy that only forwards + * JMAP paths, the `/api/account/*` and `/api/principal/*` management + * endpoints may not be exposed. In that case, operators can set + * `STALWART_API_URL` to point directly at the Stalwart HTTP listener + * (e.g. `https://admin.example.com`). + */ +function getStalwartApiUrl(jmapServerUrl: string): string { + return process.env.STALWART_API_URL || jmapServerUrl; +} + +/** + * Extract credentials from the incoming request. + * + * Tries the explicit headers first (`Authorization`, `X-JMAP-Server-URL`, + * `X-JMAP-Username`), then falls back to the encrypted session cookie. + */ +export async function getStalwartCredentials(request: NextRequest): Promise { + const authHeader = request.headers.get('Authorization'); + const serverUrl = request.headers.get('X-JMAP-Server-URL'); + const username = request.headers.get('X-JMAP-Username'); + + if (authHeader && serverUrl && username) { + const cookieStore = await cookies(); + const hasSessionCookie = !!cookieStore.get(SESSION_COOKIE)?.value; + return { + apiUrl: getStalwartApiUrl(serverUrl), + serverUrl, + authHeader, + username, + hasSessionCookie, + }; + } + + const cookieStore = await cookies(); + const token = cookieStore.get(SESSION_COOKIE)?.value; + if (!token) return null; + + const credentials = decryptSession(token); + if (!credentials) return null; + + const basic = `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`; + return { + apiUrl: getStalwartApiUrl(credentials.serverUrl), + serverUrl: credentials.serverUrl, + authHeader: basic, + username: credentials.username, + hasSessionCookie: true, + }; +}