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;
|
||||
}, [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")}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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'; }
|
||||
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'; }
|
||||
|
||||
|
||||
@@ -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
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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