From 2d7e24b5132772da99c1600a4f98733aa55a9f70 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 11 May 2026 11:04:13 +0200 Subject: [PATCH] perf: parallelize login round-trips and drop redundant JMAP re-verify --- app/api/auth/stalwart-context/route.ts | 12 +++- stores/auth-store.ts | 80 ++++++++++++++------------ 2 files changed, 54 insertions(+), 38 deletions(-) diff --git a/app/api/auth/stalwart-context/route.ts b/app/api/auth/stalwart-context/route.ts index 7894a255..c1a4c910 100644 --- a/app/api/auth/stalwart-context/route.ts +++ b/app/api/auth/stalwart-context/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { logger } from '@/lib/logger'; -import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth'; +import { JmapAuthVerificationError, normalizeJmapServerUrl, validateProxyAuthHeader, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth'; import { setStalwartAuthContext } from '@/lib/stalwart/auth-context'; import { configManager } from '@/lib/admin/config-manager'; import { isPublicHttpUrl } from '@/lib/security/url-guard'; @@ -57,7 +57,15 @@ export async function POST(request: NextRequest) { } const slot = getSlot(request, bodySlot); - const normalizedServerUrl = await verifyJmapAuth(upstreamUrl, authHeader, { trusted: upstreamTrusted }); + // Trusted (admin-configured) URLs skip the upstream re-fetch: the caller + // just authenticated to JMAP with these credentials, and the cookie we + // write here is only ever consumed for requests on behalf of this same + // user — a bogus auth header would just yield 401s downstream, not + // privilege escalation. For untrusted custom endpoints we still verify + // upstream as before. + const normalizedServerUrl = upstreamTrusted + ? (validateProxyAuthHeader(authHeader), normalizeJmapServerUrl(upstreamUrl)) + : await verifyJmapAuth(upstreamUrl, authHeader, { trusted: false }); await setStalwartAuthContext(slot, { serverUrl: normalizedServerUrl, diff --git a/stores/auth-store.ts b/stores/auth-store.ts index df65b1a7..f6490b03 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -377,27 +377,30 @@ export const useAuthStore = create()( const client = new JMAPClient(serverUrl, username, effectivePassword); await client.connect(); - const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), username); - initializeFeatureStores(client); - - // Register in account store + // Resolve account/slot info up front so writes can start immediately. const accountStore = useAccountStore.getState(); const accountId = generateAccountId(username, serverUrl); const cookieSlot = accountStore.hasAccount(username, serverUrl) ? (accountStore.getAccountById(accountId)?.cookieSlot ?? accountStore.getNextCookieSlot()) : accountStore.getNextCookieSlot(); - // Snapshot current account if switching away and clear stores so - // the new account starts with a clean email/contact/calendar state. + // Snapshot/clear before kicking off any feature-store fetches so they + // don't write into stores we're about to wipe. const prevAccountId = get().activeAccountId; if (prevAccountId && prevAccountId !== accountId) { snapshotAccount(prevAccountId); clearAllStores(); } + // Identities can fly in parallel with everything below. JMAPClient + // captures the auth header per-request, so the optional TOTP upgrade + // doesn't affect this already-issued request. + const identitiesPromise = client.getIdentities(); + // When TOTP was used, try to upgrade to token-based auth so the // session survives TOTP rotation (basic auth embeds the TOTP in - // every request, which expires after ~30 seconds). + // every request, which expires after ~30 seconds). Must complete + // before stalwart-context reads the auth header. let upgradedToOAuth = false; let oauthAccessToken: string | null = null; let oauthExpiresIn = 0; @@ -439,6 +442,29 @@ export const useAuthStore = create()( const effectiveAuthMode = upgradedToOAuth ? 'oauth' : 'basic'; + // Run the remaining independent requests in parallel. The session + // write and stalwart-context write are best-effort persistence; the + // outer login still succeeds even if they log a warning. Errors are + // caught locally so Promise.all doesn't reject on either. + const sessionWrite: Promise = (rememberMe && !upgradedToOAuth) + ? apiFetch(`/api/auth/session?slot=${cookieSlot}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ serverUrl, username, password: effectivePassword, slot: cookieSlot }), + }).then((res) => { + if (!res.ok) debug.error('Failed to store session: server returned', res.status); + }).catch((err) => debug.error('Failed to store session:', err)) + : Promise.resolve(); + + const [rawIdentities] = await Promise.all([ + identitiesPromise, + sessionWrite, + syncStalwartAuthContext(serverUrl, username, client.getAuthHeader(), cookieSlot), + ]); + + const { identities, primaryIdentity } = loadIdentities(rawIdentities, username); + initializeFeatureStores(client); + // Store client in multi-account map clients.set(accountId, client); bindClientStatusHandlers(client, set, get, accountId); @@ -468,27 +494,6 @@ export const useAuthStore = create()( lastLoginAt: Date.now(), }); - // Store session cookie BEFORE setting isAuthenticated to avoid a race - // condition: setting isAuthenticated triggers navigation to the main page, - // whose checkAuth() would try to read the cookie before it was stored. - if (rememberMe && !upgradedToOAuth) { - // For basic auth (no TOTP or TOTP upgrade failed), store encrypted credentials - try { - const res = await apiFetch(`/api/auth/session?slot=${cookieSlot}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ serverUrl, username, password: effectivePassword, slot: cookieSlot }), - }); - if (!res.ok) { - debug.error('Failed to store session: server returned', res.status); - } - } catch (err) { - debug.error('Failed to store session:', err); - } - } - - await syncStalwartAuthContext(serverUrl, username, client.getAuthHeader(), cookieSlot); - set({ isAuthenticated: true, isLoading: false, @@ -750,12 +755,17 @@ export const useAuthStore = create()( const accountStore = useAccountStore.getState(); const slot = accountStore.getNextCookieSlot(); - const ssoRes = await apiFetch('/api/auth/sso/complete', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ code, state, slot }), - }); + // SSO token exchange and config fetch are independent — fire both + // up front and let them resolve in parallel. + const [ssoRes, config] = await Promise.all([ + apiFetch('/api/auth/sso/complete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ code, state, slot }), + }), + fetchConfig(), + ]); if (!ssoRes.ok) { const errorData = await ssoRes.json().catch(() => ({ error: 'token_exchange_failed' })); @@ -764,8 +774,6 @@ export const useAuthStore = create()( const { access_token, expires_in } = await ssoRes.json(); - // We need the server URL from config - const config = await fetchConfig(); const ssoServerUrl = config.jmapServerUrl; if (!ssoServerUrl) {