From 67210c99247697eea413653458835de965b7475f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 30 Mar 2026 16:37:07 +0200 Subject: [PATCH] fix: handle 2FA/TOTP session expiry with basic auth #117 --- app/[locale]/login/page.tsx | 13 +- app/[locale]/page.tsx | 2 + app/api/auth/totp-token-exchange/route.ts | 188 ++++++++++++++++++++++ components/totp-reauth-dialog.tsx | 107 ++++++++++++ lib/demo/demo-client.ts | 3 + lib/jmap/client-interface.ts | 3 + lib/jmap/client.ts | 51 +++++- locales/en/common.json | 1 + stores/auth-store.ts | 59 ++++++- stores/totp-reauth-store.ts | 48 ++++++ 10 files changed, 468 insertions(+), 7 deletions(-) create mode 100644 app/api/auth/totp-token-exchange/route.ts create mode 100644 components/totp-reauth-dialog.tsx create mode 100644 stores/totp-reauth-store.ts diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx index 7ea19c32..3c9167d7 100644 --- a/app/[locale]/login/page.tsx +++ b/app/[locale]/login/page.tsx @@ -98,6 +98,14 @@ export default function LoginPage() { prevError.current = error; }, [error]); + // Auto-show and focus TOTP field when server requires it + useEffect(() => { + if (error === 'totp_required') { + setShowTotpField(true); + setTimeout(() => totpInputRef.current?.focus(), 100); + } + }, [error]); + useEffect(() => { if (!serverUrl) return; const saved = localStorage.getItem("webmail_usernames"); @@ -890,7 +898,10 @@ export default function LoginPage() { maxLength={6} value={totpCode} onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))} - className="h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200 text-center font-mono tracking-widest" + className={cn( + "h-11 px-3.5 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200 text-center font-mono tracking-widest", + error === 'totp_required' && "border-primary ring-2 ring-primary/30" + )} placeholder={t("totp_placeholder")} autoComplete="one-time-code" aria-label={t("totp_label")} diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 0c5a51a4..94af300d 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -30,6 +30,7 @@ import { ComposerErrorFallback, } from "@/components/error"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; +import { TotpReauthDialog } from "@/components/totp-reauth-dialog"; import { DragDropProvider } from "@/contexts/drag-drop-context"; import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils"; import { WelcomeBanner } from "@/components/ui/welcome-banner"; @@ -1726,6 +1727,7 @@ export default function Home() { + ); diff --git a/app/api/auth/totp-token-exchange/route.ts b/app/api/auth/totp-token-exchange/route.ts new file mode 100644 index 00000000..7ea840e4 --- /dev/null +++ b/app/api/auth/totp-token-exchange/route.ts @@ -0,0 +1,188 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { logger } from '@/lib/logger'; +import { discoverOAuth } from '@/lib/oauth/discovery'; +import { refreshTokenCookieName } from '@/lib/oauth/tokens'; +import { getCookieOptions } from '@/lib/oauth/cookie-config'; + +/** + * Exchange basic auth credentials (with TOTP appended) for OAuth tokens. + * + * This allows 2FA users who log in with basic auth + TOTP to upgrade + * to token-based auth, avoiding session expiry when the TOTP rotates. + * + * Tries three strategies: + * 1. ROPC grant with client_id (if OAUTH_CLIENT_ID is set) + * 2. ROPC grant without client_id + * 3. ROPC grant authenticated via Basic Auth header (Stalwart-style) + */ + +async function tryTokenRequest( + tokenEndpoint: string, + params: URLSearchParams, + extraHeaders?: Record, +): Promise<{ ok: true; tokens: { access_token: string; expires_in?: number; refresh_token?: string } } | { ok: false; status: number; error: string }> { + try { + const headers: Record = { 'Content-Type': 'application/x-www-form-urlencoded', ...extraHeaders }; + const response = await fetch(tokenEndpoint, { + method: 'POST', + headers, + body: params.toString(), + }); + + if (!response.ok) { + const errorText = await response.text(); + return { ok: false, status: response.status, error: errorText.substring(0, 500) }; + } + + const tokens = await response.json(); + if (!tokens.access_token) { + return { ok: false, status: 502, error: 'Response missing access_token' }; + } + + return { ok: true, tokens }; + } catch (err) { + return { ok: false, status: 0, error: err instanceof Error ? err.message : String(err) }; + } +} + +async function findTokenEndpoint(serverUrl: string): Promise { + // 1. Try OAuth discovery + const metadata = await discoverOAuth(serverUrl); + if (metadata?.token_endpoint) return metadata.token_endpoint; + + // 2. Try common Stalwart token endpoint paths directly + const candidates = [ + `${serverUrl}/auth/token`, + `${serverUrl}/api/oauth/token`, + ]; + + for (const url of candidates) { + try { + // A POST with no body should return 400 (bad request) rather than 404 if the endpoint exists + const probe = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'grant_type=probe' }); + if (probe.status !== 404 && probe.status !== 405) { + return url; + } + } catch { + // Network error — endpoint not reachable + } + } + + return null; +} + +export async function POST(request: NextRequest) { + try { + const { serverUrl, username, password, slot: bodySlot } = await request.json(); + + if (!serverUrl || !username || !password) { + return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 }); + } + + const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0; + + // Use the server-side JMAP_SERVER_URL if set (may differ from the + // public URL the browser uses, e.g. inside Docker). + const internalServerUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || serverUrl; + + const tokenEndpoint = await findTokenEndpoint(internalServerUrl); + if (!tokenEndpoint) { + // Also try with the client-provided URL in case the internal one differs + const clientEndpoint = internalServerUrl !== serverUrl ? await findTokenEndpoint(serverUrl) : null; + if (!clientEndpoint) { + logger.warn('TOTP token exchange: no token endpoint found', { serverUrl, internalServerUrl }); + return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 }); + } + return await attemptAllStrategies(clientEndpoint, username, password, slot); + } + + return await attemptAllStrategies(tokenEndpoint, username, password, slot); + } catch (error) { + logger.error('TOTP token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +async function attemptAllStrategies( + tokenEndpoint: string, + username: string, + password: string, + slot: number, +): Promise { + logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint }); + + const clientId = process.env.OAUTH_CLIENT_ID; + const clientSecret = process.env.OAUTH_CLIENT_SECRET; + const basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; + const attempts: Array<{ strategy: string; error: string }> = []; + + // Strategy 1: ROPC with client_id (if configured) + if (clientId) { + const params = new URLSearchParams({ grant_type: 'password', username, password, client_id: clientId }); + if (clientSecret) params.set('client_secret', clientSecret); + const result = await tryTokenRequest(tokenEndpoint, params); + if (result.ok) { + logger.info('TOTP token exchange succeeded (ROPC with client_id)'); + return await storeAndRespond(result.tokens, slot); + } + attempts.push({ strategy: 'ROPC with client_id', error: result.error }); + } + + // Strategy 2: ROPC without client_id + { + const params = new URLSearchParams({ grant_type: 'password', username, password }); + const result = await tryTokenRequest(tokenEndpoint, params); + if (result.ok) { + logger.info('TOTP token exchange succeeded (ROPC without client_id)'); + return await storeAndRespond(result.tokens, slot); + } + attempts.push({ strategy: 'ROPC without client_id', error: result.error }); + } + + // Strategy 3: Basic Auth header on token endpoint (some servers accept this) + { + const params = new URLSearchParams({ grant_type: 'password' }); + const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth }); + if (result.ok) { + logger.info('TOTP token exchange succeeded (Basic Auth header)'); + return await storeAndRespond(result.tokens, slot); + } + attempts.push({ strategy: 'Basic Auth header', error: result.error }); + } + + // Strategy 4: client_credentials with Basic Auth (last resort) + { + const params = new URLSearchParams({ grant_type: 'client_credentials' }); + const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth }); + if (result.ok) { + logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)'); + return await storeAndRespond(result.tokens, slot); + } + attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error }); + } + + logger.warn('TOTP token exchange: all strategies failed', { attempts }); + return NextResponse.json({ + error: 'token_exchange_failed', + detail: 'All token exchange strategies failed', + attempts, + }, { status: 502 }); +} + +async function storeAndRespond( + tokens: { access_token: string; expires_in?: number; refresh_token?: string }, + slot: number, +): Promise { + if (tokens.refresh_token) { + const cookieName = refreshTokenCookieName(slot); + const cookieStore = await cookies(); + cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions()); + } + + return NextResponse.json({ + access_token: tokens.access_token, + expires_in: tokens.expires_in || 3600, + has_refresh_token: !!tokens.refresh_token, + }); +} diff --git a/components/totp-reauth-dialog.tsx b/components/totp-reauth-dialog.tsx new file mode 100644 index 00000000..e36cf626 --- /dev/null +++ b/components/totp-reauth-dialog.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { useFocusTrap } from "@/hooks/use-focus-trap"; +import { useTotpReauthStore } from "@/stores/totp-reauth-store"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Shield } from "lucide-react"; + +/** + * Modal dialog that prompts the user for a fresh TOTP code when their + * 2FA session expires (TOTP rotates every ~30 seconds). + * + * Rendered once at the app root level. The JMAP client triggers it via + * the useTotpReauthStore when a 401 is received on a TOTP-authenticated session. + */ +export function TotpReauthDialog() { + const { isOpen, submit, cancel } = useTotpReauthStore(); + const [code, setCode] = useState(""); + const inputRef = useRef(null); + + const dialogRef = useFocusTrap({ + isActive: isOpen, + onEscape: cancel, + restoreFocus: true, + }); + + useEffect(() => { + if (isOpen) { + setCode(""); + setTimeout(() => inputRef.current?.focus(), 50); + } + }, [isOpen]); + + if (!isOpen) return null; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (code.length >= 6) { + submit(code); + } + }; + + return ( +
+
+
+
+
+ +
+
+

Session Expired

+

Your 2FA code has rotated

+
+
+ +

+ Enter a fresh authentication code from your authenticator app to continue. +

+ +

+ To avoid being prompted repeatedly, ask your administrator to enable OAuth authentication + (either Stalwart's built-in OAuth or an external identity provider). +

+ +
+ setCode(e.target.value.replace(/\D/g, ''))} + className="h-12 text-center font-mono tracking-widest text-lg bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200" + placeholder="000000" + autoComplete="one-time-code" + aria-label="Authentication code" + /> +
+ + +
+
+
+
+ ); +} diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index ca8340da..1d1304c9 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -41,6 +41,9 @@ export class DemoJMAPClient implements IJMAPClient { getServerUrl(): string { return 'https://demo.example.com'; } getAuthHeader(): string { return 'Bearer demo-token'; } updateAccessToken(): void { /* no-op */ } + upgradeToBearer(): void { /* no-op */ } + enableTotpReauth(): void { /* no-op */ } + updateBasicAuth(): void { /* no-op */ } getAccountId(): string { return 'demo-account'; } getUsername(): string { return 'demo@example.com'; } diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 6c35a083..127bd0a2 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -19,6 +19,9 @@ export interface IJMAPClient { getServerUrl(): string; getAuthHeader(): string; updateAccessToken(token: string): void; + upgradeToBearer(accessToken: string, onRefresh?: () => Promise): void; + enableTotpReauth(basePassword: string, callback: () => Promise): void; + updateBasicAuth(newPassword: string): void; getAccountId(): string; getUsername(): string; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 6877833b..5ff5062f 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -227,9 +227,11 @@ export class JMAPClient implements IJMAPClient { private serverUrl: string; private username: string; private password: string; + private basePassword: string = ''; private authHeader: string; private authMode: 'basic' | 'bearer' = 'basic'; private onTokenRefresh?: () => Promise; + private onTotpRequired?: () => Promise; private apiUrl: string = ""; private accountId: string = ""; private downloadUrl: string = ""; @@ -272,6 +274,29 @@ export class JMAPClient implements IJMAPClient { this.authHeader = `Bearer ${token}`; } + /** Upgrade an existing basic-auth client to bearer-token auth (e.g. after TOTP token exchange). */ + upgradeToBearer(accessToken: string, onRefresh?: () => Promise): void { + this.authMode = 'bearer'; + this.authHeader = `Bearer ${accessToken}`; + this.onTokenRefresh = onRefresh; + } + + /** + * Enable TOTP re-authentication for basic-auth sessions. + * When a 401 is received, the callback is invoked to get a fresh TOTP code. + * The base password (without TOTP) is stored so we can construct new credentials. + */ + enableTotpReauth(basePassword: string, callback: () => Promise): void { + this.basePassword = basePassword; + this.onTotpRequired = callback; + } + + /** Update basic-auth credentials with a new password (e.g. password$newTotp). */ + updateBasicAuth(newPassword: string): void { + this.password = newPassword; + this.authHeader = `Basic ${btoa(`${this.username}:${newPassword}`)}`; + } + getAuthHeader(): string { return this.authHeader; } @@ -324,7 +349,21 @@ export class JMAPClient implements IJMAPClient { const retryHeaders = { ...init?.headers as Record, 'Authorization': this.authHeader }; response = await fetch(url, { ...init, headers: retryHeaders }); } catch { - // Session refresh failed — return original 401 response + // Session refresh failed — if TOTP was used, try re-auth with fresh TOTP + if (this.onTotpRequired && this.basePassword) { + try { + const newTotp = await this.onTotpRequired(); + if (newTotp) { + this.updateBasicAuth(`${this.basePassword}$${newTotp}`); + await this.refreshSession(); + this.connectionChangeCallback?.(true); + const retryHeaders = { ...init?.headers as Record, 'Authorization': this.authHeader }; + response = await fetch(url, { ...init, headers: retryHeaders }); + } + } catch { + // TOTP re-auth also failed — return original 401 + } + } } finally { this.reconnecting = false; } @@ -368,6 +407,16 @@ export class JMAPClient implements IJMAPClient { ? 'Authentication failed - token may be expired' : 'Invalid username or password'); } + if (sessionResponse.status === 402) { + try { + const body = await sessionResponse.json(); + if (body?.title?.toLowerCase().includes('totp')) { + throw new Error('TOTP_REQUIRED'); + } + } catch (e) { + if (e instanceof Error && e.message === 'TOTP_REQUIRED') throw e; + } + } throw new Error(`Failed to get session: ${sessionResponse.status}`); } diff --git a/locales/en/common.json b/locales/en/common.json index c48413d2..207d8bcb 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -18,6 +18,7 @@ "cors_blocked": "The server is reachable but is blocking cross-origin requests. Check your JMAP server's CORS settings and allow this domain.", "server_error": "The server is temporarily unavailable. Please try again later.", "generic": "An unexpected error occurred. If this persists, contact your administrator.", + "totp_required": "A two-factor authentication code is required. Please enter your code below.", "totp_invalid": "Invalid authentication code. Please check your authenticator app and try again.", "oauth_discovery_failed": "SSO is enabled but the identity provider could not be reached. Check your OAuth configuration." }, diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 492f0e38..2a2c63ab 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -52,6 +52,7 @@ interface AuthState { const ERROR_PATTERNS: Array<{ key: string; matches: string[] }> = [ { key: 'cors_blocked', matches: ['CORS_ERROR'] }, + { key: 'totp_required', matches: ['TOTP_REQUIRED'] }, { key: 'invalid_credentials', matches: ['Invalid username or password', '401', 'Unauthorized'] }, { key: 'connection_failed', matches: ['network', 'Failed to fetch', 'NetworkError', 'ECONNREFUSED', 'Load failed', 'cancelled'] }, { key: 'server_error', matches: ['500', '502', '503', '504', 'Internal Server Error', 'Service Unavailable'] }, @@ -371,6 +372,47 @@ export const useAuthStore = create()( clearAllStores(); } + // 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). + let upgradedToOAuth = false; + let oauthAccessToken: string | null = null; + let oauthExpiresIn = 0; + + if (totp) { + try { + const tokenRes = await fetch('/api/auth/totp-token-exchange', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ serverUrl, username, password: effectivePassword, slot: cookieSlot }), + }); + if (tokenRes.ok) { + const { access_token, expires_in, has_refresh_token } = await tokenRes.json(); + // Upgrade client to Bearer auth + client.upgradeToBearer(access_token, () => get().refreshAccessToken()); + oauthAccessToken = access_token; + oauthExpiresIn = expires_in; + upgradedToOAuth = true; + debug.log('TOTP login upgraded to token-based auth (has_refresh_token=' + has_refresh_token + ')'); + } else { + const errorBody = await tokenRes.json().catch(() => ({ error: 'unknown' })); + debug.warn('TOTP token exchange failed:', tokenRes.status, errorBody); + } + } catch (err) { + debug.warn('TOTP token exchange error:', err); + } + + // If token exchange failed, enable TOTP re-auth prompt so the + // client can ask for a fresh code on 401 instead of disconnecting. + if (!upgradedToOAuth) { + const { useTotpReauthStore } = await import('@/stores/totp-reauth-store'); + client.enableTotpReauth(password, () => useTotpReauthStore.getState().requestTotp()); + debug.log('TOTP re-auth enabled — user will be prompted for fresh codes on session expiry'); + } + } + + const effectiveAuthMode = upgradedToOAuth ? 'oauth' : 'basic'; + // Store client in multi-account map clients.set(accountId, client); bindClientStatusHandlers(client, set, get, accountId); @@ -379,7 +421,7 @@ export const useAuthStore = create()( label: primaryIdentity?.name || username, serverUrl, username, - authMode: 'basic', + authMode: effectiveAuthMode, rememberMe: !!rememberMe, displayName: primaryIdentity?.name || username, email: primaryIdentity?.email || username, @@ -392,6 +434,7 @@ export const useAuthStore = create()( // Update account entry in case it already existed (addAccount is a no-op for existing accounts) accountStore.updateAccount(accountId, { + authMode: effectiveAuthMode, rememberMe: !!rememberMe, isConnected: true, hasError: false, @@ -402,7 +445,8 @@ export const useAuthStore = create()( // 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) { + if (rememberMe && !upgradedToOAuth) { + // For basic auth (no TOTP or TOTP upgrade failed), store encrypted credentials try { const res = await fetch(`/api/auth/session?slot=${cookieSlot}`, { method: 'POST', @@ -426,15 +470,20 @@ export const useAuthStore = create()( ...getClientRateLimitState(client), identities, primaryIdentity, - authMode: 'basic', + authMode: effectiveAuthMode, rememberMe: !!rememberMe, - accessToken: null, - tokenExpiresAt: null, + accessToken: oauthAccessToken, + tokenExpiresAt: oauthAccessToken ? Date.now() + oauthExpiresIn * 1000 : null, connectionLost: false, error: null, activeAccountId: accountId, }); + // Schedule token refresh for TOTP-upgraded sessions + if (upgradedToOAuth && oauthExpiresIn > 0) { + scheduleRefresh(oauthExpiresIn, get().refreshAccessToken, accountId); + } + // Sync settings from server (only if enabled) fetchConfig().then(config => { if (!config.settingsSyncEnabled) return; diff --git a/stores/totp-reauth-store.ts b/stores/totp-reauth-store.ts new file mode 100644 index 00000000..3001aef6 --- /dev/null +++ b/stores/totp-reauth-store.ts @@ -0,0 +1,48 @@ +import { create } from 'zustand'; + +/** + * Global store for TOTP re-authentication prompts. + * + * When a JMAP client detects a 401 and TOTP was used for login, + * it calls the registered callback which triggers this store to show + * a dialog. The dialog collects a fresh TOTP code and resolves the + * pending promise so the client can retry with updated credentials. + */ + +interface TotpReauthState { + isOpen: boolean; + resolve: ((totp: string | null) => void) | null; + /** Request a fresh TOTP code from the user. Returns the code or null if cancelled. */ + requestTotp: () => Promise; + /** Submit the TOTP code from the dialog. */ + submit: (totp: string) => void; + /** Cancel/dismiss the dialog. */ + cancel: () => void; +} + +export const useTotpReauthStore = create()((set, get) => ({ + isOpen: false, + resolve: null, + + requestTotp: () => { + // If already open, cancel the previous request + const prev = get().resolve; + if (prev) prev(null); + + return new Promise((resolve) => { + set({ isOpen: true, resolve }); + }); + }, + + submit: (totp: string) => { + const { resolve } = get(); + if (resolve) resolve(totp); + set({ isOpen: false, resolve: null }); + }, + + cancel: () => { + const { resolve } = get(); + if (resolve) resolve(null); + set({ isOpen: false, resolve: null }); + }, +}));