fix: handle 2FA/TOTP session expiry with basic auth #117
This commit is contained in:
@@ -98,6 +98,14 @@ export default function LoginPage() {
|
|||||||
prevError.current = error;
|
prevError.current = error;
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!serverUrl) return;
|
if (!serverUrl) return;
|
||||||
const saved = localStorage.getItem("webmail_usernames");
|
const saved = localStorage.getItem("webmail_usernames");
|
||||||
@@ -890,7 +898,10 @@ export default function LoginPage() {
|
|||||||
maxLength={6}
|
maxLength={6}
|
||||||
value={totpCode}
|
value={totpCode}
|
||||||
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
|
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")}
|
placeholder={t("totp_placeholder")}
|
||||||
autoComplete="one-time-code"
|
autoComplete="one-time-code"
|
||||||
aria-label={t("totp_label")}
|
aria-label={t("totp_label")}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
ComposerErrorFallback,
|
ComposerErrorFallback,
|
||||||
} from "@/components/error";
|
} from "@/components/error";
|
||||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
|
import { TotpReauthDialog } from "@/components/totp-reauth-dialog";
|
||||||
import { DragDropProvider } from "@/contexts/drag-drop-context";
|
import { DragDropProvider } from "@/contexts/drag-drop-context";
|
||||||
import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils";
|
import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils";
|
||||||
import { WelcomeBanner } from "@/components/ui/welcome-banner";
|
import { WelcomeBanner } from "@/components/ui/welcome-banner";
|
||||||
@@ -1726,6 +1727,7 @@ export default function Home() {
|
|||||||
|
|
||||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||||
<ConfirmDialog {...confirmDialogProps} />
|
<ConfirmDialog {...confirmDialogProps} />
|
||||||
|
<TotpReauthDialog />
|
||||||
</div>
|
</div>
|
||||||
</DragDropProvider>
|
</DragDropProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -41,6 +41,9 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
getServerUrl(): string { return 'https://demo.example.com'; }
|
getServerUrl(): string { return 'https://demo.example.com'; }
|
||||||
getAuthHeader(): string { return 'Bearer demo-token'; }
|
getAuthHeader(): string { return 'Bearer demo-token'; }
|
||||||
updateAccessToken(): void { /* no-op */ }
|
updateAccessToken(): void { /* no-op */ }
|
||||||
|
upgradeToBearer(): void { /* no-op */ }
|
||||||
|
enableTotpReauth(): void { /* no-op */ }
|
||||||
|
updateBasicAuth(): void { /* no-op */ }
|
||||||
getAccountId(): string { return 'demo-account'; }
|
getAccountId(): string { return 'demo-account'; }
|
||||||
getUsername(): string { return 'demo@example.com'; }
|
getUsername(): string { return 'demo@example.com'; }
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ export interface IJMAPClient {
|
|||||||
getServerUrl(): string;
|
getServerUrl(): string;
|
||||||
getAuthHeader(): string;
|
getAuthHeader(): string;
|
||||||
updateAccessToken(token: string): void;
|
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;
|
getAccountId(): string;
|
||||||
getUsername(): string;
|
getUsername(): string;
|
||||||
|
|
||||||
|
|||||||
+50
-1
@@ -227,9 +227,11 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
private serverUrl: string;
|
private serverUrl: string;
|
||||||
private username: string;
|
private username: string;
|
||||||
private password: string;
|
private password: string;
|
||||||
|
private basePassword: string = '';
|
||||||
private authHeader: string;
|
private authHeader: string;
|
||||||
private authMode: 'basic' | 'bearer' = 'basic';
|
private authMode: 'basic' | 'bearer' = 'basic';
|
||||||
private onTokenRefresh?: () => Promise<string | null>;
|
private onTokenRefresh?: () => Promise<string | null>;
|
||||||
|
private onTotpRequired?: () => Promise<string | null>;
|
||||||
private apiUrl: string = "";
|
private apiUrl: string = "";
|
||||||
private accountId: string = "";
|
private accountId: string = "";
|
||||||
private downloadUrl: string = "";
|
private downloadUrl: string = "";
|
||||||
@@ -272,6 +274,29 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
this.authHeader = `Bearer ${token}`;
|
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 {
|
getAuthHeader(): string {
|
||||||
return this.authHeader;
|
return this.authHeader;
|
||||||
}
|
}
|
||||||
@@ -324,7 +349,21 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
const retryHeaders = { ...init?.headers as Record<string, string>, 'Authorization': this.authHeader };
|
const retryHeaders = { ...init?.headers as Record<string, string>, 'Authorization': this.authHeader };
|
||||||
response = await fetch(url, { ...init, headers: retryHeaders });
|
response = await fetch(url, { ...init, headers: retryHeaders });
|
||||||
} catch {
|
} 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 {
|
} finally {
|
||||||
this.reconnecting = false;
|
this.reconnecting = false;
|
||||||
}
|
}
|
||||||
@@ -368,6 +407,16 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
? 'Authentication failed - token may be expired'
|
? 'Authentication failed - token may be expired'
|
||||||
: 'Invalid username or password');
|
: '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}`);
|
throw new Error(`Failed to get session: ${sessionResponse.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.",
|
"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.",
|
"server_error": "The server is temporarily unavailable. Please try again later.",
|
||||||
"generic": "An unexpected error occurred. If this persists, contact your administrator.",
|
"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.",
|
"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."
|
"oauth_discovery_failed": "SSO is enabled but the identity provider could not be reached. Check your OAuth configuration."
|
||||||
},
|
},
|
||||||
|
|||||||
+54
-5
@@ -52,6 +52,7 @@ interface AuthState {
|
|||||||
|
|
||||||
const ERROR_PATTERNS: Array<{ key: string; matches: string[] }> = [
|
const ERROR_PATTERNS: Array<{ key: string; matches: string[] }> = [
|
||||||
{ key: 'cors_blocked', matches: ['CORS_ERROR'] },
|
{ key: 'cors_blocked', matches: ['CORS_ERROR'] },
|
||||||
|
{ key: 'totp_required', matches: ['TOTP_REQUIRED'] },
|
||||||
{ key: 'invalid_credentials', matches: ['Invalid username or password', '401', 'Unauthorized'] },
|
{ key: 'invalid_credentials', matches: ['Invalid username or password', '401', 'Unauthorized'] },
|
||||||
{ key: 'connection_failed', matches: ['network', 'Failed to fetch', 'NetworkError', 'ECONNREFUSED', 'Load failed', 'cancelled'] },
|
{ 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'] },
|
{ key: 'server_error', matches: ['500', '502', '503', '504', 'Internal Server Error', 'Service Unavailable'] },
|
||||||
@@ -371,6 +372,47 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
clearAllStores();
|
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
|
// Store client in multi-account map
|
||||||
clients.set(accountId, client);
|
clients.set(accountId, client);
|
||||||
bindClientStatusHandlers(client, set, get, accountId);
|
bindClientStatusHandlers(client, set, get, accountId);
|
||||||
@@ -379,7 +421,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
label: primaryIdentity?.name || username,
|
label: primaryIdentity?.name || username,
|
||||||
serverUrl,
|
serverUrl,
|
||||||
username,
|
username,
|
||||||
authMode: 'basic',
|
authMode: effectiveAuthMode,
|
||||||
rememberMe: !!rememberMe,
|
rememberMe: !!rememberMe,
|
||||||
displayName: primaryIdentity?.name || username,
|
displayName: primaryIdentity?.name || username,
|
||||||
email: primaryIdentity?.email || 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)
|
// Update account entry in case it already existed (addAccount is a no-op for existing accounts)
|
||||||
accountStore.updateAccount(accountId, {
|
accountStore.updateAccount(accountId, {
|
||||||
|
authMode: effectiveAuthMode,
|
||||||
rememberMe: !!rememberMe,
|
rememberMe: !!rememberMe,
|
||||||
isConnected: true,
|
isConnected: true,
|
||||||
hasError: false,
|
hasError: false,
|
||||||
@@ -402,7 +445,8 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
// Store session cookie BEFORE setting isAuthenticated to avoid a race
|
// Store session cookie BEFORE setting isAuthenticated to avoid a race
|
||||||
// condition: setting isAuthenticated triggers navigation to the main page,
|
// condition: setting isAuthenticated triggers navigation to the main page,
|
||||||
// whose checkAuth() would try to read the cookie before it was stored.
|
// 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 {
|
try {
|
||||||
const res = await fetch(`/api/auth/session?slot=${cookieSlot}`, {
|
const res = await fetch(`/api/auth/session?slot=${cookieSlot}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -426,15 +470,20 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
...getClientRateLimitState(client),
|
...getClientRateLimitState(client),
|
||||||
identities,
|
identities,
|
||||||
primaryIdentity,
|
primaryIdentity,
|
||||||
authMode: 'basic',
|
authMode: effectiveAuthMode,
|
||||||
rememberMe: !!rememberMe,
|
rememberMe: !!rememberMe,
|
||||||
accessToken: null,
|
accessToken: oauthAccessToken,
|
||||||
tokenExpiresAt: null,
|
tokenExpiresAt: oauthAccessToken ? Date.now() + oauthExpiresIn * 1000 : null,
|
||||||
connectionLost: false,
|
connectionLost: false,
|
||||||
error: null,
|
error: null,
|
||||||
activeAccountId: accountId,
|
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)
|
// Sync settings from server (only if enabled)
|
||||||
fetchConfig().then(config => {
|
fetchConfig().then(config => {
|
||||||
if (!config.settingsSyncEnabled) return;
|
if (!config.settingsSyncEnabled) return;
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
},
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user