diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 936efca..70e0b0f 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -32,6 +32,7 @@ import { } from '@/lib/app-support'; import { bootstrapAppSession, + type CompletedLogin, readInitialAppBootstrapState, performPasswordLogin, performRecoverTwoFactorLogin, @@ -94,9 +95,11 @@ export default function App() { const [decryptedFolders, setDecryptedFolders] = useState([]); const [decryptedCiphers, setDecryptedCiphers] = useState([]); const [decryptedSends, setDecryptedSends] = useState([]); + const sessionRef = useRef(initialBootstrap.session); const migratedPlainFolderIdsRef = useRef>(new Set()); const silentRefreshVaultRef = useRef<() => Promise>(async () => {}); const refreshAuthorizedDevicesRef = useRef<() => Promise>(async () => {}); + const repairAttemptRef = useRef(''); const { toasts, pushToast, removeToast } = useToastManager(); useEffect(() => { @@ -153,6 +156,7 @@ export default function App() { }, []); function setSession(next: SessionState | null) { + sessionRef.current = next; setSessionState(next); saveSession(next); } @@ -210,10 +214,9 @@ export default function App() { }; }, []); - async function finalizeLogin(nextSession: SessionState, nextProfile: Profile) { - setSession(nextSession); - setProfile(nextProfile); - await silentlyRepairBackupSettingsIfNeeded(nextSession, nextProfile); + async function finalizeLogin(login: CompletedLogin) { + setSession(login.session); + setProfile(login.profile); setPendingTotp(null); setTotpCode(''); setPhase('app'); @@ -221,6 +224,15 @@ export default function App() { navigate('/vault'); } pushToast('success', t('txt_login_success')); + void (async () => { + try { + const hydratedProfile = await login.profilePromise; + if (sessionRef.current?.accessToken !== login.session.accessToken) return; + setProfile(hydratedProfile); + } catch { + // Keep the in-memory transient profile for the current session. + } + })(); } async function handleLogin() { @@ -233,7 +245,7 @@ export default function App() { try { const result = await performPasswordLogin(loginValues.email, loginValues.password, defaultKdfIterations); if (result.kind === 'success') { - await finalizeLogin(result.login.session, result.login.profile); + await finalizeLogin(result.login); return; } if (result.kind === 'totp') { @@ -258,7 +270,7 @@ export default function App() { } try { const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice); - await finalizeLogin(login.session, login.profile); + await finalizeLogin(login); } catch (error) { pushToast('error', error instanceof Error ? error.message : t('txt_totp_verify_failed')); } @@ -275,7 +287,7 @@ export default function App() { try { const recovered = await performRecoverTwoFactorLogin(email, password, recoveryCode, defaultKdfIterations); if (recovered.login) { - await finalizeLogin(recovered.login.session, recovered.login.profile); + await finalizeLogin(recovered.login); if (recovered.newRecoveryCode) { pushToast('success', t('txt_text_2fa_recovered_new_recovery_code_code', { code: recovered.newRecoveryCode })); } else { @@ -337,7 +349,6 @@ export default function App() { try { const nextSession = await performUnlock(session, profile, unlockPassword, defaultKdfIterations); setSession(nextSession); - await silentlyRepairBackupSettingsIfNeeded(nextSession, profile); setUnlockPassword(''); setPhase('app'); if (location === '/' || location === '/lock') navigate('/vault'); @@ -439,6 +450,20 @@ export default function App() { enabled: phase === 'app' && !!session?.accessToken, }); + useEffect(() => { + if (phase !== 'app' || !session?.accessToken || !session?.symEncKey || !session?.symMacKey) return; + if (!profile?.role || profile.role !== 'admin') return; + if (repairAttemptRef.current === session.accessToken) return; + + repairAttemptRef.current = session.accessToken; + void silentlyRepairBackupSettingsIfNeeded(session, profile); + }, [phase, session?.accessToken, session?.symEncKey, session?.symMacKey, profile]); + + useEffect(() => { + if (session?.accessToken) return; + repairAttemptRef.current = ''; + }, [session?.accessToken]); + useEffect(() => { if (!session?.symEncKey || !session?.symMacKey) { setDecryptedFolders([]); diff --git a/webapp/src/lib/api/auth.ts b/webapp/src/lib/api/auth.ts index 94895b4..2256ef5 100644 --- a/webapp/src/lib/api/auth.ts +++ b/webapp/src/lib/api/auth.ts @@ -109,6 +109,18 @@ export async function deriveLoginHash(email: string, password: string, fallbackI return { hash: bytesToBase64(hash), masterKey, kdfIterations: iterations }; } +export async function deriveLoginHashLocally( + email: string, + password: string, + fallbackIterations: number +): Promise { + const normalizedEmail = String(email || '').trim().toLowerCase(); + const iterations = Number(fallbackIterations || 600000); + const masterKey = await pbkdf2(password, normalizedEmail, iterations, 32); + const hash = await pbkdf2(masterKey, password, 1, 32); + return { hash: bytesToBase64(hash), masterKey, kdfIterations: iterations }; +} + export async function getPreloginKdfConfig(email: string, fallbackIterations: number): Promise { const normalized = String(email || '').trim().toLowerCase(); if (!normalized) throw new Error('Email is required'); diff --git a/webapp/src/lib/app-auth.ts b/webapp/src/lib/app-auth.ts index dce2cda..4e723e9 100644 --- a/webapp/src/lib/app-auth.ts +++ b/webapp/src/lib/app-auth.ts @@ -1,6 +1,7 @@ import { createAuthedFetch, deriveLoginHash, + deriveLoginHashLocally, getProfile, loadSession, loginWithPassword, @@ -10,7 +11,7 @@ import { unlockVaultKey, } from '@/lib/api/auth'; import { readInviteCodeFromUrl } from '@/lib/app-support'; -import type { AppPhase, Profile, SessionState, WebBootstrapResponse } from '@/lib/types'; +import type { AppPhase, Profile, SessionState, TokenSuccess, WebBootstrapResponse } from '@/lib/types'; export interface PendingTotp { email: string; @@ -38,6 +39,7 @@ export interface InitialAppBootstrapState { export interface CompletedLogin { session: SessionState; profile: Profile; + profilePromise: Promise; } export type PasswordLoginResult = @@ -91,6 +93,42 @@ function readWindowBootstrap(): WebBootstrapResponse { return raw && typeof raw === 'object' ? raw : {}; } +interface AccessTokenClaims { + sub?: string; + email?: string; + name?: string | null; + premium?: boolean; +} + +function decodeAccessTokenClaims(accessToken: string): AccessTokenClaims { + try { + const parts = accessToken.split('.'); + if (parts.length < 2) return {}; + const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const padded = payload.padEnd(Math.ceil(payload.length / 4) * 4, '='); + return (JSON.parse(atob(padded)) as AccessTokenClaims) || {}; + } catch { + return {}; + } +} + +function buildTransientProfile(token: TokenSuccess, email: string): Profile { + const claims = decodeAccessTokenClaims(token.access_token); + const normalizedEmail = String(claims.email || email || '').trim().toLowerCase(); + const accountKeys = token.accountKeys ?? token.AccountKeys ?? null; + return { + id: String(claims.sub || ''), + email: normalizedEmail, + name: String(claims.name || normalizedEmail || ''), + key: String(token.Key || ''), + privateKey: token.PrivateKey ?? null, + role: 'user', + premium: !!claims.premium, + accountKeys, + object: 'profile', + }; +} + export function readInitialAppBootstrapState(): InitialAppBootstrapState { const boot = readWindowBootstrap(); const defaultKdfIterations = Number(boot.defaultKdfIterations || 600000); @@ -168,21 +206,29 @@ export async function bootstrapAppSession(): Promise { } export async function completeLogin( - tokenAccess: string, - tokenRefresh: string, + token: TokenSuccess, email: string, masterKey: Uint8Array ): Promise { - const baseSession: SessionState = { accessToken: tokenAccess, refreshToken: tokenRefresh, email }; + const normalizedEmail = email.trim().toLowerCase(); + const baseSession: SessionState = { + accessToken: token.access_token, + refreshToken: token.refresh_token, + email: normalizedEmail, + }; const tempFetch = createAuthedFetch( () => baseSession, () => {} ); - const profile = await getProfile(tempFetch); + const profile = buildTransientProfile(token, normalizedEmail); + if (!profile.key) { + throw new Error('Missing profile key'); + } const keys = await unlockVaultKey(profile.key, masterKey); return { session: { ...baseSession, ...keys }, profile, + profilePromise: getProfile(tempFetch), }; } @@ -192,13 +238,13 @@ export async function performPasswordLogin( fallbackIterations: number ): Promise { const normalizedEmail = email.trim().toLowerCase(); - const derived = await deriveLoginHash(normalizedEmail, password, fallbackIterations); + const derived = await deriveLoginHashLocally(normalizedEmail, password, fallbackIterations); const token = await loginWithPassword(normalizedEmail, derived.hash, { useRememberToken: true }); if ('access_token' in token && token.access_token) { return { kind: 'success', - login: await completeLogin(token.access_token, token.refresh_token, normalizedEmail, derived.masterKey), + login: await completeLogin(token, normalizedEmail, derived.masterKey), }; } @@ -230,7 +276,7 @@ export async function performTotpLogin( rememberDevice, }); if ('access_token' in token && token.access_token) { - return completeLogin(token.access_token, token.refresh_token, pendingTotp.email, pendingTotp.masterKey); + return completeLogin(token, pendingTotp.email, pendingTotp.masterKey); } const tokenError = token as { error_description?: string; error?: string }; throw new Error(tokenError.error_description || tokenError.error || 'TOTP verify failed'); @@ -243,13 +289,13 @@ export async function performRecoverTwoFactorLogin( fallbackIterations: number ): Promise { const normalizedEmail = email.trim().toLowerCase(); - const derived = await deriveLoginHash(normalizedEmail, password, fallbackIterations); + const derived = await deriveLoginHashLocally(normalizedEmail, password, fallbackIterations); const recovered = await recoverTwoFactor(normalizedEmail, derived.hash, recoveryCode.trim()); const token = await loginWithPassword(normalizedEmail, derived.hash, { useRememberToken: false }); if ('access_token' in token && token.access_token) { return { - login: await completeLogin(token.access_token, token.refresh_token, normalizedEmail, derived.masterKey), + login: await completeLogin(token, normalizedEmail, derived.masterKey), newRecoveryCode: recovered.newRecoveryCode || null, }; } @@ -282,7 +328,7 @@ export async function performUnlock( password: string, fallbackIterations: number ): Promise { - const derived = await deriveLoginHash(profile.email || session.email, password, fallbackIterations); + const derived = await deriveLoginHashLocally(profile.email || session.email, password, fallbackIterations); const keys = await unlockVaultKey(profile.key, derived.masterKey); const refreshedSession = await maybeRefreshSession(session); if (!refreshedSession) { diff --git a/webapp/src/lib/types.ts b/webapp/src/lib/types.ts index 8f58923..ce43707 100644 --- a/webapp/src/lib/types.ts +++ b/webapp/src/lib/types.ts @@ -265,7 +265,23 @@ export interface WebBootstrapResponse { export interface TokenSuccess { access_token: string; refresh_token: string; + expires_in?: number; + token_type?: string; TwoFactorToken?: string; + Key?: string; + PrivateKey?: string | null; + AccountKeys?: unknown | null; + accountKeys?: unknown | null; + Kdf?: number; + KdfIterations?: number; + KdfMemory?: number | null; + KdfParallelism?: number | null; + ForcePasswordReset?: boolean; + ResetMasterPassword?: boolean; + scope?: string; + unofficialServer?: boolean; + UserDecryptionOptions?: unknown; + userDecryptionOptions?: unknown; } export interface TokenError {