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
+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}`);
}