fix: handle 2FA/TOTP session expiry with basic auth #117

This commit is contained in:
Linus Rath
2026-03-30 16:37:07 +02:00
parent 8937777bcd
commit 67210c9924
10 changed files with 468 additions and 7 deletions
+12 -1
View File
@@ -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")}
+2
View File
@@ -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() {
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<ConfirmDialog {...confirmDialogProps} />
<TotpReauthDialog />
</div>
</DragDropProvider>
);
+188
View File
@@ -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<string, string>,
): Promise<{ ok: true; tokens: { access_token: string; expires_in?: number; refresh_token?: string } } | { ok: false; status: number; error: string }> {
try {
const headers: Record<string, string> = { '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<string | null> {
// 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<NextResponse> {
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<NextResponse> {
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,
});
}
+107
View File
@@ -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<HTMLInputElement>(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 (
<div className="fixed inset-0 z-[100] flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={cancel} />
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label="Two-factor authentication required"
className="relative z-10 w-full max-w-sm mx-4 bg-background rounded-2xl shadow-xl border border-border p-6"
>
<div className="flex items-center gap-3 mb-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-primary/10">
<Shield className="w-5 h-5 text-primary" />
</div>
<div>
<h2 className="text-lg font-semibold text-foreground">Session Expired</h2>
<p className="text-sm text-muted-foreground">Your 2FA code has rotated</p>
</div>
</div>
<p className="text-sm text-muted-foreground mb-4">
Enter a fresh authentication code from your authenticator app to continue.
</p>
<p className="text-xs text-amber-600 dark:text-amber-400 mb-4 leading-relaxed">
To avoid being prompted repeatedly, ask your administrator to enable OAuth authentication
(either Stalwart&apos;s built-in OAuth or an external identity provider).
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
ref={inputRef}
type="text"
inputMode="numeric"
maxLength={6}
value={code}
onChange={(e) => 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"
/>
<div className="flex gap-3">
<Button
type="button"
variant="outline"
className="flex-1"
onClick={cancel}
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={code.length < 6}
>
Verify
</Button>
</div>
</form>
</div>
</div>
);
}
+3
View File
@@ -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'; }
+3
View File
@@ -19,6 +19,9 @@ export interface IJMAPClient {
getServerUrl(): string;
getAuthHeader(): string;
updateAccessToken(token: string): void;
upgradeToBearer(accessToken: string, onRefresh?: () => Promise<string | null>): void;
enableTotpReauth(basePassword: string, callback: () => Promise<string | null>): void;
updateBasicAuth(newPassword: string): void;
getAccountId(): string;
getUsername(): string;
+50 -1
View File
@@ -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<string | null>;
private onTotpRequired?: () => Promise<string | null>;
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<string | null>): 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<string | null>): 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<string, string>, '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<string, string>, '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}`);
}
+1
View File
@@ -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."
},
+54 -5
View File
@@ -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<AuthState>()(
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<AuthState>()(
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<AuthState>()(
// 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<AuthState>()(
// 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<AuthState>()(
...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;
+48
View File
@@ -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<string | null>;
/** Submit the TOTP code from the dialog. */
submit: (totp: string) => void;
/** Cancel/dismiss the dialog. */
cancel: () => void;
}
export const useTotpReauthStore = create<TotpReauthState>()((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<string | null>((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 });
},
}));