From 0343bdd39316900880c3491034d301172580cd06 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Thu, 3 Jul 2025 13:52:52 +0530 Subject: [PATCH 01/18] Unused Notice the typo --- web/packages/accounts/pages/credentials.tsx | 1 - web/packages/accounts/pages/two-factor/recover.tsx | 2 +- web/packages/accounts/pages/verify.tsx | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/web/packages/accounts/pages/credentials.tsx b/web/packages/accounts/pages/credentials.tsx index f28bfceaaf..ca1ce4874c 100644 --- a/web/packages/accounts/pages/credentials.tsx +++ b/web/packages/accounts/pages/credentials.tsx @@ -217,7 +217,6 @@ const Page: React.FC = () => { ...user, passkeySessionID, isTwoFactorEnabled: true, - isTwoFactorPasskeysEnabled: true, }); stashRedirect("/"); const url = passkeyVerificationRedirectURL( diff --git a/web/packages/accounts/pages/two-factor/recover.tsx b/web/packages/accounts/pages/two-factor/recover.tsx index e8fa006da2..d6fcb0e2fb 100644 --- a/web/packages/accounts/pages/two-factor/recover.tsx +++ b/web/packages/accounts/pages/two-factor/recover.tsx @@ -69,7 +69,7 @@ const Page: React.FC = ({ twoFactorType }) => { if (!user?.email || !sessionID) { void router.push("/"); } else if ( - !(user.isTwoFactorEnabled || user.isTwoFactorEnabledPasskey) && + !user.isTwoFactorEnabled && (user.encryptedToken || user.token) ) { void router.push("/generate"); diff --git a/web/packages/accounts/pages/verify.tsx b/web/packages/accounts/pages/verify.tsx index 7111b57435..a711248d4e 100644 --- a/web/packages/accounts/pages/verify.tsx +++ b/web/packages/accounts/pages/verify.tsx @@ -106,7 +106,6 @@ const Page: React.FC = () => { ...user, passkeySessionID, isTwoFactorEnabled: true, - isTwoFactorPasskeysEnabled: true, }); saveIsFirstLogin(); const url = passkeyVerificationRedirectURL( From e68b695284b3bbf9998d3b00e0660d77d845b570 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Thu, 3 Jul 2025 14:00:10 +0530 Subject: [PATCH 02/18] Used but missing --- web/packages/accounts/services/accounts-db.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/packages/accounts/services/accounts-db.ts b/web/packages/accounts/services/accounts-db.ts index 2aff817739..e3411d9767 100644 --- a/web/packages/accounts/services/accounts-db.ts +++ b/web/packages/accounts/services/accounts-db.ts @@ -83,6 +83,7 @@ export interface PartialLocalUser { encryptedToken?: string; isTwoFactorEnabled?: boolean; twoFactorSessionID?: string; + passkeySessionID?: string; } const PartialLocalUser = z.object({ @@ -92,6 +93,7 @@ const PartialLocalUser = z.object({ encryptedToken: z.string().nullish().transform(nullToUndefined), isTwoFactorEnabled: z.boolean().nullish().transform(nullToUndefined), twoFactorSessionID: z.string().nullish().transform(nullToUndefined), + passkeySessionID: z.string().nullish().transform(nullToUndefined), }); /** From 337c25b670e8296a6374763b8beac7643ace9c59 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Thu, 3 Jul 2025 15:05:10 +0530 Subject: [PATCH 03/18] Update --- web/apps/photos/src/pages/_app.tsx | 6 +- web/apps/photos/src/pages/gallery.tsx | 9 +-- web/apps/photos/src/pages/index.tsx | 87 ++++++++++++--------------- 3 files changed, 45 insertions(+), 57 deletions(-) diff --git a/web/apps/photos/src/pages/_app.tsx b/web/apps/photos/src/pages/_app.tsx index baa3f357e9..960f01de0b 100644 --- a/web/apps/photos/src/pages/_app.tsx +++ b/web/apps/photos/src/pages/_app.tsx @@ -4,9 +4,9 @@ import { CssBaseline, Typography } from "@mui/material"; import { styled, ThemeProvider } from "@mui/material/styles"; import { useNotification } from "components/utils/hooks-app"; import { - getData, isLocalStorageAndIndexedDBMismatch, savedLocalUser, + savedPartialLocalUser, } from "ente-accounts/services/accounts-db"; import { isDesktop, staticAppTitle } from "ente-base/app"; import { CenteredRow } from "ente-base/components/containers"; @@ -127,11 +127,11 @@ const App: React.FC = ({ Component, pageProps }) => { useEffect(() => { const query = new URLSearchParams(window.location.search); const needsFamilyRedirect = query.get("redirect") == "families"; - if (needsFamilyRedirect && getData("user")?.token) + if (needsFamilyRedirect && savedPartialLocalUser()?.token) redirectToFamilyPortal(); router.events.on("routeChangeStart", () => { - if (needsFamilyRedirect && getData("user")?.token) { + if (needsFamilyRedirect && savedPartialLocalUser()?.token) { redirectToFamilyPortal(); // https://github.com/vercel/next.js/issues/2476#issuecomment-573460710 diff --git a/web/apps/photos/src/pages/gallery.tsx b/web/apps/photos/src/pages/gallery.tsx index 46579debcf..4d8b4a2f56 100644 --- a/web/apps/photos/src/pages/gallery.tsx +++ b/web/apps/photos/src/pages/gallery.tsx @@ -17,10 +17,10 @@ import { sessionExpiredDialogAttributes } from "ente-accounts/components/utils/d import { getAndClearIsFirstLogin, getAndClearJustSignedUp, - getData, } from "ente-accounts/services/accounts-db"; import { stashRedirect } from "ente-accounts/services/redirect"; import { isSessionInvalid } from "ente-accounts/services/session"; +import { ensureLocalUser } from "ente-accounts/services/user"; import type { MiniDialogAttributes } from "ente-base/components/MiniDialog"; import { NavbarBase } from "ente-base/components/Navbar"; import { SingleInputDialog } from "ente-base/components/SingleInputDialog"; @@ -318,13 +318,10 @@ const Page: React.FC = () => { } // Initialize the reducer. - const user = getData("user"); - // TODO: Pass entire snapshot to reducer? - const familyData = userDetailsSnapshot()?.familyData; dispatch({ type: "mount", - user, - familyData, + user: ensureLocalUser(), + familyData: userDetailsSnapshot()?.familyData, collections: await savedCollections(), collectionFiles: await savedCollectionFiles(), trashItems: await savedTrashItems(), diff --git a/web/apps/photos/src/pages/index.tsx b/web/apps/photos/src/pages/index.tsx index 291314daa5..3ac7b0d771 100644 --- a/web/apps/photos/src/pages/index.tsx +++ b/web/apps/photos/src/pages/index.tsx @@ -1,7 +1,7 @@ import { Box, Stack, Typography, styled } from "@mui/material"; import { LoginContents } from "ente-accounts/components/LoginContents"; import { SignUpContents } from "ente-accounts/components/SignUpContents"; -import { getData } from "ente-accounts/services/accounts-db"; +import { savedPartialLocalUser } from "ente-accounts/services/accounts-db"; import { CenteredFill, CenteredRow } from "ente-base/components/containers"; import { EnteLogo } from "ente-base/components/EnteLogo"; import { ActivityIndicator } from "ente-base/components/mui/ActivityIndicator"; @@ -34,53 +34,44 @@ const Page: React.FC = () => { ); useEffect(() => { - refreshHost(); - const currentURL = new URL(window.location.href); - const albumsURL = new URL(albumsAppOrigin()); - currentURL.pathname = router.pathname; - if ( - currentURL.host === albumsURL.host && - currentURL.pathname != "/shared-albums" - ) { - handleAlbumsRedirect(currentURL); - } else { - handleNormalRedirect(); - } - }, [refreshHost]); - - const handleAlbumsRedirect = async (currentURL: URL) => { - const end = currentURL.hash.lastIndexOf("&"); - const hash = currentURL.hash.slice(1, end !== -1 ? end : undefined); - await router.replace({ - pathname: "/shared-albums", - search: currentURL.search, - hash: hash, - }); - await ensureIndexedDBAccess(); - }; - - const handleNormalRedirect = async () => { - const user = getData("user"); - await updateSessionFromElectronSafeStorageIfNeeded(); - if (await haveAuthenticatedSession()) { - await router.push("/gallery"); - } else if (user?.email) { - await router.push("/verify"); - } - await ensureIndexedDBAccess(); - }; - - const ensureIndexedDBAccess = useCallback(async () => { - if (!(await canAccessIndexedDB())) { - showMiniDialog({ - title: t("error"), - message: t("local_storage_not_accessible"), - nonClosable: true, - cancel: false, - }); - } - setLoading(false); - }, [showMiniDialog]); + void (async () => { + refreshHost(); + const currentURL = new URL(window.location.href); + const albumsURL = new URL(albumsAppOrigin()); + currentURL.pathname = router.pathname; + if ( + currentURL.host == albumsURL.host && + currentURL.pathname != "/shared-albums" + ) { + const end = currentURL.hash.lastIndexOf("&"); + const hash = currentURL.hash.slice( + 1, + end !== -1 ? end : undefined, + ); + await router.replace({ + pathname: "/shared-albums", + search: currentURL.search, + hash: hash, + }); + } else { + await updateSessionFromElectronSafeStorageIfNeeded(); + if (await haveAuthenticatedSession()) { + await router.push("/gallery"); + } else if (savedPartialLocalUser()?.email) { + await router.push("/verify"); + } + } + if (!(await canAccessIndexedDB())) { + showMiniDialog({ + title: t("error"), + message: t("local_storage_not_accessible"), + nonClosable: true, + cancel: false, + }); + } + setLoading(false); + })(); + }, [showMiniDialog, router, refreshHost]); return ( From 5b7d4a880688eecea66db9718e328f57874d0681 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Thu, 3 Jul 2025 15:09:33 +0530 Subject: [PATCH 04/18] Update --- .../photos/src/services/upload-manager.ts | 8 +++---- web/apps/photos/src/utils/file/index.ts | 21 ++----------------- 2 files changed, 6 insertions(+), 23 deletions(-) diff --git a/web/apps/photos/src/services/upload-manager.ts b/web/apps/photos/src/services/upload-manager.ts index 95087cdde5..9c351f8df7 100644 --- a/web/apps/photos/src/services/upload-manager.ts +++ b/web/apps/photos/src/services/upload-manager.ts @@ -1,3 +1,4 @@ +import { ensureLocalUser } from "ente-accounts/services/user"; import { isDesktop } from "ente-base/app"; import { createComlinkCryptoWorker } from "ente-base/crypto"; import { type CryptoWorker } from "ente-base/crypto/worker"; @@ -41,7 +42,6 @@ import { computeNormalCollectionFilesFromSaved } from "ente-new/photos/services/ import { indexNewUpload } from "ente-new/photos/services/ml"; import { wait } from "ente-utils/promise"; import watcher from "services/watch"; -import { getUserOwnedFiles } from "utils/file"; export type FileID = number; @@ -443,9 +443,9 @@ class UploadManager { this.publicAlbumsCredentials.accessToken, ); } else { - this.existingFiles = getUserOwnedFiles( - await computeNormalCollectionFilesFromSaved(), - ); + const files = await computeNormalCollectionFilesFromSaved(); + const userID = ensureLocalUser().id; + this.existingFiles = files.filter((file) => file.ownerID == userID); } this.collections = new Map( collections.map((collection) => [collection.id, collection]), diff --git a/web/apps/photos/src/utils/file/index.ts b/web/apps/photos/src/utils/file/index.ts index 03ccbc90a0..5558efaff0 100644 --- a/web/apps/photos/src/utils/file/index.ts +++ b/web/apps/photos/src/utils/file/index.ts @@ -1,5 +1,4 @@ -import { getData } from "ente-accounts/services/accounts-db"; -import type { LocalUser, PartialLocalUser } from "ente-accounts/services/user"; +import type { LocalUser } from "ente-accounts/services/user"; import { joinPath } from "ente-base/file-name"; import log from "ente-base/log"; import { type Electron } from "ente-base/types/ipc"; @@ -8,11 +7,7 @@ import { downloadManager } from "ente-gallery/services/download"; import { detectFileTypeInfo } from "ente-gallery/utils/detect-type"; import { writeStream } from "ente-gallery/utils/native-stream"; import { EnteFile } from "ente-media/file"; -import { - ItemVisibility, - fileFileName, - isArchivedFile, -} from "ente-media/file-metadata"; +import { ItemVisibility, fileFileName } from "ente-media/file-metadata"; import { FileType } from "ente-media/file-type"; import { decodeLivePhoto } from "ente-media/live-photo"; import { type FileOp } from "ente-new/photos/components/SelectedFileOptions"; @@ -279,18 +274,6 @@ async function downloadFileDesktop( } } -export const getArchivedFiles = (files: EnteFile[]) => { - return files.filter(isArchivedFile).map((file) => file.id); -}; - -export const getUserOwnedFiles = (files: EnteFile[]) => { - const user: PartialLocalUser = getData("user"); - if (!user?.id) { - throw Error("user missing"); - } - return files.filter((file) => file.ownerID === user.id); -}; - export const shouldShowAvatar = ( file: EnteFile, user: LocalUser | undefined, From 6249211bca6429ddddb66e8acdd2bd563e3e79ab Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Thu, 3 Jul 2025 15:21:17 +0530 Subject: [PATCH 05/18] Rename --- web/apps/photos/src/pages/gallery.tsx | 9 +++++---- web/packages/new/photos/services/user-details.ts | 14 +++++++++----- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/web/apps/photos/src/pages/gallery.tsx b/web/apps/photos/src/pages/gallery.tsx index 4d8b4a2f56..1fea4027fb 100644 --- a/web/apps/photos/src/pages/gallery.tsx +++ b/web/apps/photos/src/pages/gallery.tsx @@ -108,8 +108,8 @@ import { import type { SearchOption } from "ente-new/photos/services/search/types"; import { initSettings } from "ente-new/photos/services/settings"; import { - initUserDetailsOrTriggerPull, redirectToCustomerPortal, + savedUserDetailsOrTriggerPull, userDetailsSnapshot, verifyStripeSubscription, } from "ente-new/photos/services/user-details"; @@ -301,7 +301,6 @@ const Page: React.FC = () => { // One time inits. preloadImage("/images/subscription-card-background"); initSettings(); - await initUserDetailsOrTriggerPull(); setupSelectAllKeyBoardShortcutHandler(); // Show the initial state while the rest of the sequence proceeds. @@ -318,10 +317,12 @@ const Page: React.FC = () => { } // Initialize the reducer. + const user = ensureLocalUser(); + const userDetails = await savedUserDetailsOrTriggerPull(); dispatch({ type: "mount", - user: ensureLocalUser(), - familyData: userDetailsSnapshot()?.familyData, + user, + familyData: userDetails?.familyData, collections: await savedCollections(), collectionFiles: await savedCollectionFiles(), trashItems: await savedTrashItems(), diff --git a/web/packages/new/photos/services/user-details.ts b/web/packages/new/photos/services/user-details.ts index 4d48d7449e..2a99fdafcb 100644 --- a/web/packages/new/photos/services/user-details.ts +++ b/web/packages/new/photos/services/user-details.ts @@ -177,18 +177,22 @@ export const logoutUserDetails = () => { }; /** - * Read in the locally persisted settings into memory, otherwise initiate a - * network requests to fetch the latest values (but don't wait for it to - * complete). + * Read in the locally persisted user details into memory and return them. + * + * If there are no locally persisted values, initiate a network requests to + * fetch the latest values (but don't wait for it to complete). * * This assumes that the user is already logged in. */ -export const initUserDetailsOrTriggerPull = async () => { +export const savedUserDetailsOrTriggerPull = async () => { const saved = await getKV("userDetails"); if (saved) { - setUserDetailsSnapshot(UserDetails.parse(saved)); + const userDetails = UserDetails.parse(saved); + setUserDetailsSnapshot(userDetails); + return userDetails; } else { void pullUserDetails(); + return undefined; } }; From ef752a244c1d1fb5045336e2d4fdba4f366eb2d8 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Thu, 3 Jul 2025 15:41:47 +0530 Subject: [PATCH 06/18] Handle family email --- web/apps/photos/src/pages/gallery.tsx | 14 +++++++-- .../new/photos/components/gallery/reducer.ts | 29 ++++++++++++++++++- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/web/apps/photos/src/pages/gallery.tsx b/web/apps/photos/src/pages/gallery.tsx index 1fea4027fb..fce8113997 100644 --- a/web/apps/photos/src/pages/gallery.tsx +++ b/web/apps/photos/src/pages/gallery.tsx @@ -77,7 +77,10 @@ import { } from "ente-new/photos/components/gallery/reducer"; import { notifyOthersFilesDialogAttributes } from "ente-new/photos/components/utils/dialog-attributes"; import { useIsOffline } from "ente-new/photos/components/utils/use-is-offline"; -import { usePeopleStateSnapshot } from "ente-new/photos/components/utils/use-snapshot"; +import { + usePeopleStateSnapshot, + useUserDetailsSnapshot, +} from "ente-new/photos/components/utils/use-snapshot"; import { shouldShowWhatsNew } from "ente-new/photos/services/changelog"; import { addToFavoritesCollection, @@ -110,7 +113,6 @@ import { initSettings } from "ente-new/photos/services/settings"; import { redirectToCustomerPortal, savedUserDetailsOrTriggerPull, - userDetailsSnapshot, verifyStripeSubscription, } from "ente-new/photos/services/user-details"; import { usePhotosAppContext } from "ente-new/photos/types/context"; @@ -182,6 +184,7 @@ const Page: React.FC = () => { EnteFile[] >([]); + const userDetails = useUserDetailsSnapshot(); const peopleState = usePeopleStateSnapshot(); // The (non-sticky) header shown at the top of the gallery items. @@ -352,6 +355,13 @@ const Page: React.FC = () => { }; }, []); + useEffect(() => { + // Only act on updates after the initial mount has completed. + if (state.user && userDetails) { + dispatch({ type: "setUserDetails", userDetails }); + } + }, [state.user, userDetails]); + useEffect(() => { if (typeof activeCollectionID == "undefined" || !router.isReady) { return; diff --git a/web/packages/new/photos/components/gallery/reducer.ts b/web/packages/new/photos/components/gallery/reducer.ts index 56a2590ff0..d7aedfc0cb 100644 --- a/web/packages/new/photos/components/gallery/reducer.ts +++ b/web/packages/new/photos/components/gallery/reducer.ts @@ -38,7 +38,7 @@ import { } from "../../services/collection-summary"; import type { PeopleState, Person } from "../../services/ml/people"; import type { SearchSuggestion } from "../../services/search/types"; -import type { FamilyData } from "../../services/user-details"; +import type { FamilyData, UserDetails } from "../../services/user-details"; /** * Specifies what the bar at the top of the gallery is displaying currently. @@ -455,6 +455,7 @@ export type GalleryAction = collectionFiles: EnteFile[]; trashItems: TrashItem[]; } + | { type: "setUserDetails"; userDetails: UserDetails } | { type: "setCollections"; collections: Collection[] } | { type: "setCollectionFiles"; collectionFiles: EnteFile[] } | { type: "uploadFile"; file: EnteFile } @@ -623,6 +624,32 @@ const galleryReducer: React.Reducer = ( }); } + case "setUserDetails": { + // While user details have more state that can change, the only + // changes that affect the reducer's state (so far) are if the + // user's own email changes, or the list of their family members + // changes. + // + // Both of these affect only the list of share suggestion emails. + + let user = state.user!; + const { email, familyData } = action.userDetails; + if (email != user.email) { + user = { ...user, email }; + } + + return { + ...state, + user, + familyData, + shareSuggestionEmails: createShareSuggestionEmails( + user, + familyData, + state.collections, + ), + }; + } + case "setCollections": { const collections = action.collections; From ea5ebd09659145574b36d5b67b39fdec442572ec Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Thu, 3 Jul 2025 16:10:33 +0530 Subject: [PATCH 07/18] Perf I didn't see it being a problem, so this is perhaps premature optimization --- .../new/photos/services/user-details.ts | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/web/packages/new/photos/services/user-details.ts b/web/packages/new/photos/services/user-details.ts index 2a99fdafcb..b7805e1633 100644 --- a/web/packages/new/photos/services/user-details.ts +++ b/web/packages/new/photos/services/user-details.ts @@ -238,7 +238,6 @@ const setUserDetailsSnapshot = (snapshot: UserDetails) => { export const pullUserDetails = async () => { const userDetails = await getUserDetails(); await setKV("userDetails", userDetails); - setUserDetailsSnapshot(userDetails); // Update the email for the local storage user if needed (the user might've // changed their email on a different client). @@ -247,6 +246,32 @@ export const pullUserDetails = async () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument await setLSUser({ ...getData("user"), email: userDetails.email }); } + + // The gallery listens for updates to userDetails, so a special case, do a + // deep equality check so as to not rerender it on redundant updates. + // + // [Note: Deep equal check] + // + // React uses `Object.is` to detect changes, which changes for arrays, + // objects and combinations thereof even if the underlying data is the same. + // + // In many cases, the code can be restructured to avoid this being a + // problem, or the rerender might be infrequent enough that it is not a + // problem. + // + // However, when used with useSyncExternalStore, there is an easy way to + // prevent this, by doing a preflight deep equality comparison. + // + // There are arguably faster libraries out there that'll do the deep + // equality check for us, but since it is an infrequent pattern in our code + // base currently, we just use the JSON serialization. + // + // Mark all cases that do this using this note's title so we can audit them + // if we move to a deep equality comparison library in the future. + + if (JSON.stringify(userDetails) != JSON.stringify(userDetailsSnapshot())) { + setUserDetailsSnapshot(userDetails); + } }; /** From 4e8a4250dc5e79e5561bac107303e60f67f8c9a7 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Thu, 3 Jul 2025 16:31:20 +0530 Subject: [PATCH 08/18] Update --- web/packages/accounts/pages/credentials.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/packages/accounts/pages/credentials.tsx b/web/packages/accounts/pages/credentials.tsx index ca1ce4874c..8b0b1757d0 100644 --- a/web/packages/accounts/pages/credentials.tsx +++ b/web/packages/accounts/pages/credentials.tsx @@ -82,7 +82,7 @@ const Page: React.FC = () => { const [user, setUser] = useState(undefined); const [keyAttributes, setKeyAttributes] = useState(); - const [srpAttributes, setSrpAttributes] = useState(); + const [srpAttributes, setSRPAttributes] = useState(); const [passkeyVerificationData, setPasskeyVerificationData] = useState< { passkeySessionID: string; url: string } | undefined >(); @@ -175,7 +175,7 @@ const Page: React.FC = () => { } if (srpAttributes) { - setSrpAttributes(srpAttributes); + setSRPAttributes(srpAttributes); } else { void router.push("/"); } From 9e4a67312f46603ba2f4517f2c3283a838fcfe4e Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Thu, 3 Jul 2025 16:48:32 +0530 Subject: [PATCH 09/18] Update --- web/packages/accounts/pages/credentials.tsx | 18 ++--- web/packages/accounts/pages/login.tsx | 34 +++++++++- .../accounts/pages/two-factor/recover.tsx | 14 ++-- .../accounts/pages/two-factor/verify.tsx | 66 ++++++++++--------- web/packages/accounts/pages/verify.tsx | 18 ++--- web/packages/accounts/services/accounts-db.ts | 29 +++++++- web/packages/accounts/services/user.ts | 11 +++- .../components/sidebar/TwoFactorSettings.tsx | 23 +++---- .../new/photos/services/user-details.ts | 8 +-- 9 files changed, 137 insertions(+), 84 deletions(-) diff --git a/web/packages/accounts/pages/credentials.tsx b/web/packages/accounts/pages/credentials.tsx index 8b0b1757d0..046770bc43 100644 --- a/web/packages/accounts/pages/credentials.tsx +++ b/web/packages/accounts/pages/credentials.tsx @@ -25,6 +25,7 @@ import { saveKeyAttributes, saveSRPAttributes, setLSUser, + updateSavedLocalUser, } from "ente-accounts/services/accounts-db"; import { openPasskeyVerificationURL, @@ -212,12 +213,7 @@ const Page: React.FC = () => { if (passkeySessionID) { await stashKeyEncryptionKeyInSessionStore(kek); - const user = getData("user"); - await setLSUser({ - ...user, - passkeySessionID, - isTwoFactorEnabled: true, - }); + updateSavedLocalUser({ passkeySessionID }); stashRedirect("/"); const url = passkeyVerificationRedirectURL( accountsUrl!, @@ -228,11 +224,9 @@ const Page: React.FC = () => { throw new Error(twoFactorEnabledErrorMessage); } else if (twoFactorSessionID) { await stashKeyEncryptionKeyInSessionStore(kek); - const user = getData("user"); - await setLSUser({ - ...user, - twoFactorSessionID, + updateSavedLocalUser({ isTwoFactorEnabled: true, + twoFactorSessionID, }); void router.push("/two-factor/verify"); throw new Error(twoFactorEnabledErrorMessage); @@ -243,7 +237,9 @@ const Page: React.FC = () => { token, encryptedToken, id, - isTwoFactorEnabled: false, + isTwoFactorEnabled: undefined, + twoFactorSessionID: undefined, + passkeySessionID: undefined, }); if (keyAttributes) saveKeyAttributes(keyAttributes); return keyAttributes; diff --git a/web/packages/accounts/pages/login.tsx b/web/packages/accounts/pages/login.tsx index bf754056ff..b98eebaefb 100644 --- a/web/packages/accounts/pages/login.tsx +++ b/web/packages/accounts/pages/login.tsx @@ -49,6 +49,8 @@ import React, { useCallback, useEffect, useState } from "react"; * * - Redirects to the passkey app once email verification is complete if the * user has setup an additional passkey that also needs to be verified. + * Before redirecting, it sets the `inflightPasskeySessionID` in session + * storage. * * - "/credentials" - A page that allows the user to enter their password to * authenticate (initial login) or reauthenticate (new web app tab) @@ -79,11 +81,41 @@ import React, { useCallback, useEffect, useState } from "react"; * * - Redirects to "/change-password" once the recovery key is verified. * - * - "/change-password" - A page that allows the user to reset their password. + * - "/change-password" - A page that allows the user to reset their password. * * - Redirects to "/" if there is no `email` present in the saved partial * user, and after successfully changing the password. * + * - "/two-factor/verify" - A page that allows the user to verify their TOTP + * based second factor. + * + * - Redirects to "/" if there is no `email` or `twoFactorSessionID` in the + * saved partial local user. + * + * - Redirects to "/credentials" if there `isTwoFactorEnabled` is not `true` + * and either of `encryptedToken` or `token` is present in the saved partial + * local user. + * + * - "/passkeys/finish" - A page that the accounts app hands off control back to + * us (the calling app) to continue the rest of the authentication. + * + * - Redirects to "/" if there is no matching `inflightPasskeySessionID` in + * session storage. + * + * - Redirects to "/credentials" otherwise. + * + * - "/two-factor/recover" and "/passkeys/recover" - Pages that allow the user + * to reset or bypass their second factor if they possess their recovery key. + * Both pages work similarly, except the second factor they act on. + * + * - Redirects to "/" if there is no `email` in the saved partial local user, + * or either of `twoFactorSessionID` and `twoFactorSessionID` is set. + * + * - Redirects to "/generate" if there is an `encryptedToken` or `token` in + * the saved partial local user (TODO: Why?). + * + * - Redirects to "/credentials" after recovery. + * */ const Page: React.FC = () => { const [loading, setLoading] = useState(true); diff --git a/web/packages/accounts/pages/two-factor/recover.tsx b/web/packages/accounts/pages/two-factor/recover.tsx index d6fcb0e2fb..caa3480661 100644 --- a/web/packages/accounts/pages/two-factor/recover.tsx +++ b/web/packages/accounts/pages/two-factor/recover.tsx @@ -4,7 +4,7 @@ import { AccountsPageFooter, AccountsPageTitle, } from "ente-accounts/components/layouts/centered-paper"; -import { getData } from "ente-accounts/services/accounts-db"; +import { savedPartialLocalUser } from "ente-accounts/services/accounts-db"; import { recoverTwoFactor, recoverTwoFactorFinish, @@ -64,14 +64,14 @@ const Page: React.FC = ({ twoFactorType }) => { ); useEffect(() => { - const user = getData("user"); - const sessionID = user.passkeySessionID || user.twoFactorSessionID; + const user = savedPartialLocalUser(); + const sessionID = + twoFactorType == "passkey" + ? user?.passkeySessionID + : user?.twoFactorSessionID; if (!user?.email || !sessionID) { void router.push("/"); - } else if ( - !user.isTwoFactorEnabled && - (user.encryptedToken || user.token) - ) { + } else if (user.encryptedToken || user.token) { void router.push("/generate"); } else { setSessionID(sessionID); diff --git a/web/packages/accounts/pages/two-factor/verify.tsx b/web/packages/accounts/pages/two-factor/verify.tsx index 8c32a92b3e..918fba2486 100644 --- a/web/packages/accounts/pages/two-factor/verify.tsx +++ b/web/packages/accounts/pages/two-factor/verify.tsx @@ -1,17 +1,17 @@ import { Verify2FACodeForm } from "ente-accounts/components/Verify2FACodeForm"; import { getData, + savedPartialLocalUser, saveKeyAttributes, setLSUser, } from "ente-accounts/services/accounts-db"; -import type { PartialLocalUser } from "ente-accounts/services/user"; import { verifyTwoFactor } from "ente-accounts/services/user"; import { LinkButton } from "ente-base/components/LinkButton"; import { useBaseContext } from "ente-base/context"; import { isHTTPErrorWithStatus } from "ente-base/http"; import { t } from "i18next"; import { useRouter } from "next/router"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { AccountsPageContents, AccountsPageFooter, @@ -19,15 +19,18 @@ import { } from "../../components/layouts/centered-paper"; import { unstashRedirect } from "../../services/redirect"; +/** + * A page that allows the user to verify their TOTP based second factor. + */ const Page: React.FC = () => { const { logout } = useBaseContext(); - const [sessionID, setSessionID] = useState(""); + const [twoFactorSessionID, setTwoFactorSessionID] = useState(""); const router = useRouter(); useEffect(() => { - const user: PartialLocalUser = getData("user"); + const user = savedPartialLocalUser(); if (!user?.email || !user.twoFactorSessionID) { void router.push("/"); } else if ( @@ -36,37 +39,38 @@ const Page: React.FC = () => { ) { void router.push("/credentials"); } else { - setSessionID(user.twoFactorSessionID); + setTwoFactorSessionID(user.twoFactorSessionID); } }, [router]); - const handleSubmit = async (otp: string) => { - try { - const { keyAttributes, encryptedToken, id } = await verifyTwoFactor( - otp, - sessionID, - ); - await setLSUser({ - ...getData("user"), - id, - // TODO: [Note: empty token?] - // - // The original code was parsing an token which is never going - // to be present in the response, so effectively was always - // setting token to undefined. So this works, but is it needed? - token: undefined, - encryptedToken, - }); - saveKeyAttributes(keyAttributes); - await router.push(unstashRedirect() ?? "/credentials"); - } catch (e) { - if (isHTTPErrorWithStatus(e, 404)) { - logout(); - } else { - throw e; + const handleSubmit = useCallback( + async (otp: string) => { + try { + const { keyAttributes, encryptedToken, id } = + await verifyTwoFactor(otp, twoFactorSessionID); + await setLSUser({ + ...getData("user"), + id, + // TODO: [Note: empty token?] + // + // The original code was parsing an token which is never going + // to be present in the response, so effectively was always + // setting token to undefined. So this works, but is it needed? + token: undefined, + encryptedToken, + }); + saveKeyAttributes(keyAttributes); + await router.push(unstashRedirect() ?? "/credentials"); + } catch (e) { + if (isHTTPErrorWithStatus(e, 404)) { + logout(); + } else { + throw e; + } } - } - }; + }, + [logout, router, twoFactorSessionID], + ); return ( diff --git a/web/packages/accounts/pages/verify.tsx b/web/packages/accounts/pages/verify.tsx index a711248d4e..9eac4d569d 100644 --- a/web/packages/accounts/pages/verify.tsx +++ b/web/packages/accounts/pages/verify.tsx @@ -8,7 +8,6 @@ import { VerifyingPasskey } from "ente-accounts/components/LoginComponents"; import { SecondFactorChoice } from "ente-accounts/components/SecondFactorChoice"; import { useSecondFactorChoiceIfNeeded } from "ente-accounts/components/utils/second-factor-choice"; import { - getData, savedKeyAttributes, savedOriginalKeyAttributes, savedPartialLocalUser, @@ -19,6 +18,7 @@ import { setLSUser, unstashAfterUseSRPSetupAttributes, unstashReferralSource, + updateSavedLocalUser, } from "ente-accounts/services/accounts-db"; import { openPasskeyVerificationURL, @@ -101,12 +101,7 @@ const Page: React.FC = () => { await verifyEmail(email, ott, cleanedReferral), ); if (passkeySessionID) { - const user = getData("user"); - await setLSUser({ - ...user, - passkeySessionID, - isTwoFactorEnabled: true, - }); + updateSavedLocalUser({ passkeySessionID }); saveIsFirstLogin(); const url = passkeyVerificationRedirectURL( accountsUrl!, @@ -115,10 +110,9 @@ const Page: React.FC = () => { setPasskeyVerificationData({ passkeySessionID, url }); openPasskeyVerificationURL({ passkeySessionID, url }); } else if (twoFactorSessionID) { - await setLSUser({ - email, - twoFactorSessionID, + updateSavedLocalUser({ isTwoFactorEnabled: true, + twoFactorSessionID, }); saveIsFirstLogin(); void router.push("/two-factor/verify"); @@ -128,7 +122,9 @@ const Page: React.FC = () => { token, encryptedToken, id, - isTwoFactorEnabled: false, + isTwoFactorEnabled: undefined, + twoFactorSessionID: undefined, + passkeySessionID: undefined, }); if (keyAttributes) { saveKeyAttributes(keyAttributes); diff --git a/web/packages/accounts/services/accounts-db.ts b/web/packages/accounts/services/accounts-db.ts index e3411d9767..98c4577058 100644 --- a/web/packages/accounts/services/accounts-db.ts +++ b/web/packages/accounts/services/accounts-db.ts @@ -58,6 +58,9 @@ import { * also get filled in. Once they verify their TOTP based second factor, their * {@link id} and {@link encryptedToken} will also get filled in. * + * - If they have a passkey set as a second factor set, then after verifying + * their password the {@link passkeySessionID} will be set. + * * - As the login or signup sequence completes, a {@link token} obtained from * the {@link encryptedToken} will be written out, and the * {@link encryptedToken} cleared since it is not needed anymore. @@ -105,6 +108,7 @@ const LocalUser = z.object({ id: z.number(), email: z.string(), token: z.string(), + isTwoFactorEnabled: z.boolean().nullish().transform(nullToUndefined), }); /** @@ -127,13 +131,33 @@ export const savedPartialLocalUser = (): PartialLocalUser | undefined => { * * See: [Note: Partial local user]. * + * This method replaces the existing data. Use {@link updatePartialLocalUser} to + * update selected fields while keeping the other fields as it is. + * * TODO: WARNING: This does not update the KV token. The idea is to gradually * move over uses of setLSUser to this while explicitly setting the KV token * where needed. */ -export const savePartialLocalUser = (partialLocalUser: Partial) => +export const savePartialLocalUser = (partialLocalUser: PartialLocalUser) => localStorage.setItem("user", JSON.stringify(partialLocalUser)); +/** + * Partially update the saved user data. + * + * This is a delta variant of {@link savePartialLocalUser}, which replaces the + * entire saved object, while this function spreads the provided {@link updates} + * onto the currently saved value. + * + * @param updates A subset of {@link PartialLocalUser} fields that we'd like to + * update. The other fields, if any, remain unchanged. + * + * TODO: WARNING: This does not update the KV token. The idea is to gradually + * move over uses of setLSUser to this while explicitly setting the KV token + * where needed. + */ +export const updateSavedLocalUser = (updates: Partial) => + savePartialLocalUser({ ...savedPartialLocalUser(), ...updates }); + /** * Return data about the logged-in user, if someone is indeed logged in. * Otherwise return `undefined`. @@ -143,7 +167,8 @@ export const savePartialLocalUser = (partialLocalUser: Partial) => * not accessible to web workers. * * There is no setter corresponding to this function since this is only a view - * on data saved using {@link savePartialLocalUser}. + * on data saved using {@link savePartialLocalUser} or + * {@link updateSavedLocalUser}. * * See: [Note: Partial local user] for more about the whole shebang. */ diff --git a/web/packages/accounts/services/user.ts b/web/packages/accounts/services/user.ts index abfd6ea5ed..7fb7390c91 100644 --- a/web/packages/accounts/services/user.ts +++ b/web/packages/accounts/services/user.ts @@ -5,6 +5,7 @@ import { saveKeyAttributes, saveSRPAttributes, setLSUser, + updateSavedLocalUser, type PartialLocalUser, } from "ente-accounts/services/accounts-db"; import { @@ -70,6 +71,10 @@ export interface LocalUser { * in IndexedDB and thus can also be used in web workers. */ token: string; + /** + * `true` if the TOTP based second factor is enabled for the user. + */ + isTwoFactorEnabled?: boolean; } /** @@ -664,7 +669,7 @@ export const generateAndSaveInteractiveKeyAttributes = async ( */ export const changeEmail = async (email: string, ott: string) => { await postChangeEmail(email, ott); - await setLSUser({ ...getData("user"), email }); + updateSavedLocalUser({ email }); }; /** @@ -784,7 +789,7 @@ export const setupTwoFactorFinish = async ( encryptedTwoFactorSecret: box.encryptedData, twoFactorSecretDecryptionNonce: box.nonce, }); - await setLSUser({ ...getData("user"), isTwoFactorEnabled: true }); + updateSavedLocalUser({ isTwoFactorEnabled: true }); }; interface EnableTwoFactorRequest { @@ -956,7 +961,7 @@ export const recoverTwoFactorFinish = async ( await setLSUser({ ...getData("user"), id, - isTwoFactorEnabled: false, + isTwoFactorEnabled: undefined, encryptedToken, token: undefined, }); diff --git a/web/packages/new/photos/components/sidebar/TwoFactorSettings.tsx b/web/packages/new/photos/components/sidebar/TwoFactorSettings.tsx index badbe850b6..c20f378f7b 100644 --- a/web/packages/new/photos/components/sidebar/TwoFactorSettings.tsx +++ b/web/packages/new/photos/components/sidebar/TwoFactorSettings.tsx @@ -1,6 +1,9 @@ import LockIcon from "@mui/icons-material/Lock"; import { Stack, Typography } from "@mui/material"; -import { getData, setLSUser } from "ente-accounts/services/accounts-db"; +import { + savedPartialLocalUser, + updateSavedLocalUser, +} from "ente-accounts/services/accounts-db"; import { RowButton, RowButtonGroup, @@ -24,12 +27,9 @@ export const TwoFactorSettings: React.FC< const [isTwoFactorEnabled, setIsTwoFactorEnabled] = useState(false); useEffect(() => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const isTwoFactorEnabled = - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - getData("user").isTwoFactorEnabled ?? false; - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - setIsTwoFactorEnabled(isTwoFactorEnabled); + if (savedPartialLocalUser()?.isTwoFactorEnabled) { + setIsTwoFactorEnabled(true); + } }, []); useEffect(() => { @@ -37,11 +37,7 @@ export const TwoFactorSettings: React.FC< void (async () => { const isEnabled = await get2FAStatus(); setIsTwoFactorEnabled(isEnabled); - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - await setLSUser({ - ...getData("user"), - isTwoFactorEnabled: isEnabled, - }); + updateSavedLocalUser({ isTwoFactorEnabled: isEnabled }); })(); }, [open]); @@ -112,8 +108,7 @@ const ManageDrawerContents: React.FC = ({ onRootClose }) => { const disable = async () => { await disable2FA(); - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - await setLSUser({ ...getData("user"), isTwoFactorEnabled: false }); + updateSavedLocalUser({ isTwoFactorEnabled: undefined }); onRootClose(); }; diff --git a/web/packages/new/photos/services/user-details.ts b/web/packages/new/photos/services/user-details.ts index b7805e1633..596112b8a1 100644 --- a/web/packages/new/photos/services/user-details.ts +++ b/web/packages/new/photos/services/user-details.ts @@ -1,4 +1,4 @@ -import { getData, setLSUser } from "ente-accounts/services/accounts-db"; +import { updateSavedLocalUser } from "ente-accounts/services/accounts-db"; import { ensureLocalUser } from "ente-accounts/services/user"; import { isDesktop } from "ente-base/app"; import { authenticatedRequestHeaders, ensureOk } from "ente-base/http"; @@ -241,10 +241,10 @@ export const pullUserDetails = async () => { // Update the email for the local storage user if needed (the user might've // changed their email on a different client). - if (ensureLocalUser().email != userDetails.email) { + const { email } = userDetails; + if (ensureLocalUser().email != email) { log.info("Updating user email to match fetched user details"); - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - await setLSUser({ ...getData("user"), email: userDetails.email }); + updateSavedLocalUser({ email }); } // The gallery listens for updates to userDetails, so a special case, do a From 69cf09e13dd33f02bdf3bd2833e72d10c2f0bc51 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Thu, 3 Jul 2025 18:52:33 +0530 Subject: [PATCH 10/18] Rework --- web/apps/photos/src/pages/gallery.tsx | 4 +- .../components/VerifyMasterPasswordForm.tsx | 66 +++-- .../components/utils/second-factor-choice.ts | 9 - web/packages/accounts/pages/credentials.tsx | 278 +++++++++--------- web/packages/accounts/pages/login.tsx | 14 +- web/packages/accounts/services/accounts-db.ts | 9 +- web/packages/accounts/services/session.ts | 4 +- web/packages/accounts/services/user.ts | 10 +- web/packages/base/session.ts | 4 +- web/packages/base/token.ts | 38 ++- 10 files changed, 235 insertions(+), 201 deletions(-) diff --git a/web/apps/photos/src/pages/gallery.tsx b/web/apps/photos/src/pages/gallery.tsx index fce8113997..94e5b7d366 100644 --- a/web/apps/photos/src/pages/gallery.tsx +++ b/web/apps/photos/src/pages/gallery.tsx @@ -38,7 +38,7 @@ import { haveCredentialsInSession, masterKeyFromSession, } from "ente-base/session"; -import { getAuthToken } from "ente-base/token"; +import { savedAuthToken } from "ente-base/token"; import { FullScreenDropZone } from "ente-gallery/components/FullScreenDropZone"; import { type UploadTypeSelectorIntent } from "ente-gallery/components/Upload"; import { type Collection } from "ente-media/collection"; @@ -282,7 +282,7 @@ const Page: React.FC = () => { let syncIntervalID: ReturnType | undefined; void (async () => { - if (!haveCredentialsInSession() || !(await getAuthToken())) { + if (!haveCredentialsInSession() || !(await savedAuthToken())) { // If we don't have master key or auth token, reauthenticate. stashRedirect("/gallery"); router.push("/"); diff --git a/web/packages/accounts/components/VerifyMasterPasswordForm.tsx b/web/packages/accounts/components/VerifyMasterPasswordForm.tsx index c0eaf39b00..1afb5edd76 100644 --- a/web/packages/accounts/components/VerifyMasterPasswordForm.tsx +++ b/web/packages/accounts/components/VerifyMasterPasswordForm.tsx @@ -11,15 +11,33 @@ import log from "ente-base/log"; import { useFormik } from "formik"; import { t } from "i18next"; import { useCallback, useState } from "react"; -import { twoFactorEnabledErrorMessage } from "./utils/second-factor-choice"; export interface VerifyMasterPasswordFormProps { /** * The email of the user whose password we're trying to verify. */ userEmail: string; + /** + * The user's SRP attributes. + * + * The SRP attributes are used to derive the KEK from the user's password. + * If they are not present, the {@link keyAttributes} will be used instead. + * + * At least one of {@link srpAttributes} and {@link keyAttributes} must be + * present, otherwise the verification will fail. + */ + srpAttributes?: SRPAttributes; /** * The user's key attributes. + * + * If they are present, they are used to derive the KEK from the user's + * password when {@link srpAttributes} are not present. This is the case + * when the user has already logged in (or signed up) on this client before, + * and is now doing a reauthentication. + * + * If they are not present, then {@link getKeyAttributes} must be present + * and will be used to obtain the user's key attributes. This is the case + * when the user is logging into a new client. */ keyAttributes: KeyAttributes | undefined; /** @@ -30,20 +48,18 @@ export interface VerifyMasterPasswordFormProps { * used for reauthenticating the user after they've already logged in, then * this function will not be provided. * - * @throws A Error with message {@link twoFactorEnabledErrorMessage} to - * signal to the form that some other form of second factor is enabled and - * the user has been redirected to a two factor verification page. + * @returns The user's key attributes obtained from remote, or + * "redirecting-second-factor" if the user has an additional second factor + * verification required and the app is redirecting there. * * @throws A Error with message * {@link srpVerificationUnauthorizedErrorMessage} to signal that either * that the password is incorrect, or no account with the provided email * exists. */ - getKeyAttributes?: (kek: string) => Promise; - /** - * The user's SRP attributes. - */ - srpAttributes?: SRPAttributes; + getKeyAttributes?: ( + kek: string, + ) => Promise; /** * The title of the submit button on the form. */ @@ -152,24 +168,24 @@ export const VerifyMasterPasswordForm: React.FC< } } else throw new Error("Both SRP and key attributes are missing"); - if (!keyAttributes && typeof getKeyAttributes == "function") { + if (!keyAttributes && getKeyAttributes) { try { - keyAttributes = await getKeyAttributes(kek); + const result = await getKeyAttributes(kek); + if (result == "redirecting-second-factor") { + // Two factor enabled, user has been redirected to the + // corresponding second factor verification page. + return; + } else { + keyAttributes = result; + } } catch (e) { - if (e instanceof Error) { - switch (e.message) { - case twoFactorEnabledErrorMessage: - // Two factor enabled, user has been redirected to - // the two-factor verification page. - return; - - case srpVerificationUnauthorizedErrorMessage: - log.error("Incorrect password or no account", e); - setFieldError( - t("incorrect_password_or_no_account"), - ); - return; - } + if ( + e instanceof Error && + e.message == srpVerificationUnauthorizedErrorMessage + ) { + log.error("Incorrect password or no account", e); + setFieldError(t("incorrect_password_or_no_account")); + return; } throw e; } diff --git a/web/packages/accounts/components/utils/second-factor-choice.ts b/web/packages/accounts/components/utils/second-factor-choice.ts index e2e2ca19a3..1557255ae3 100644 --- a/web/packages/accounts/components/utils/second-factor-choice.ts +++ b/web/packages/accounts/components/utils/second-factor-choice.ts @@ -8,15 +8,6 @@ import { useModalVisibility } from "ente-base/components/utils/modal"; import { useCallback, useMemo, useRef } from "react"; import type { SecondFactorType } from "../SecondFactorChoice"; -/** - * The message of the {@link Error} that is thrown when the user has enabled a - * second factor so further authentication is needed during the login sequence. - * - * TODO: This is not really an error but rather is a code flow flag; consider - * not using exceptions for flow control. - */ -export const twoFactorEnabledErrorMessage = "two factor enabled"; - /** * A convenience hook for keeping track of the state and logic that is needed * after password verification to determine which second factor (if any) we diff --git a/web/packages/accounts/pages/credentials.tsx b/web/packages/accounts/pages/credentials.tsx index 046770bc43..83866b478c 100644 --- a/web/packages/accounts/pages/credentials.tsx +++ b/web/packages/accounts/pages/credentials.tsx @@ -6,17 +6,12 @@ import { } from "ente-accounts/components/LoginComponents"; import { SecondFactorChoice } from "ente-accounts/components/SecondFactorChoice"; import { sessionExpiredDialogAttributes } from "ente-accounts/components/utils/dialog"; -import { - twoFactorEnabledErrorMessage, - useSecondFactorChoiceIfNeeded, -} from "ente-accounts/components/utils/second-factor-choice"; +import { useSecondFactorChoiceIfNeeded } from "ente-accounts/components/utils/second-factor-choice"; import { VerifyMasterPasswordForm, type VerifyMasterPasswordFormProps, } from "ente-accounts/components/VerifyMasterPasswordForm"; import { - getData, - getToken, savedIsFirstLogin, savedKeyAttributes, savedPartialLocalUser, @@ -24,7 +19,6 @@ import { saveIsFirstLogin, saveKeyAttributes, saveSRPAttributes, - setLSUser, updateSavedLocalUser, } from "ente-accounts/services/accounts-db"; import { @@ -47,13 +41,13 @@ import { import { generateAndSaveInteractiveKeyAttributes, type KeyAttributes, - type PartialLocalUser, } from "ente-accounts/services/user"; import { decryptAndStoreToken } from "ente-accounts/utils/helpers"; import { LinkButton } from "ente-base/components/LinkButton"; import { LoadingIndicator } from "ente-base/components/loaders"; import { useBaseContext } from "ente-base/context"; import { decryptBox } from "ente-base/crypto"; +import { isDevBuild } from "ente-base/env"; import { clearLocalStorage } from "ente-base/local-storage"; import log from "ente-base/log"; import { @@ -63,6 +57,7 @@ import { unstashKeyEncryptionKeyFromSession, updateSessionFromElectronSafeStorageIfNeeded, } from "ente-base/session"; +import { saveAuthToken } from "ente-base/token"; import { t } from "i18next"; import { useRouter } from "next/router"; import { useCallback, useEffect, useState } from "react"; @@ -77,19 +72,25 @@ import { useCallback, useEffect, useState } from "react"; * - Subsequent reauthentication, when the user opens the web app in a new tab. * Such a tab won't have the user's master key in session storage, so we ask * the user to reauthenticate using their password. + * + * See: [Note: Login pages] */ const Page: React.FC = () => { const { logout, showMiniDialog } = useBaseContext(); - const [user, setUser] = useState(undefined); - const [keyAttributes, setKeyAttributes] = useState(); - const [srpAttributes, setSRPAttributes] = useState(); + const [userEmail, setUserEmail] = useState(""); + const [keyAttributes, setKeyAttributes] = useState< + KeyAttributes | undefined + >(undefined); + const [srpAttributes, setSRPAttributes] = useState< + SRPAttributes | undefined + >(undefined); const [passkeyVerificationData, setPasskeyVerificationData] = useState< { passkeySessionID: string; url: string } | undefined - >(); + >(undefined); const [sessionValidityCheck, setSessionValidityCheck] = useState< Promise | undefined - >(); + >(undefined); const { secondFactorChoiceProps, @@ -128,28 +129,55 @@ const Page: React.FC = () => { } }, [logout, showMiniDialog]); + const postVerification = useCallback( + async ( + userEmail: string, + masterKey: string, + kek: string, + keyAttributes: KeyAttributes, + ) => { + await saveMasterKeyInSessionAndSafeStore(masterKey); + await decryptAndStoreToken(keyAttributes, masterKey); + try { + let srpAttributes = savedSRPAttributes(); + if (!srpAttributes) { + srpAttributes = await getSRPAttributes(userEmail); + if (srpAttributes) { + saveSRPAttributes(srpAttributes); + } else { + await setupSRP(await generateSRPSetupAttributes(kek)); + } + } + } catch (e) { + log.error("SRP migration failed", e); + } + void router.push(unstashRedirect() ?? appHomeRoute); + }, + [router], + ); + useEffect(() => { - const main = async () => { + void (async () => { const user = savedPartialLocalUser(); - if (!user?.email) { + const userEmail = user?.email; + if (!userEmail) { void router.push("/"); return; } - setUser(user); await updateSessionFromElectronSafeStorageIfNeeded(); if (await haveAuthenticatedSession()) { void router.push(appHomeRoute); return; } + + setUserEmail(userEmail); + if (user.token) setSessionValidityCheck(validateSession()); + const kek = await unstashKeyEncryptionKeyFromSession(); const keyAttributes = savedKeyAttributes(); - const srpAttributes = savedSRPAttributes(); - - if (getToken()) { - setSessionValidityCheck(validateSession()); - } + // Refreshing an existing tab, or desktop app. if (kek && keyAttributes) { const masterKey = await decryptBox( { @@ -158,15 +186,21 @@ const Page: React.FC = () => { }, kek, ); - await postVerification(masterKey, kek, keyAttributes); + await postVerification( + userEmail, + masterKey, + kek, + keyAttributes, + ); return; } + // Reauthentication in a new tab on the web app. Use previously + // generated interactive key attributes to verify password. if (keyAttributes) { - if ( - (!user?.token && !user?.encryptedToken) || - (keyAttributes && !keyAttributes.memLimit) - ) { + if (!user?.token && !user?.encryptedToken) { + // TODO(RE): Why? For now, add a dev mode circuit breaker. + if (isDevBuild) throw new Error("Unexpected case reached"); clearLocalStorage(); void router.push("/"); return; @@ -175,127 +209,103 @@ const Page: React.FC = () => { return; } + // First login on a new client. `getKeyAttributes` from below will + // be used during password verification to generate interactive key + // attributes for subsequent reauthentications. + const srpAttributes = savedSRPAttributes(); if (srpAttributes) { setSRPAttributes(srpAttributes); - } else { - void router.push("/"); + return; } - }; - void main(); - // TODO: validateSession is a dependency, but add that only after we've - // wrapped items from the callback (like logout) in useCallback too. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + + void router.push("/"); + })(); + }, [router, validateSession, postVerification]); const getKeyAttributes: VerifyMasterPasswordFormProps["getKeyAttributes"] = async (kek: string) => { - try { - // Currently the page will get reloaded if any of the attributes - // have changed, so we don't need to worry about the KEK having - // been generated using stale credentials. This await on the - // promise is here to only ensure we're done with the check - // before we let the user in. - if (sessionValidityCheck) await sessionValidityCheck; + const { + id, + keyAttributes, + token, + encryptedToken, + twoFactorSessionID, + passkeySessionID, + accountsUrl, + } = await userVerificationResultAfterResolvingSecondFactorChoice( + await verifySRP(srpAttributes!, kek), + ); - const { - keyAttributes, - encryptedToken, - token, - id, - twoFactorSessionID, + // If we had to ask remote for the key attributes, it is the initial + // login on this client. + saveIsFirstLogin(); + + if (passkeySessionID) { + await stashKeyEncryptionKeyInSessionStore(kek); + updateSavedLocalUser({ passkeySessionID }); + stashRedirect("/"); + const url = passkeyVerificationRedirectURL( + accountsUrl!, passkeySessionID, - accountsUrl, - } = - await userVerificationResultAfterResolvingSecondFactorChoice( - await verifySRP(srpAttributes!, kek), - ); - saveIsFirstLogin(); - - if (passkeySessionID) { - await stashKeyEncryptionKeyInSessionStore(kek); - updateSavedLocalUser({ passkeySessionID }); - stashRedirect("/"); - const url = passkeyVerificationRedirectURL( - accountsUrl!, - passkeySessionID, - ); - setPasskeyVerificationData({ passkeySessionID, url }); - openPasskeyVerificationURL({ passkeySessionID, url }); - throw new Error(twoFactorEnabledErrorMessage); - } else if (twoFactorSessionID) { - await stashKeyEncryptionKeyInSessionStore(kek); - updateSavedLocalUser({ - isTwoFactorEnabled: true, - twoFactorSessionID, - }); - void router.push("/two-factor/verify"); - throw new Error(twoFactorEnabledErrorMessage); - } else { - const user = getData("user"); - await setLSUser({ - ...user, - token, - encryptedToken, - id, - isTwoFactorEnabled: undefined, - twoFactorSessionID: undefined, - passkeySessionID: undefined, - }); - if (keyAttributes) saveKeyAttributes(keyAttributes); - return keyAttributes; - } - } catch (e) { - if ( - e instanceof Error && - e.message != twoFactorEnabledErrorMessage - ) { - log.error("getKeyAttributes failed", e); - } - throw e; + ); + setPasskeyVerificationData({ passkeySessionID, url }); + openPasskeyVerificationURL({ passkeySessionID, url }); + return "redirecting-second-factor"; + } else if (twoFactorSessionID) { + await stashKeyEncryptionKeyInSessionStore(kek); + updateSavedLocalUser({ + isTwoFactorEnabled: true, + twoFactorSessionID, + }); + void router.push("/two-factor/verify"); + return "redirecting-second-factor"; + } else { + // In rare cases, if the user hasn't already setup their key + // attributes, we might get the plaintext token from remote. + if (token) await saveAuthToken(token); + updateSavedLocalUser({ + id, + token, + encryptedToken, + isTwoFactorEnabled: undefined, + twoFactorSessionID: undefined, + passkeySessionID: undefined, + }); + if (keyAttributes) saveKeyAttributes(keyAttributes); + return keyAttributes; } }; const handleVerifyMasterPassword: VerifyMasterPasswordFormProps["onVerify"] = - (key, kek, keyAttributes, password) => { - void (async () => { - const updatedKeyAttributes = savedIsFirstLogin() - ? await generateAndSaveInteractiveKeyAttributes( - password, - keyAttributes, - key, - ) - : keyAttributes; - await postVerification(key, kek, updatedKeyAttributes); - })(); - }; + useCallback( + (key, kek, keyAttributes, password) => { + void (async () => { + // Currently the page will get reloaded if any of the + // attributes have changed, so we don't need to worry about + // the KEK having been generated using stale credentials. + // + // This await on the promise is here to only ensure we're + // done with the check before we let the user in. + if (sessionValidityCheck) await sessionValidityCheck; - const postVerification = async ( - masterKey: string, - kek: string, - keyAttributes: KeyAttributes, - ) => { - await saveMasterKeyInSessionAndSafeStore(masterKey); - await decryptAndStoreToken(keyAttributes, masterKey); - try { - let srpAttributes = savedSRPAttributes(); - if (!srpAttributes && user?.email) { - srpAttributes = await getSRPAttributes(user.email); - if (srpAttributes) { - saveSRPAttributes(srpAttributes); - } - } - // TODO: todo? - log.debug(() => `userSRPSetupPending ${!srpAttributes}`); - if (!srpAttributes) { - await setupSRP(await generateSRPSetupAttributes(kek)); - } - } catch (e) { - log.error("migrate to srp failed", e); - } - void router.push(unstashRedirect() ?? appHomeRoute); - }; + const updatedKeyAttributes = savedIsFirstLogin() + ? await generateAndSaveInteractiveKeyAttributes( + password, + keyAttributes, + key, + ) + : keyAttributes; - const userEmail = user?.email; + await postVerification( + userEmail, + key, + kek, + updatedKeyAttributes, + ); + })(); + }, + [postVerification, userEmail, sessionValidityCheck], + ); if (!userEmail) { return ; @@ -321,7 +331,7 @@ const Page: React.FC = () => { return ( openPasskeyVerificationURL(passkeyVerificationData) diff --git a/web/packages/accounts/pages/login.tsx b/web/packages/accounts/pages/login.tsx index b98eebaefb..2d92a600ee 100644 --- a/web/packages/accounts/pages/login.tsx +++ b/web/packages/accounts/pages/login.tsx @@ -58,6 +58,18 @@ import React, { useCallback, useEffect, useState } from "react"; * - Redirects to "/" if there is no `email` present in the saved partial * local user. * + * - Redirects to "/two-factor/verify" if saved key attributes are not present + * once password is verified and the user has setup an additional TOTP + * second factor that also needs to be verified. + * + * - Redirects to the passkey app once password is verified if saved key + * attributes are not present if the user has setup an additional passkey + * that also needs to be verified. Before redirecting, it sets the + * `inflightPasskeySessionID` in session storage. + * + * - Redirects to the `appHomeRoute` otherwise (e.g. /gallery). The flow is + * complete. + * * - "/generate" - A page that allows the user to generate key attributes if * needed, and shows them their recovery key. * @@ -112,7 +124,7 @@ import React, { useCallback, useEffect, useState } from "react"; * or either of `twoFactorSessionID` and `twoFactorSessionID` is set. * * - Redirects to "/generate" if there is an `encryptedToken` or `token` in - * the saved partial local user (TODO: Why?). + * the saved partial local user. * * - Redirects to "/credentials" after recovery. * diff --git a/web/packages/accounts/services/accounts-db.ts b/web/packages/accounts/services/accounts-db.ts index 98c4577058..18f4259922 100644 --- a/web/packages/accounts/services/accounts-db.ts +++ b/web/packages/accounts/services/accounts-db.ts @@ -19,7 +19,7 @@ import { getKVS, removeKV, setKV } from "ente-base/kv"; import log from "ente-base/log"; -import { getAuthToken } from "ente-base/token"; +import { savedAuthToken } from "ente-base/token"; import { nullToUndefined } from "ente-utils/transform"; import { z } from "zod/v4"; import { @@ -264,7 +264,7 @@ export const migrateKVToken = async (user: unknown) => { * token in local storage, then it should also be present in IndexedDB. */ export const isLocalStorageAndIndexedDBMismatch = async () => - savedPartialLocalUser()?.token && !(await getAuthToken()); + savedPartialLocalUser()?.token && !(await savedAuthToken()); /** * Return the user's {@link KeyAttributes} if they are present in local storage. @@ -381,11 +381,6 @@ export const unstashAfterUseSRPSetupAttributes = async ( localStorage.removeItem("srpSetupAttributes"); }; -export const getToken = (): string => { - const token = getData("user")?.token; - return token; -}; - /** * Zod schema for the legacy format in which the {@link savedIsFirstLogin} and * {@link savedJustSignedUp} flags were saved in local storage. diff --git a/web/packages/accounts/services/session.ts b/web/packages/accounts/services/session.ts index 414fba3378..8bfbc856eb 100644 --- a/web/packages/accounts/services/session.ts +++ b/web/packages/accounts/services/session.ts @@ -6,7 +6,7 @@ import type { KeyAttributes } from "ente-accounts/services/user"; import { authenticatedRequestHeaders, HTTPError } from "ente-base/http"; import log from "ente-base/log"; import { apiURL } from "ente-base/origins"; -import { getAuthToken } from "ente-base/token"; +import { savedAuthToken } from "ente-base/token"; import { nullToUndefined } from "ente-utils/transform"; import { z } from "zod/v4"; import { getSRPAttributes, type SRPAttributes } from "./srp"; @@ -150,7 +150,7 @@ export const checkSessionValidity = async (): Promise => { * e.g. transient network issues. */ export const isSessionInvalid = async (): Promise => { - const token = await getAuthToken(); + const token = await savedAuthToken(); if (!token) { return true; /* No saved token, session is invalid */ } diff --git a/web/packages/accounts/services/user.ts b/web/packages/accounts/services/user.ts index 7fb7390c91..23ecf7b599 100644 --- a/web/packages/accounts/services/user.ts +++ b/web/packages/accounts/services/user.ts @@ -33,7 +33,7 @@ import { ensureMasterKeyFromSession, saveMasterKeyInSessionAndSafeStore, } from "ente-base/session"; -import { getAuthToken } from "ente-base/token"; +import { savedAuthToken } from "ente-base/token"; import { ensure } from "ente-utils/ensure"; import { nullToUndefined } from "ente-utils/transform"; import { z } from "zod/v4"; @@ -67,7 +67,7 @@ export interface LocalUser { * the value of the X-Auth-Token header in the HTTP request. * * Usually you shouldn't be needing to access this property; instead use - * {@link getAuthToken()} which is kept in sync with this value, and lives + * {@link savedAuthToken()} which is kept in sync with this value, and lives * in IndexedDB and thus can also be used in web workers. */ token: string; @@ -582,9 +582,9 @@ export const verifyEmail = async ( * Log the user out on remote, if possible and needed. */ export const remoteLogoutIfNeeded = async () => { - if (!(await getAuthToken())) { - // If the logout is attempted during the signup flow itself, then we - // won't have an auth token. + if (!(await savedAuthToken())) { + // If the logout is attempted during the login / signup flow itself, + // then we won't have an auth token. Handle that gracefully. return; } diff --git a/web/packages/base/session.ts b/web/packages/base/session.ts index 0adc3003ed..704aaaca43 100644 --- a/web/packages/base/session.ts +++ b/web/packages/base/session.ts @@ -1,7 +1,7 @@ import { z } from "zod/v4"; import { decryptBox, encryptBox, generateKey } from "./crypto"; import log from "./log"; -import { getAuthToken } from "./token"; +import { savedAuthToken } from "./token"; /** * Remove all data stored in session storage (data tied to the browser tab). @@ -159,7 +159,7 @@ export const updateSessionFromElectronSafeStorageIfNeeded = async () => { * and their auth token in KV DB. */ export const haveAuthenticatedSession = async () => - (await masterKeyFromSession()) && !!(await getAuthToken()); + (await masterKeyFromSession()) && !!(await savedAuthToken()); /** * Save the user's encypted key encryption key ("key") in session store diff --git a/web/packages/base/token.ts b/web/packages/base/token.ts index f23d7866c7..87e6626118 100644 --- a/web/packages/base/token.ts +++ b/web/packages/base/token.ts @@ -1,25 +1,35 @@ -import { getKVS } from "./kv"; - -/** - * Return the user's auth token, if present. - * - * The user's auth token is stored in KV DB after they have successfully logged - * in. This function returns that saved auth token. - * - * The underlying data is stored in IndexedDB, and can be accessed from web - * workers. - */ -export const getAuthToken = () => getKVS("token"); +import { getKVS, setKV } from "./kv"; /** * Return the user's auth token, or throw an error. * - * The user's auth token can be retrieved using {@link getAuthToken}. This + * The user's auth token can be retrieved using {@link savedAuthToken}. This * function is a wrapper which throws an error if the token is not found (which * should only happen if the user is not logged in). */ export const ensureAuthToken = async () => { - const token = await getAuthToken(); + const token = await savedAuthToken(); if (!token) throw new Error("Not logged in"); return token; }; + +/** + * Return the user's auth token, if available. + * + * The user's auth token is stored in KV DB using {@link saveAuthToken} during + * the login / signup flow. This function returns that saved auth token. + * + * The underlying data is stored in IndexedDB, and can be accessed from web + * workers. + * + * If your code is running in a context where the user is already expected to be + * logged in, use {@link ensureAuthToken} instead. + */ +export const savedAuthToken = () => getKVS("token"); + +/** + * Save the user's auth token in KV DB. + * + * This is the setter corresponding to {@link savedAuthToken}. + */ +export const saveAuthToken = (token: string) => setKV("token", token); From 5c0b3795c2e602fb3a3ba0433cc8055551d66cd7 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 4 Jul 2025 07:21:41 +0530 Subject: [PATCH 11/18] Tweak --- web/apps/photos/src/pages/_app.tsx | 4 +- .../accounts/components/LoginContents.tsx | 7 +- .../accounts/components/SignUpContents.tsx | 6 + .../components/VerifyMasterPasswordForm.tsx | 5 +- web/packages/accounts/pages/credentials.tsx | 104 +++++++++--------- web/packages/accounts/pages/login.tsx | 35 +++--- web/packages/accounts/pages/verify.tsx | 34 +++--- 7 files changed, 105 insertions(+), 90 deletions(-) diff --git a/web/apps/photos/src/pages/_app.tsx b/web/apps/photos/src/pages/_app.tsx index 960f01de0b..9547992de3 100644 --- a/web/apps/photos/src/pages/_app.tsx +++ b/web/apps/photos/src/pages/_app.tsx @@ -130,7 +130,9 @@ const App: React.FC = ({ Component, pageProps }) => { if (needsFamilyRedirect && savedPartialLocalUser()?.token) redirectToFamilyPortal(); - router.events.on("routeChangeStart", () => { + router.events.on("routeChangeStart", (url) => { + if (process.env.NEXT_PUBLIC_ENTE_TRACE) console.log("route", url); + if (needsFamilyRedirect && savedPartialLocalUser()?.token) { redirectToFamilyPortal(); diff --git a/web/packages/accounts/components/LoginContents.tsx b/web/packages/accounts/components/LoginContents.tsx index f0677daa52..a741a235b5 100644 --- a/web/packages/accounts/components/LoginContents.tsx +++ b/web/packages/accounts/components/LoginContents.tsx @@ -25,9 +25,10 @@ interface LoginContentsProps { } /** - * Contents of the "login form", maintained as a separate component so that the - * same code can be used both in the standalone /login page, and also within the - * embedded login form shown on the photos index page. + * A contents of the "login" form. + * + * It is used both on the "/login" page, and as the embedded login form on the + * "/" page where the user can toggle between the signup and login forms inline. */ export const LoginContents: React.FC = ({ onSignUp, diff --git a/web/packages/accounts/components/SignUpContents.tsx b/web/packages/accounts/components/SignUpContents.tsx index 3a04d6e7e2..bf82767f64 100644 --- a/web/packages/accounts/components/SignUpContents.tsx +++ b/web/packages/accounts/components/SignUpContents.tsx @@ -55,6 +55,12 @@ interface SignUpContentsProps { host: string | undefined; } +/** + * A contents of the "signup" form. + * + * It is used both on the "/signup" page itself, and as a subcomponent of the + * "/" page where the user can toggle between the signup and login forms inline. + */ export const SignUpContents: React.FC = ({ router, onLogin, diff --git a/web/packages/accounts/components/VerifyMasterPasswordForm.tsx b/web/packages/accounts/components/VerifyMasterPasswordForm.tsx index 1afb5edd76..c974886508 100644 --- a/web/packages/accounts/components/VerifyMasterPasswordForm.tsx +++ b/web/packages/accounts/components/VerifyMasterPasswordForm.tsx @@ -58,6 +58,7 @@ export interface VerifyMasterPasswordFormProps { * exists. */ getKeyAttributes?: ( + srpAttributes: SRPAttributes, kek: string, ) => Promise; /** @@ -168,9 +169,9 @@ export const VerifyMasterPasswordForm: React.FC< } } else throw new Error("Both SRP and key attributes are missing"); - if (!keyAttributes && getKeyAttributes) { + if (!keyAttributes && getKeyAttributes && srpAttributes) { try { - const result = await getKeyAttributes(kek); + const result = await getKeyAttributes(srpAttributes, kek); if (result == "redirecting-second-factor") { // Two factor enabled, user has been redirected to the // corresponding second factor verification page. diff --git a/web/packages/accounts/pages/credentials.tsx b/web/packages/accounts/pages/credentials.tsx index 83866b478c..b94dcfcb41 100644 --- a/web/packages/accounts/pages/credentials.tsx +++ b/web/packages/accounts/pages/credentials.tsx @@ -223,58 +223,62 @@ const Page: React.FC = () => { }, [router, validateSession, postVerification]); const getKeyAttributes: VerifyMasterPasswordFormProps["getKeyAttributes"] = - async (kek: string) => { - const { - id, - keyAttributes, - token, - encryptedToken, - twoFactorSessionID, - passkeySessionID, - accountsUrl, - } = await userVerificationResultAfterResolvingSecondFactorChoice( - await verifySRP(srpAttributes!, kek), - ); - - // If we had to ask remote for the key attributes, it is the initial - // login on this client. - saveIsFirstLogin(); - - if (passkeySessionID) { - await stashKeyEncryptionKeyInSessionStore(kek); - updateSavedLocalUser({ passkeySessionID }); - stashRedirect("/"); - const url = passkeyVerificationRedirectURL( - accountsUrl!, - passkeySessionID, - ); - setPasskeyVerificationData({ passkeySessionID, url }); - openPasskeyVerificationURL({ passkeySessionID, url }); - return "redirecting-second-factor"; - } else if (twoFactorSessionID) { - await stashKeyEncryptionKeyInSessionStore(kek); - updateSavedLocalUser({ - isTwoFactorEnabled: true, - twoFactorSessionID, - }); - void router.push("/two-factor/verify"); - return "redirecting-second-factor"; - } else { - // In rare cases, if the user hasn't already setup their key - // attributes, we might get the plaintext token from remote. - if (token) await saveAuthToken(token); - updateSavedLocalUser({ + useCallback( + async (srpAttributes: SRPAttributes, kek: string) => { + const { id, + keyAttributes, token, encryptedToken, - isTwoFactorEnabled: undefined, - twoFactorSessionID: undefined, - passkeySessionID: undefined, - }); - if (keyAttributes) saveKeyAttributes(keyAttributes); - return keyAttributes; - } - }; + twoFactorSessionID, + passkeySessionID, + accountsUrl, + } = + await userVerificationResultAfterResolvingSecondFactorChoice( + await verifySRP(srpAttributes, kek), + ); + + // If we had to ask remote for the key attributes, it is the + // initial login on this client. + saveIsFirstLogin(); + + if (passkeySessionID) { + await stashKeyEncryptionKeyInSessionStore(kek); + updateSavedLocalUser({ passkeySessionID }); + stashRedirect("/"); + const url = passkeyVerificationRedirectURL( + accountsUrl!, + passkeySessionID, + ); + setPasskeyVerificationData({ passkeySessionID, url }); + openPasskeyVerificationURL({ passkeySessionID, url }); + return "redirecting-second-factor"; + } else if (twoFactorSessionID) { + await stashKeyEncryptionKeyInSessionStore(kek); + updateSavedLocalUser({ + isTwoFactorEnabled: true, + twoFactorSessionID, + }); + void router.push("/two-factor/verify"); + return "redirecting-second-factor"; + } else { + // In rare cases, if the user hasn't already setup their key + // attributes, we might get the plaintext token from remote. + if (token) await saveAuthToken(token); + updateSavedLocalUser({ + id, + token, + encryptedToken, + isTwoFactorEnabled: undefined, + twoFactorSessionID: undefined, + passkeySessionID: undefined, + }); + if (keyAttributes) saveKeyAttributes(keyAttributes); + return keyAttributes; + } + }, + [userVerificationResultAfterResolvingSecondFactorChoice, router], + ); const handleVerifyMasterPassword: VerifyMasterPasswordFormProps["onVerify"] = useCallback( @@ -341,8 +345,6 @@ const Page: React.FC = () => { ); } - // TODO: Handle the case when user is not present, or exclude that - // possibility using types. return ( diff --git a/web/packages/accounts/pages/login.tsx b/web/packages/accounts/pages/login.tsx index 2d92a600ee..2d3f37abc5 100644 --- a/web/packages/accounts/pages/login.tsx +++ b/web/packages/accounts/pages/login.tsx @@ -40,17 +40,20 @@ import React, { useCallback, useEffect, useState } from "react"; * - Redirects to "/" if there is no `email` present in the saved partial * local user. * - * - Redirects to "/credentials" if email verification is not needed, and also - * when email verification completes. + * - Redirects to "/credentials" if both saved key attributes and a `token` + * (or `encryptedToken`) in present in the saved partial local user, or if + * email verification is not needed, or when email verification completes + * and remote sent us key attributes (which will happen on login). * - * - Redirects to "/two-factor/verify" once email verification is complete if - * the user has setup an additional TOTP second factor that also needs to be - * verified. + * - Redirects to "/generate" once email verification is complete and the user + * does not have key attributes (which will happen for new signups). * - * - Redirects to the passkey app once email verification is complete if the - * user has setup an additional passkey that also needs to be verified. - * Before redirecting, it sets the `inflightPasskeySessionID` in session - * storage. + * - Redirects to "/two-factor/verify" when email verification completes and + * the user has setup a TOTP second factor that also needs to be verified. + * + * - Redirects to the passkey app when email verification completes and the + * user has setup a passkey that also needs to be verified. Before + * redirecting, `inflightPasskeySessionID` in saved in session storage. * * - "/credentials" - A page that allows the user to enter their password to * authenticate (initial login) or reauthenticate (new web app tab) @@ -59,16 +62,16 @@ import React, { useCallback, useEffect, useState } from "react"; * local user. * * - Redirects to "/two-factor/verify" if saved key attributes are not present - * once password is verified and the user has setup an additional TOTP - * second factor that also needs to be verified. + * once password is verified and the user has setup a TOTP second factor + * that also needs to be verified. * * - Redirects to the passkey app once password is verified if saved key - * attributes are not present if the user has setup an additional passkey - * that also needs to be verified. Before redirecting, it sets the - * `inflightPasskeySessionID` in session storage. + * attributes are not present if the user has setup a passkey that also + * needs to be verified. Before redirecting, `inflightPasskeySessionID` is + * saved in session storage. * - * - Redirects to the `appHomeRoute` otherwise (e.g. /gallery). The flow is - * complete. + * - Redirects to the `appHomeRoute` otherwise (e.g. /gallery). **The flow is + * complete**. * * - "/generate" - A page that allows the user to generate key attributes if * needed, and shows them their recovery key. diff --git a/web/packages/accounts/pages/verify.tsx b/web/packages/accounts/pages/verify.tsx index 9eac4d569d..12c6cfa773 100644 --- a/web/packages/accounts/pages/verify.tsx +++ b/web/packages/accounts/pages/verify.tsx @@ -46,7 +46,7 @@ import log from "ente-base/log"; import { clearSessionStorage } from "ente-base/session"; import { t } from "i18next"; import { useRouter } from "next/router"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Trans } from "react-i18next"; /** @@ -58,7 +58,9 @@ const Page: React.FC = () => { const { logout, showMiniDialog } = useBaseContext(); const [email, setEmail] = useState(""); - const [resend, setResend] = useState(0); + const [resend, setResend] = useState<"enable" | "sending" | "sent">( + "enable", + ); const [passkeyVerificationData, setPasskeyVerificationData] = useState< { passkeySessionID: string; url: string } | undefined >(); @@ -100,6 +102,7 @@ const Page: React.FC = () => { } = await userVerificationResultAfterResolvingSecondFactorChoice( await verifyEmail(email, ott, cleanedReferral), ); + if (passkeySessionID) { updateSavedLocalUser({ passkeySessionID }); saveIsFirstLogin(); @@ -137,12 +140,11 @@ const Page: React.FC = () => { await unstashAfterUseSRPSetupAttributes(setupSRP); } saveIsFirstLogin(); - const redirectURL = unstashRedirect(); - if (keyAttributes?.encryptedKey) { + if (keyAttributes) { clearSessionStorage(); - void router.push(redirectURL ?? "/credentials"); + void router.push(unstashRedirect() ?? "/credentials"); } else { - void router.push(redirectURL ?? "/generate"); + void router.push(unstashRedirect() ?? "/generate"); } } } catch (e) { @@ -157,12 +159,12 @@ const Page: React.FC = () => { } }; - const resendEmail = async () => { - setResend(1); + const resendEmail = useCallback(async () => { + setResend("sending"); await sendOTT(email, undefined); - setResend(2); - setTimeout(() => setResend(0), 3000); - }; + setResend("sent"); + setTimeout(() => setResend("enable"), 3000); + }, [email]); if (!email) { return ; @@ -225,13 +227,13 @@ const Page: React.FC = () => { /> - {resend == 0 && ( + {resend == "enable" && ( {t("resend_code")} )} - {resend == 1 && {t("status_sending")}} - {resend == 2 && {t("status_sent")}} + {resend == "sending" && {t("status_sending")}} + {resend == "sent" && {t("status_sent")}} {t("change_email")} @@ -256,9 +258,7 @@ const redirectionIfNeededOrEmail = async () => { return "/"; } - const keyAttributes = savedKeyAttributes(); - - if (keyAttributes?.encryptedKey && (user.token || user.encryptedToken)) { + if (savedKeyAttributes() && (user.token || user.encryptedToken)) { return "/credentials"; } From 70b5b8e682c56ffdec54bdbc81d2814b1a3d1a37 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 4 Jul 2025 07:30:27 +0530 Subject: [PATCH 12/18] Update --- .../accounts/components/LoginContents.tsx | 6 +++--- .../accounts/components/SignUpContents.tsx | 4 ++-- web/packages/accounts/pages/verify.tsx | 17 +++++++---------- web/packages/accounts/services/accounts-db.ts | 14 +++++++------- 4 files changed, 19 insertions(+), 22 deletions(-) diff --git a/web/packages/accounts/components/LoginContents.tsx b/web/packages/accounts/components/LoginContents.tsx index a741a235b5..f3107993f5 100644 --- a/web/packages/accounts/components/LoginContents.tsx +++ b/web/packages/accounts/components/LoginContents.tsx @@ -1,7 +1,7 @@ import { Input, Stack, TextField, Typography } from "@mui/material"; import { AccountsPageFooter } from "ente-accounts/components/layouts/centered-paper"; import { - savePartialLocalUser, + replaceSavedLocalUser, saveSRPAttributes, } from "ente-accounts/services/accounts-db"; import { getSRPAttributes } from "ente-accounts/services/srp"; @@ -51,10 +51,10 @@ export const LoginContents: React.FC = ({ } throw e; } - savePartialLocalUser({ email }); + replaceSavedLocalUser({ email }); void router.push("/verify"); } else { - savePartialLocalUser({ email }); + replaceSavedLocalUser({ email }); saveSRPAttributes(srpAttributes); void router.push("/credentials"); } diff --git a/web/packages/accounts/components/SignUpContents.tsx b/web/packages/accounts/components/SignUpContents.tsx index bf82767f64..b4c3372f06 100644 --- a/web/packages/accounts/components/SignUpContents.tsx +++ b/web/packages/accounts/components/SignUpContents.tsx @@ -14,9 +14,9 @@ import { Typography, } from "@mui/material"; import { + replaceSavedLocalUser, saveJustSignedUp, saveOriginalKeyAttributes, - savePartialLocalUser, stashReferralSource, stashSRPSetupAttributes, } from "ente-accounts/services/accounts-db"; @@ -130,7 +130,7 @@ export const SignUpContents: React.FC = ({ throw e; } - savePartialLocalUser({ email }); + replaceSavedLocalUser({ email }); let gkResult: GenerateKeysAndAttributesResult; try { diff --git a/web/packages/accounts/pages/verify.tsx b/web/packages/accounts/pages/verify.tsx index 12c6cfa773..3817b4d1fa 100644 --- a/web/packages/accounts/pages/verify.tsx +++ b/web/packages/accounts/pages/verify.tsx @@ -8,6 +8,7 @@ import { VerifyingPasskey } from "ente-accounts/components/LoginComponents"; import { SecondFactorChoice } from "ente-accounts/components/SecondFactorChoice"; import { useSecondFactorChoiceIfNeeded } from "ente-accounts/components/utils/second-factor-choice"; import { + replaceSavedLocalUser, savedKeyAttributes, savedOriginalKeyAttributes, savedPartialLocalUser, @@ -15,7 +16,6 @@ import { saveIsFirstLogin, saveKeyAttributes, saveOriginalKeyAttributes, - setLSUser, unstashAfterUseSRPSetupAttributes, unstashReferralSource, updateSavedLocalUser, @@ -44,6 +44,7 @@ import { useBaseContext } from "ente-base/context"; import { isHTTPErrorWithStatus } from "ente-base/http"; import log from "ente-base/log"; import { clearSessionStorage } from "ente-base/session"; +import { saveAuthToken } from "ente-base/token"; import { t } from "i18next"; import { useRouter } from "next/router"; import { useCallback, useEffect, useState } from "react"; @@ -103,6 +104,9 @@ const Page: React.FC = () => { await verifyEmail(email, ott, cleanedReferral), ); + // The following flow is similar to (but not the same) as what + // happens after `verifySRP` in the `/credentials` page. + if (passkeySessionID) { updateSavedLocalUser({ passkeySessionID }); saveIsFirstLogin(); @@ -120,15 +124,8 @@ const Page: React.FC = () => { saveIsFirstLogin(); void router.push("/two-factor/verify"); } else { - await setLSUser({ - email, - token, - encryptedToken, - id, - isTwoFactorEnabled: undefined, - twoFactorSessionID: undefined, - passkeySessionID: undefined, - }); + if (token) await saveAuthToken(token); + replaceSavedLocalUser({ id, email, token, encryptedToken }); if (keyAttributes) { saveKeyAttributes(keyAttributes); saveOriginalKeyAttributes(keyAttributes); diff --git a/web/packages/accounts/services/accounts-db.ts b/web/packages/accounts/services/accounts-db.ts index 18f4259922..eda8fe69e8 100644 --- a/web/packages/accounts/services/accounts-db.ts +++ b/web/packages/accounts/services/accounts-db.ts @@ -118,7 +118,7 @@ const LocalUser = z.object({ * After the user is logged in, use {@link savedLocalUser} or * {@link ensureLocalUser} instead. * - * Use {@link savePartialLocalUser} to updated the saved value. + * Use {@link replaceSavedLocalUser} to updated the saved value. */ export const savedPartialLocalUser = (): PartialLocalUser | undefined => { const jsonString = localStorage.getItem("user"); @@ -131,32 +131,32 @@ export const savedPartialLocalUser = (): PartialLocalUser | undefined => { * * See: [Note: Partial local user]. * - * This method replaces the existing data. Use {@link updatePartialLocalUser} to + * This method replaces the existing data. Use {@link updateSavedLocalUser} to * update selected fields while keeping the other fields as it is. * * TODO: WARNING: This does not update the KV token. The idea is to gradually * move over uses of setLSUser to this while explicitly setting the KV token * where needed. */ -export const savePartialLocalUser = (partialLocalUser: PartialLocalUser) => +export const replaceSavedLocalUser = (partialLocalUser: PartialLocalUser) => localStorage.setItem("user", JSON.stringify(partialLocalUser)); /** * Partially update the saved user data. * - * This is a delta variant of {@link savePartialLocalUser}, which replaces the + * This is a delta variant of {@link replaceSavedLocalUser}, which replaces the * entire saved object, while this function spreads the provided {@link updates} * onto the currently saved value. * * @param updates A subset of {@link PartialLocalUser} fields that we'd like to - * update. The other fields, if any, remain unchanged. + * update. The other fields, if present in local storage, remain unchanged. * * TODO: WARNING: This does not update the KV token. The idea is to gradually * move over uses of setLSUser to this while explicitly setting the KV token * where needed. */ export const updateSavedLocalUser = (updates: Partial) => - savePartialLocalUser({ ...savedPartialLocalUser(), ...updates }); + replaceSavedLocalUser({ ...savedPartialLocalUser(), ...updates }); /** * Return data about the logged-in user, if someone is indeed logged in. @@ -167,7 +167,7 @@ export const updateSavedLocalUser = (updates: Partial) => * not accessible to web workers. * * There is no setter corresponding to this function since this is only a view - * on data saved using {@link savePartialLocalUser} or + * on data saved using {@link replaceSavedLocalUser} or * {@link updateSavedLocalUser}. * * See: [Note: Partial local user] for more about the whole shebang. From c9521fb6262b48aefa7fcf6dc966d45d0679f23a Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 4 Jul 2025 08:08:35 +0530 Subject: [PATCH 13/18] Update --- web/apps/photos/src/pages/gallery.tsx | 6 +- .../accounts/components/utils/use-redirect.ts | 4 +- web/packages/accounts/pages/generate.tsx | 82 +++++++++---------- web/packages/accounts/pages/login.tsx | 15 ++-- web/packages/accounts/pages/recover.tsx | 4 +- web/packages/base/session.ts | 4 +- 6 files changed, 58 insertions(+), 57 deletions(-) diff --git a/web/apps/photos/src/pages/gallery.tsx b/web/apps/photos/src/pages/gallery.tsx index 94e5b7d366..f29869493a 100644 --- a/web/apps/photos/src/pages/gallery.tsx +++ b/web/apps/photos/src/pages/gallery.tsx @@ -35,7 +35,7 @@ import { useBaseContext } from "ente-base/context"; import log from "ente-base/log"; import { clearSessionStorage, - haveCredentialsInSession, + haveMasterKeyInSession, masterKeyFromSession, } from "ente-base/session"; import { savedAuthToken } from "ente-base/token"; @@ -282,7 +282,7 @@ const Page: React.FC = () => { let syncIntervalID: ReturnType | undefined; void (async () => { - if (!haveCredentialsInSession() || !(await savedAuthToken())) { + if (!haveMasterKeyInSession() || !(await savedAuthToken())) { // If we don't have master key or auth token, reauthenticate. stashRedirect("/gallery"); router.push("/"); @@ -376,7 +376,7 @@ const Page: React.FC = () => { }, [activeCollectionID, router.isReady]); useEffect(() => { - if (router.isReady && haveCredentialsInSession()) { + if (router.isReady && haveMasterKeyInSession()) { handleSubscriptionCompletionRedirectIfNeeded( showMiniDialog, showLoadingBar, diff --git a/web/packages/accounts/components/utils/use-redirect.ts b/web/packages/accounts/components/utils/use-redirect.ts index a96e79b750..de34afc717 100644 --- a/web/packages/accounts/components/utils/use-redirect.ts +++ b/web/packages/accounts/components/utils/use-redirect.ts @@ -1,4 +1,4 @@ -import { haveCredentialsInSession } from "ente-base/session"; +import { haveMasterKeyInSession } from "ente-base/session"; import { useRouter } from "next/router"; import { useEffect } from "react"; import { stashRedirect } from "../../services/redirect"; @@ -16,7 +16,7 @@ export const useRedirectIfNeedsCredentials = (currentPageSlug: string) => { const router = useRouter(); useEffect(() => { - if (!haveCredentialsInSession()) { + if (!haveMasterKeyInSession()) { stashRedirect(currentPageSlug); void router.push("/"); } diff --git a/web/packages/accounts/pages/generate.tsx b/web/packages/accounts/pages/generate.tsx index 92d3bb1900..c80fa3db7f 100644 --- a/web/packages/accounts/pages/generate.tsx +++ b/web/packages/accounts/pages/generate.tsx @@ -16,7 +16,6 @@ import { generateSRPSetupAttributes, setupSRP, } from "ente-accounts/services/srp"; -import type { PartialLocalUser } from "ente-accounts/services/user"; import { generateAndSaveInteractiveKeyAttributes, generateKeysAndAttributes, @@ -28,12 +27,12 @@ import { useBaseContext } from "ente-base/context"; import { deriveKeyInsufficientMemoryErrorMessage } from "ente-base/crypto/types"; import log from "ente-base/log"; import { - haveCredentialsInSession, + haveMasterKeyInSession, saveMasterKeyInSessionAndSafeStore, } from "ente-base/session"; import { t } from "i18next"; import { useRouter } from "next/router"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { NewPasswordForm, type NewPasswordFormProps, @@ -41,79 +40,76 @@ import { /** * A page that allows the user to generate key attributes if needed, and shows - * them their recovery key. + * them their recovery key if they just signed up. * * See: [Note: Login pages] */ const Page: React.FC = () => { const { logout, showMiniDialog } = useBaseContext(); - const [user, setUser] = useState(undefined); + const [userEmail, setUserEmail] = useState(""); const [openRecoveryKey, setOpenRecoveryKey] = useState(false); const router = useRouter(); useEffect(() => { const user = savedPartialLocalUser(); - if (!user?.token) { + if (!user?.email || !user?.token) { void router.push("/"); - } else if (haveCredentialsInSession()) { + } else if (haveMasterKeyInSession()) { if (savedJustSignedUp()) { setOpenRecoveryKey(true); - setUser(user); } else { void router.push(appHomeRoute); } - } else if (savedOriginalKeyAttributes()?.encryptedKey) { + } else if (savedOriginalKeyAttributes()) { void router.push("/credentials"); } else { - setUser(user); + setUserEmail(user.email); } }, [router]); - const handleSubmit: NewPasswordFormProps["onSubmit"] = async ( - password, - setPasswordsFieldError, - ) => { - try { - const { masterKey, kek, keyAttributes } = - await generateKeysAndAttributes(password); - await putUserKeyAttributes(keyAttributes); - await setupSRP(await generateSRPSetupAttributes(kek)); - await generateAndSaveInteractiveKeyAttributes( - password, - keyAttributes, - masterKey, - ); - await saveMasterKeyInSessionAndSafeStore(masterKey); - saveJustSignedUp(); - setOpenRecoveryKey(true); - } catch (e) { - log.error("failed to generate password", e); - setPasswordsFieldError( - e instanceof Error && - e.message == deriveKeyInsufficientMemoryErrorMessage - ? t("password_generation_failed") - : t("generic_error"), - ); - } - }; + const handleSubmit: NewPasswordFormProps["onSubmit"] = useCallback( + async (password, setPasswordsFieldError) => { + try { + const { masterKey, kek, keyAttributes } = + await generateKeysAndAttributes(password); + await putUserKeyAttributes(keyAttributes); + await setupSRP(await generateSRPSetupAttributes(kek)); + await generateAndSaveInteractiveKeyAttributes( + password, + keyAttributes, + masterKey, + ); + await saveMasterKeyInSessionAndSafeStore(masterKey); + saveJustSignedUp(); + setOpenRecoveryKey(true); + } catch (e) { + log.error("Could not generate key attributes from password", e); + setPasswordsFieldError( + e instanceof Error && + e.message == deriveKeyInsufficientMemoryErrorMessage + ? t("password_generation_failed") + : t("generic_error"), + ); + } + }, + [], + ); return ( <> - {!user ? ( - - ) : openRecoveryKey ? ( + {openRecoveryKey ? ( void router.push(appHomeRoute)} showMiniDialog={showMiniDialog} /> - ) : ( + ) : userEmail ? ( {t("set_password")} @@ -122,6 +118,8 @@ const Page: React.FC = () => { {t("go_back")} + ) : ( + )} ); diff --git a/web/packages/accounts/pages/login.tsx b/web/packages/accounts/pages/login.tsx index 2d3f37abc5..6d36a1cd84 100644 --- a/web/packages/accounts/pages/login.tsx +++ b/web/packages/accounts/pages/login.tsx @@ -74,14 +74,17 @@ import React, { useCallback, useEffect, useState } from "react"; * complete**. * * - "/generate" - A page that allows the user to generate key attributes if - * needed, and shows them their recovery key. + * needed, and shows them their recovery key if they just signed up. * - * - Redirects to "/" if there is no `email` present in the saved partial - * local user, or after viewing the recovery key, or after the user sets - * their password (if they did no have key attributes). + * - Redirects to "/" if there is no `email` or `token` present in the saved + * partial user. * - * - Redirects to "/credentials" if they already have the original key - * attributes. + * - Redirects to `appHomeRoute` after viewing the recovery key if they have a + * master key in session, or after setting the password and then viewing the + * recovery key (if they did not have a master key in session store). + * + * - Redirects to "/credentials" if if they don't have a master key in session + * store but have saved original key attributes. * * - "/recover" - A page that allows the user to recover their master key using * their recovery key. diff --git a/web/packages/accounts/pages/recover.tsx b/web/packages/accounts/pages/recover.tsx index ac91d3c9fc..edbe1e9e6a 100644 --- a/web/packages/accounts/pages/recover.tsx +++ b/web/packages/accounts/pages/recover.tsx @@ -21,7 +21,7 @@ import { useBaseContext } from "ente-base/context"; import { decryptBox } from "ente-base/crypto"; import log from "ente-base/log"; import { - haveCredentialsInSession, + haveMasterKeyInSession, saveMasterKeyInSessionAndSafeStore, } from "ente-base/session"; import { t } from "i18next"; @@ -59,7 +59,7 @@ const Page: React.FC = () => { const keyAttributes = savedKeyAttributes(); if (!keyAttributes) { void router.push("/generate"); - } else if (haveCredentialsInSession()) { + } else if (haveMasterKeyInSession()) { void router.push(appHomeRoute); } else { setKeyAttributes(keyAttributes); diff --git a/web/packages/base/session.ts b/web/packages/base/session.ts index 704aaaca43..1486c31952 100644 --- a/web/packages/base/session.ts +++ b/web/packages/base/session.ts @@ -51,7 +51,7 @@ export const ensureMasterKeyFromSession = async () => { * have credentials at hand or not, however it doesn't attempt to verify that * the key present in the session can actually be decrypted. */ -export const haveCredentialsInSession = () => +export const haveMasterKeyInSession = () => !!sessionStorage.getItem("encryptionKey"); /** @@ -140,7 +140,7 @@ export const updateSessionFromElectronSafeStorageIfNeeded = async () => { const electron = globalThis.electron; if (!electron) return; - if (haveCredentialsInSession()) return; + if (haveMasterKeyInSession()) return; let masterKey: string | undefined; try { From bbe10b1618fc6bbbee9cb44d64b592d50bce822a Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 4 Jul 2025 08:21:55 +0530 Subject: [PATCH 14/18] Update --- web/packages/accounts/pages/login.tsx | 20 ++--- .../accounts/pages/passkeys/finish.tsx | 7 +- web/packages/accounts/pages/recover.tsx | 89 ++++++++++--------- .../accounts/pages/two-factor/recover.tsx | 36 ++++---- .../accounts/pages/two-factor/verify.tsx | 2 + 5 files changed, 85 insertions(+), 69 deletions(-) diff --git a/web/packages/accounts/pages/login.tsx b/web/packages/accounts/pages/login.tsx index 6d36a1cd84..9aab2d2105 100644 --- a/web/packages/accounts/pages/login.tsx +++ b/web/packages/accounts/pages/login.tsx @@ -110,12 +110,12 @@ import React, { useCallback, useEffect, useState } from "react"; * - Redirects to "/" if there is no `email` or `twoFactorSessionID` in the * saved partial local user. * - * - Redirects to "/credentials" if there `isTwoFactorEnabled` is not `true` - * and either of `encryptedToken` or `token` is present in the saved partial + * - Redirects to "/credentials" if `isTwoFactorEnabled` is not `true` and + * either of `encryptedToken` or `token` is present in the saved partial * local user. * - * - "/passkeys/finish" - A page that the accounts app hands off control back to - * us (the calling app) to continue the rest of the authentication. + * - "/passkeys/finish" - A page where the accounts app hands off control back + * to us (the calling app) once the passkey has been verified. * * - Redirects to "/" if there is no matching `inflightPasskeySessionID` in * session storage. @@ -124,15 +124,15 @@ import React, { useCallback, useEffect, useState } from "react"; * * - "/two-factor/recover" and "/passkeys/recover" - Pages that allow the user * to reset or bypass their second factor if they possess their recovery key. - * Both pages work similarly, except the second factor they act on. + * Both pages work similarly, except for the second factor they act on. * - * - Redirects to "/" if there is no `email` in the saved partial local user, - * or either of `twoFactorSessionID` and `twoFactorSessionID` is set. + * - Redirect to "/" if there is no `email` or `twoFactorSessionID` / + * `passkeySessionID` in the saved partial local user. * - * - Redirects to "/generate" if there is an `encryptedToken` or `token` in - * the saved partial local user. + * - Redirect to "/generate" if there is an `encryptedToken` or `token` in the + * saved partial local user. * - * - Redirects to "/credentials" after recovery. + * - Redirect to "/credentials" after recovery. * */ const Page: React.FC = () => { diff --git a/web/packages/accounts/pages/passkeys/finish.tsx b/web/packages/accounts/pages/passkeys/finish.tsx index 1518a61b94..b8fd76e89c 100644 --- a/web/packages/accounts/pages/passkeys/finish.tsx +++ b/web/packages/accounts/pages/passkeys/finish.tsx @@ -13,6 +13,11 @@ import { useRouter } from "next/router"; import React, { useEffect } from "react"; /** + * The page where the accounts app hands back control to us once the passkey has + * been verified. + * + * See: [Note: Login pages] + * * [Note: Finish passkey flow in the requesting app] * * The passkey finish step needs to happen in the context of the client which @@ -23,7 +28,7 @@ const Page: React.FC = () => { const router = useRouter(); useEffect(() => { - // Extract response from query params + // Extract response from query params. const searchParams = new URLSearchParams(window.location.search); const passkeySessionID = searchParams.get("passkeySessionID"); const response = searchParams.get("response"); diff --git a/web/packages/accounts/pages/recover.tsx b/web/packages/accounts/pages/recover.tsx index edbe1e9e6a..23c789d475 100644 --- a/web/packages/accounts/pages/recover.tsx +++ b/web/packages/accounts/pages/recover.tsx @@ -26,7 +26,7 @@ import { } from "ente-base/session"; import { t } from "i18next"; import { useRouter } from "next/router"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; /** * A page that allows the user to enter their recovery key to recover their @@ -44,58 +44,63 @@ const Page: React.FC = () => { const router = useRouter(); useEffect(() => { - const user = savedPartialLocalUser(); - if (!user?.email) { - void router.push("/"); - return; - } - if (!user?.encryptedToken && !user?.token) { - void sendOTT(user.email, undefined); - stashRedirect("/recover"); - void router.push("/verify"); - return; - } + void (async () => { + const user = savedPartialLocalUser(); + if (!user?.email) { + await router.push("/"); + return; + } - const keyAttributes = savedKeyAttributes(); - if (!keyAttributes) { - void router.push("/generate"); - } else if (haveMasterKeyInSession()) { - void router.push(appHomeRoute); - } else { - setKeyAttributes(keyAttributes); - } + if (!user.encryptedToken && !user.token) { + await sendOTT(user.email, undefined); + stashRedirect("/recover"); + await router.push("/verify"); + return; + } + + const keyAttributes = savedKeyAttributes(); + if (!keyAttributes) { + await router.push("/generate"); + } else if (haveMasterKeyInSession()) { + await router.push(appHomeRoute); + } else { + setKeyAttributes(keyAttributes); + } + })(); }, [router]); - const handleSubmit: SingleInputFormProps["onSubmit"] = async ( - recoveryKeyMnemonic: string, - setFieldError, - ) => { - try { - const keyAttr = keyAttributes!; - const masterKey = await decryptBox( - { - encryptedData: keyAttr.masterKeyEncryptedWithRecoveryKey!, - nonce: keyAttr.masterKeyDecryptionNonce!, - }, - await recoveryKeyFromMnemonic(recoveryKeyMnemonic), - ); - await saveMasterKeyInSessionAndSafeStore(masterKey); - await decryptAndStoreToken(keyAttr, masterKey); + const handleSubmit: SingleInputFormProps["onSubmit"] = useCallback( + async (recoveryKeyMnemonic: string, setFieldError) => { + try { + const keyAttr = keyAttributes!; + const masterKey = await decryptBox( + { + encryptedData: + keyAttr.masterKeyEncryptedWithRecoveryKey!, + nonce: keyAttr.masterKeyDecryptionNonce!, + }, + await recoveryKeyFromMnemonic(recoveryKeyMnemonic), + ); + await saveMasterKeyInSessionAndSafeStore(masterKey); + await decryptAndStoreToken(keyAttr, masterKey); - void router.push("/change-password?op=reset"); - } catch (e) { - log.error("password recovery failed", e); - setFieldError(t("incorrect_recovery_key")); - } - }; + void router.push("/change-password?op=reset"); + } catch (e) { + log.error("Master key recovery failed", e); + setFieldError(t("incorrect_recovery_key")); + } + }, + [router, keyAttributes], + ); - const showNoRecoveryKeyMessage = () => + const showNoRecoveryKeyMessage = useCallback(() => { showMiniDialog({ title: t("sorry"), message: t("no_recovery_key_message"), continue: { color: "secondary" }, cancel: false, }); + }, [showMiniDialog]); return ( diff --git a/web/packages/accounts/pages/two-factor/recover.tsx b/web/packages/accounts/pages/two-factor/recover.tsx index caa3480661..6b17dfec6d 100644 --- a/web/packages/accounts/pages/two-factor/recover.tsx +++ b/web/packages/accounts/pages/two-factor/recover.tsx @@ -64,20 +64,23 @@ const Page: React.FC = ({ twoFactorType }) => { ); useEffect(() => { - const user = savedPartialLocalUser(); - const sessionID = - twoFactorType == "passkey" - ? user?.passkeySessionID - : user?.twoFactorSessionID; - if (!user?.email || !sessionID) { - void router.push("/"); - } else if (user.encryptedToken || user.token) { - void router.push("/generate"); - } else { - setSessionID(sessionID); - void recoverTwoFactor(twoFactorType, sessionID) - .then(setRecoveryResponse) - .catch((e: unknown) => { + void (async () => { + const user = savedPartialLocalUser(); + const sessionID = + twoFactorType == "passkey" + ? user?.passkeySessionID + : user?.twoFactorSessionID; + if (!user?.email || !sessionID) { + await router.push("/"); + } else if (user.encryptedToken || user.token) { + await router.push("/generate"); + } else { + setSessionID(sessionID); + try { + setRecoveryResponse( + await recoverTwoFactor(twoFactorType, sessionID), + ); + } catch (e) { log.error("Second factor recovery page setup failed", e); if (isHTTPErrorWithStatus(e, 404)) { logout(); @@ -86,8 +89,9 @@ const Page: React.FC = ({ twoFactorType }) => { } else { onGenericError(e); } - }); - } + } + } + })(); }, [ twoFactorType, logout, diff --git a/web/packages/accounts/pages/two-factor/verify.tsx b/web/packages/accounts/pages/two-factor/verify.tsx index 918fba2486..c11a1238a7 100644 --- a/web/packages/accounts/pages/two-factor/verify.tsx +++ b/web/packages/accounts/pages/two-factor/verify.tsx @@ -21,6 +21,8 @@ import { unstashRedirect } from "../../services/redirect"; /** * A page that allows the user to verify their TOTP based second factor. + * + * See: [Note: Login pages] */ const Page: React.FC = () => { const { logout } = useBaseContext(); From 46dc71ebd228309bcd12a578881111b1877d0b6c Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 4 Jul 2025 09:07:53 +0530 Subject: [PATCH 15/18] Rework --- web/apps/photos/src/pages/index.tsx | 8 +- web/packages/accounts/pages/credentials.tsx | 11 +-- .../accounts/pages/passkeys/finish.tsx | 14 +-- .../accounts/pages/two-factor/verify.tsx | 21 ++--- web/packages/accounts/services/passkey.ts | 11 ++- web/packages/accounts/services/user.ts | 87 ++++++++++++++++--- web/packages/accounts/utils/helpers.ts | 35 -------- web/packages/base/session.ts | 7 -- web/packages/base/token.ts | 9 +- 9 files changed, 118 insertions(+), 85 deletions(-) delete mode 100644 web/packages/accounts/utils/helpers.ts diff --git a/web/apps/photos/src/pages/index.tsx b/web/apps/photos/src/pages/index.tsx index 3ac7b0d771..d452129a18 100644 --- a/web/apps/photos/src/pages/index.tsx +++ b/web/apps/photos/src/pages/index.tsx @@ -9,9 +9,10 @@ import { FocusVisibleButton } from "ente-base/components/mui/FocusVisibleButton" import { useBaseContext } from "ente-base/context"; import { albumsAppOrigin, customAPIHost } from "ente-base/origins"; import { - haveAuthenticatedSession, + masterKeyFromSession, updateSessionFromElectronSafeStorageIfNeeded, } from "ente-base/session"; +import { savedAuthToken } from "ente-base/token"; import { canAccessIndexedDB } from "ente-gallery/services/files-db"; import { DevSettings } from "ente-new/photos/components/DevSettings"; import { t } from "i18next"; @@ -55,7 +56,10 @@ const Page: React.FC = () => { }); } else { await updateSessionFromElectronSafeStorageIfNeeded(); - if (await haveAuthenticatedSession()) { + if ( + (await masterKeyFromSession()) && + (await savedAuthToken()) + ) { await router.push("/gallery"); } else if (savedPartialLocalUser()?.email) { await router.push("/verify"); diff --git a/web/packages/accounts/pages/credentials.tsx b/web/packages/accounts/pages/credentials.tsx index b94dcfcb41..66cbd2680a 100644 --- a/web/packages/accounts/pages/credentials.tsx +++ b/web/packages/accounts/pages/credentials.tsx @@ -39,10 +39,10 @@ import { verifySRP, } from "ente-accounts/services/srp"; import { + decryptAndStoreToken, generateAndSaveInteractiveKeyAttributes, type KeyAttributes, } from "ente-accounts/services/user"; -import { decryptAndStoreToken } from "ente-accounts/utils/helpers"; import { LinkButton } from "ente-base/components/LinkButton"; import { LoadingIndicator } from "ente-base/components/loaders"; import { useBaseContext } from "ente-base/context"; @@ -51,13 +51,13 @@ import { isDevBuild } from "ente-base/env"; import { clearLocalStorage } from "ente-base/local-storage"; import log from "ente-base/log"; import { - haveAuthenticatedSession, + masterKeyFromSession, saveMasterKeyInSessionAndSafeStore, stashKeyEncryptionKeyInSessionStore, unstashKeyEncryptionKeyFromSession, updateSessionFromElectronSafeStorageIfNeeded, } from "ente-base/session"; -import { saveAuthToken } from "ente-base/token"; +import { saveAuthToken, savedAuthToken } from "ente-base/token"; import { t } from "i18next"; import { useRouter } from "next/router"; import { useCallback, useEffect, useState } from "react"; @@ -166,7 +166,7 @@ const Page: React.FC = () => { } await updateSessionFromElectronSafeStorageIfNeeded(); - if (await haveAuthenticatedSession()) { + if ((await masterKeyFromSession()) && (await savedAuthToken())) { void router.push(appHomeRoute); return; } @@ -177,7 +177,8 @@ const Page: React.FC = () => { const kek = await unstashKeyEncryptionKeyFromSession(); const keyAttributes = savedKeyAttributes(); - // Refreshing an existing tab, or desktop app. + // Refreshing an existing tab, or desktop app, or only the token + // needs to decrypted and set. if (kek && keyAttributes) { const masterKey = await decryptBox( { diff --git a/web/packages/accounts/pages/passkeys/finish.tsx b/web/packages/accounts/pages/passkeys/finish.tsx index b8fd76e89c..6d25ba5ba5 100644 --- a/web/packages/accounts/pages/passkeys/finish.tsx +++ b/web/packages/accounts/pages/passkeys/finish.tsx @@ -1,10 +1,12 @@ import { - getData, saveKeyAttributes, - setLSUser, + updateSavedLocalUser, } from "ente-accounts/services/accounts-db"; import { unstashRedirect } from "ente-accounts/services/redirect"; -import { TwoFactorAuthorizationResponse } from "ente-accounts/services/user"; +import { + resetSavedLocalUserTokens, + TwoFactorAuthorizationResponse, +} from "ente-accounts/services/user"; import { LoadingIndicator } from "ente-base/components/loaders"; import { fromB64URLSafeNoPadding } from "ente-base/crypto"; import log from "ente-base/log"; @@ -95,10 +97,8 @@ const saveQueryCredentialsAndNavigateTo = async ( const { id, keyAttributes, encryptedToken } = decodedResponse; - // TODO: See: [Note: empty token?] - const token = undefined; - - await setLSUser({ ...getData("user"), token, encryptedToken, id }); + await resetSavedLocalUserTokens(id, encryptedToken); + updateSavedLocalUser({ passkeySessionID: undefined }); saveKeyAttributes(keyAttributes); return unstashRedirect() ?? "/credentials"; diff --git a/web/packages/accounts/pages/two-factor/verify.tsx b/web/packages/accounts/pages/two-factor/verify.tsx index c11a1238a7..f56c186f8f 100644 --- a/web/packages/accounts/pages/two-factor/verify.tsx +++ b/web/packages/accounts/pages/two-factor/verify.tsx @@ -1,11 +1,13 @@ import { Verify2FACodeForm } from "ente-accounts/components/Verify2FACodeForm"; import { - getData, savedPartialLocalUser, saveKeyAttributes, - setLSUser, + updateSavedLocalUser, } from "ente-accounts/services/accounts-db"; -import { verifyTwoFactor } from "ente-accounts/services/user"; +import { + resetSavedLocalUserTokens, + verifyTwoFactor, +} from "ente-accounts/services/user"; import { LinkButton } from "ente-base/components/LinkButton"; import { useBaseContext } from "ente-base/context"; import { isHTTPErrorWithStatus } from "ente-base/http"; @@ -50,17 +52,8 @@ const Page: React.FC = () => { try { const { keyAttributes, encryptedToken, id } = await verifyTwoFactor(otp, twoFactorSessionID); - await setLSUser({ - ...getData("user"), - id, - // TODO: [Note: empty token?] - // - // The original code was parsing an token which is never going - // to be present in the response, so effectively was always - // setting token to undefined. So this works, but is it needed? - token: undefined, - encryptedToken, - }); + await resetSavedLocalUserTokens(id, encryptedToken); + updateSavedLocalUser({ twoFactorSessionID: undefined }); saveKeyAttributes(keyAttributes); await router.push(unstashRedirect() ?? "/credentials"); } catch (e) { diff --git a/web/packages/accounts/services/passkey.ts b/web/packages/accounts/services/passkey.ts index 52f39ab7ec..b3cef840a1 100644 --- a/web/packages/accounts/services/passkey.ts +++ b/web/packages/accounts/services/passkey.ts @@ -1,9 +1,11 @@ import { - getData, saveKeyAttributes, - setLSUser, + updateSavedLocalUser, } from "ente-accounts/services/accounts-db"; -import { TwoFactorAuthorizationResponse } from "ente-accounts/services/user"; +import { + resetSavedLocalUserTokens, + TwoFactorAuthorizationResponse, +} from "ente-accounts/services/user"; import { clientPackageName, isDesktop } from "ente-base/app"; import { encryptBox, generateKey } from "ente-base/crypto"; import { @@ -260,7 +262,8 @@ export const saveCredentialsAndNavigateTo = async ( const { id, encryptedToken, keyAttributes } = response; - await setLSUser({ ...getData("user"), encryptedToken, id }); + await resetSavedLocalUserTokens(id, encryptedToken); + updateSavedLocalUser({ passkeySessionID: undefined }); saveKeyAttributes(keyAttributes); return unstashRedirect() ?? "/credentials"; diff --git a/web/packages/accounts/services/user.ts b/web/packages/accounts/services/user.ts index 23ecf7b599..1729bce23b 100644 --- a/web/packages/accounts/services/user.ts +++ b/web/packages/accounts/services/user.ts @@ -1,12 +1,13 @@ import { getData, + replaceSavedLocalUser, savedKeyAttributes, savedLocalUser, + savedPartialLocalUser, saveKeyAttributes, saveSRPAttributes, setLSUser, updateSavedLocalUser, - type PartialLocalUser, } from "ente-accounts/services/accounts-db"; import { generateSRPSetupAttributes, @@ -15,12 +16,14 @@ import { type UpdatedKeyAttr, } from "ente-accounts/services/srp"; import { + boxSealOpenBytes, decryptBox, deriveInteractiveKey, deriveSensitiveKey, encryptBox, generateKey, generateKeyPair, + toB64URLSafe, } from "ente-base/crypto"; import { isDevBuild } from "ente-base/env"; import { @@ -33,15 +36,12 @@ import { ensureMasterKeyFromSession, saveMasterKeyInSessionAndSafeStore, } from "ente-base/session"; -import { savedAuthToken } from "ente-base/token"; +import { removeAuthToken, savedAuthToken } from "ente-base/token"; import { ensure } from "ente-utils/ensure"; import { nullToUndefined } from "ente-utils/transform"; import { z } from "zod/v4"; import { getUserRecoveryKey, recoveryKeyFromMnemonic } from "./recovery-key"; -// TODO(RE): Temporary re-export -export type { PartialLocalUser }; - /** * The locally persisted data we have about the user after they've logged in. * @@ -738,6 +738,74 @@ export const changePassword = async (password: string) => { } }; +/** + * Update the {@link encryptedToken} present in the saved partial local user. + * + * This function removes the {@link token}, if any, present in the saved partial + * local user and sets the provided {@link encryptedToken}. + * + * It is expected that the code will subsequently redirect to "/credentials", + * which should call {@link decryptAndStoreToken} which will decrypt the newly + * set {@link encryptedToken} and write out the decrypted value as the + * {@link token} in the saved local user. + * + * @param userID The ID of the user whose token this is. This is used as a + * sanity check to ensure that the we do not overwrite saved partial local user + * data for a different user. + * + * @param encryptedToken The newly obtained base64 encoded encrypted token from + * remote (e.g. as a result of the user verifying their email). + */ +export const resetSavedLocalUserTokens = async ( + userID: number, + encryptedToken: string, +) => { + const user = savedPartialLocalUser(); + if (user?.id && user.id != userID) { + throw new Error(`User ID mismatch (${user.id}, ${userID})`); + } + replaceSavedLocalUser({ ...user, token: undefined, encryptedToken }); + return removeAuthToken(); +}; + +/** + * Decrypt the user's {@link encryptedToken}, if present, and use it to update + * both the locally saved user and the KV DB. + * + * @param keyAttributes The user's key attributes. + * + * @param masterKey The user's master key (base64 encoded). + */ +export const decryptAndStoreToken = async ( + keyAttributes: KeyAttributes, + masterKey: string, +) => { + const user = getData("user"); + const { encryptedToken } = user; + + if (encryptedToken && encryptedToken.length > 0) { + const { encryptedSecretKey, secretKeyDecryptionNonce, publicKey } = + keyAttributes; + const privateKey = await decryptBox( + { + encryptedData: encryptedSecretKey, + nonce: secretKeyDecryptionNonce, + }, + masterKey, + ); + + const decryptedToken = await toB64URLSafe( + await boxSealOpenBytes(encryptedToken, { publicKey, privateKey }), + ); + + await setLSUser({ + ...user, + token: decryptedToken, + encryptedToken: null, + }); + } +}; + const TwoFactorSecret = z.object({ /** * The 2FA secret code. @@ -958,12 +1026,11 @@ export const recoverTwoFactorFinish = async ( sessionID, twoFactorSecret, ); - await setLSUser({ - ...getData("user"), - id, + await resetSavedLocalUserTokens(id, encryptedToken); + updateSavedLocalUser({ isTwoFactorEnabled: undefined, - encryptedToken, - token: undefined, + twoFactorSessionID: undefined, + passkeySessionID: undefined, }); saveKeyAttributes(keyAttributes); }; diff --git a/web/packages/accounts/utils/helpers.ts b/web/packages/accounts/utils/helpers.ts deleted file mode 100644 index 9a980587f4..0000000000 --- a/web/packages/accounts/utils/helpers.ts +++ /dev/null @@ -1,35 +0,0 @@ -// TODO: Audit this file, this can be better. e.g. do we need the Object.assign? - -import { getData, setLSUser } from "ente-accounts/services/accounts-db"; -import { type KeyAttributes } from "ente-accounts/services/user"; -import { boxSealOpenBytes, decryptBox, toB64URLSafe } from "ente-base/crypto"; - -export async function decryptAndStoreToken( - keyAttributes: KeyAttributes, - masterKey: string, -) { - const user = getData("user"); - const { encryptedToken } = user; - - if (encryptedToken && encryptedToken.length > 0) { - const { encryptedSecretKey, secretKeyDecryptionNonce, publicKey } = - keyAttributes; - const privateKey = await decryptBox( - { - encryptedData: encryptedSecretKey, - nonce: secretKeyDecryptionNonce, - }, - masterKey, - ); - - const decryptedToken = await toB64URLSafe( - await boxSealOpenBytes(encryptedToken, { publicKey, privateKey }), - ); - - await setLSUser({ - ...user, - token: decryptedToken, - encryptedToken: null, - }); - } -} diff --git a/web/packages/base/session.ts b/web/packages/base/session.ts index 1486c31952..0d94ae0472 100644 --- a/web/packages/base/session.ts +++ b/web/packages/base/session.ts @@ -154,13 +154,6 @@ export const updateSessionFromElectronSafeStorageIfNeeded = async () => { } }; -/** - * Return true if we both have a usable user's master key in session storage, - * and their auth token in KV DB. - */ -export const haveAuthenticatedSession = async () => - (await masterKeyFromSession()) && !!(await savedAuthToken()); - /** * Save the user's encypted key encryption key ("key") in session store * temporarily, until we get back here after completing the second factor. diff --git a/web/packages/base/token.ts b/web/packages/base/token.ts index 87e6626118..109322b339 100644 --- a/web/packages/base/token.ts +++ b/web/packages/base/token.ts @@ -1,4 +1,4 @@ -import { getKVS, setKV } from "./kv"; +import { getKVS, removeKV, setKV } from "./kv"; /** * Return the user's auth token, or throw an error. @@ -33,3 +33,10 @@ export const savedAuthToken = () => getKVS("token"); * This is the setter corresponding to {@link savedAuthToken}. */ export const saveAuthToken = (token: string) => setKV("token", token); + +/** + * Remove the user's auth token from KV DB. + * + * See {@link saveAuthToken}. + */ +export const removeAuthToken = () => removeKV("token"); From 7dabd9545eba4e81fd03c0ea0baed06b9c025af7 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 4 Jul 2025 09:21:19 +0530 Subject: [PATCH 16/18] Fin --- web/packages/accounts/pages/recover.tsx | 3 +- web/packages/accounts/services/accounts-db.ts | 94 +++---------------- web/packages/accounts/services/user.ts | 60 +++++------- web/packages/base/session.ts | 1 - 4 files changed, 36 insertions(+), 122 deletions(-) diff --git a/web/packages/accounts/pages/recover.tsx b/web/packages/accounts/pages/recover.tsx index 23c789d475..104709b91b 100644 --- a/web/packages/accounts/pages/recover.tsx +++ b/web/packages/accounts/pages/recover.tsx @@ -10,8 +10,7 @@ import { import { recoveryKeyFromMnemonic } from "ente-accounts/services/recovery-key"; import { appHomeRoute, stashRedirect } from "ente-accounts/services/redirect"; import type { KeyAttributes } from "ente-accounts/services/user"; -import { sendOTT } from "ente-accounts/services/user"; -import { decryptAndStoreToken } from "ente-accounts/utils/helpers"; +import { decryptAndStoreToken, sendOTT } from "ente-accounts/services/user"; import { LinkButton } from "ente-base/components/LinkButton"; import { SingleInputForm, diff --git a/web/packages/accounts/services/accounts-db.ts b/web/packages/accounts/services/accounts-db.ts index eda8fe69e8..d2af23aee8 100644 --- a/web/packages/accounts/services/accounts-db.ts +++ b/web/packages/accounts/services/accounts-db.ts @@ -17,8 +17,6 @@ * - "srpAttributes" */ -import { getKVS, removeKV, setKV } from "ente-base/kv"; -import log from "ente-base/log"; import { savedAuthToken } from "ente-base/token"; import { nullToUndefined } from "ente-utils/transform"; import { z } from "zod/v4"; @@ -123,7 +121,9 @@ const LocalUser = z.object({ export const savedPartialLocalUser = (): PartialLocalUser | undefined => { const jsonString = localStorage.getItem("user"); if (!jsonString) return undefined; - return PartialLocalUser.parse(JSON.parse(jsonString)); + const result = PartialLocalUser.parse(JSON.parse(jsonString)); + void ensureTokensMatch(result); + return result; }; /** @@ -133,10 +133,6 @@ export const savedPartialLocalUser = (): PartialLocalUser | undefined => { * * This method replaces the existing data. Use {@link updateSavedLocalUser} to * update selected fields while keeping the other fields as it is. - * - * TODO: WARNING: This does not update the KV token. The idea is to gradually - * move over uses of setLSUser to this while explicitly setting the KV token - * where needed. */ export const replaceSavedLocalUser = (partialLocalUser: PartialLocalUser) => localStorage.setItem("user", JSON.stringify(partialLocalUser)); @@ -150,10 +146,6 @@ export const replaceSavedLocalUser = (partialLocalUser: PartialLocalUser) => * * @param updates A subset of {@link PartialLocalUser} fields that we'd like to * update. The other fields, if present in local storage, remain unchanged. - * - * TODO: WARNING: This does not update the KV token. The idea is to gradually - * move over uses of setLSUser to this while explicitly setting the KV token - * where needed. */ export const updateSavedLocalUser = (updates: Partial) => replaceSavedLocalUser({ ...savedPartialLocalUser(), ...updates }); @@ -177,84 +169,20 @@ export const savedLocalUser = (): LocalUser | undefined => { if (!jsonString) return undefined; // We might have some data, but not all of it. So do a non-throwing parse. const { success, data } = LocalUser.safeParse(JSON.parse(jsonString)); + if (success) void ensureTokensMatch(data); return success ? data : undefined; }; -export type LocalStorageKey = "user"; - -export const getData = (key: LocalStorageKey) => { - try { - if ( - typeof localStorage == "undefined" || - typeof key == "undefined" || - typeof localStorage.getItem(key) == "undefined" || - localStorage.getItem(key) == "undefined" - ) { - return null; - } - const data = localStorage.getItem(key); - return data && JSON.parse(data); - } catch (e) { - log.error(`Failed to Parse JSON for key ${key}`, e); - } -}; - -export const setData = (key: LocalStorageKey, value: object) => - localStorage.setItem(key, JSON.stringify(value)); - -// TODO: Migrate this to `local-user.ts`, with (a) more precise optionality -// indication of the constituent fields, (b) moving any fields that need to be -// accessed from web workers to KV DB. -// -// Creating a new function here to act as a funnel point. -export const setLSUser = async (user: object) => { - await migrateKVToken(user); - setData("user", user); -}; - /** - * Update the "token" KV with the token (if any) for the given {@link user}. + * Sanity check to ensure that KV token and local storage token are the same. * - * This is an internal implementation details of {@link setLSUser} and doesn't - * need to exposed conceptually. For now though, we need to call this externally - * at an early point in the app startup to also copy over the token into KV DB - * for existing users. - * - * This was added 1 July 2024, can be removed after a while and this code - * inlined into `setLSUser` (tag: Migration). + * TODO: Added July 2025, can just be removed soon, there is already a sanity + * check `isLocalStorageAndIndexedDBMismatch` on app start (tag: Migration). */ -export const migrateKVToken = async (user: unknown) => { - // Throw an error if the data is in local storage but not in IndexedDB. This - // is a pre-cursor to inlining this code. - // TODO: Remove this sanity check eventually when this code is revisited. - const oldLSUser = getData("user"); - const wasMissing = - oldLSUser && - typeof oldLSUser == "object" && - "token" in oldLSUser && - typeof oldLSUser.token == "string" && - !(await getKVS("token")); - - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - user && - typeof user == "object" && - "id" in user && - typeof user.id == "number" - ? await setKV("userID", user.id) - : await removeKV("userID"); - - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - user && - typeof user == "object" && - "token" in user && - typeof user.token == "string" - ? await setKV("token", user.token) - : await removeKV("token"); - - if (wasMissing) - throw new Error( - "The user's token was present in local storage but not in IndexedDB", - ); +export const ensureTokensMatch = async (user: PartialLocalUser | undefined) => { + if (user?.token !== (await savedAuthToken())) { + throw new Error("Token mismatch"); + } }; /** diff --git a/web/packages/accounts/services/user.ts b/web/packages/accounts/services/user.ts index 1729bce23b..8c00ddf100 100644 --- a/web/packages/accounts/services/user.ts +++ b/web/packages/accounts/services/user.ts @@ -1,12 +1,10 @@ import { - getData, replaceSavedLocalUser, savedKeyAttributes, savedLocalUser, savedPartialLocalUser, saveKeyAttributes, saveSRPAttributes, - setLSUser, updateSavedLocalUser, } from "ente-accounts/services/accounts-db"; import { @@ -25,18 +23,19 @@ import { generateKeyPair, toB64URLSafe, } from "ente-base/crypto"; -import { isDevBuild } from "ente-base/env"; import { authenticatedRequestHeaders, ensureOk, publicRequestHeaders, } from "ente-base/http"; +import log from "ente-base/log"; import { apiURL } from "ente-base/origins"; +import { ensureMasterKeyFromSession } from "ente-base/session"; import { - ensureMasterKeyFromSession, - saveMasterKeyInSessionAndSafeStore, -} from "ente-base/session"; -import { removeAuthToken, savedAuthToken } from "ente-base/token"; + removeAuthToken, + saveAuthToken, + savedAuthToken, +} from "ente-base/token"; import { ensure } from "ente-utils/ensure"; import { nullToUndefined } from "ente-utils/transform"; import { z } from "zod/v4"; @@ -730,12 +729,6 @@ export const changePassword = async (password: string) => { { ...keyAttributes, ...updatedKeyAttr }, masterKey, ); - - // TODO(RE): This shouldn't be needed, remove me. As a soft remove, - // disabling it for dev builds. (tag: Migration) - if (!isDevBuild) { - await saveMasterKeyInSessionAndSafeStore(masterKey); - } }; /** @@ -780,30 +773,25 @@ export const decryptAndStoreToken = async ( keyAttributes: KeyAttributes, masterKey: string, ) => { - const user = getData("user"); - const { encryptedToken } = user; - - if (encryptedToken && encryptedToken.length > 0) { - const { encryptedSecretKey, secretKeyDecryptionNonce, publicKey } = - keyAttributes; - const privateKey = await decryptBox( - { - encryptedData: encryptedSecretKey, - nonce: secretKeyDecryptionNonce, - }, - masterKey, - ); - - const decryptedToken = await toB64URLSafe( - await boxSealOpenBytes(encryptedToken, { publicKey, privateKey }), - ); - - await setLSUser({ - ...user, - token: decryptedToken, - encryptedToken: null, - }); + const { encryptedToken } = savedPartialLocalUser() ?? {}; + if (!encryptedToken) { + log.info("Skipping token decryption (no encrypted token found)"); + return; } + + const { encryptedSecretKey, secretKeyDecryptionNonce, publicKey } = + keyAttributes; + const privateKey = await decryptBox( + { encryptedData: encryptedSecretKey, nonce: secretKeyDecryptionNonce }, + masterKey, + ); + + const token = await toB64URLSafe( + await boxSealOpenBytes(encryptedToken, { publicKey, privateKey }), + ); + + updateSavedLocalUser({ token, encryptedToken: undefined }); + return saveAuthToken(token); }; const TwoFactorSecret = z.object({ diff --git a/web/packages/base/session.ts b/web/packages/base/session.ts index 0d94ae0472..077e6d3271 100644 --- a/web/packages/base/session.ts +++ b/web/packages/base/session.ts @@ -1,7 +1,6 @@ import { z } from "zod/v4"; import { decryptBox, encryptBox, generateKey } from "./crypto"; import log from "./log"; -import { savedAuthToken } from "./token"; /** * Remove all data stored in session storage (data tied to the browser tab). From a2072c022c3ab1285aa5979656c3db93cfdf899d Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 4 Jul 2025 09:36:11 +0530 Subject: [PATCH 17/18] Don't add a history entry for automated client routing redirects --- web/packages/accounts/components/LoginContents.tsx | 10 +++++++--- web/packages/accounts/pages/change-password.tsx | 2 +- web/packages/accounts/pages/credentials.tsx | 8 ++++---- web/packages/accounts/pages/generate.tsx | 6 +++--- web/packages/accounts/pages/login.tsx | 2 +- web/packages/accounts/pages/passkeys/finish.tsx | 2 +- web/packages/accounts/pages/recover.tsx | 8 ++++---- web/packages/accounts/pages/signup.tsx | 2 +- web/packages/accounts/pages/two-factor/recover.tsx | 4 ++-- web/packages/accounts/pages/two-factor/verify.tsx | 4 ++-- web/packages/accounts/pages/verify.tsx | 2 +- 11 files changed, 27 insertions(+), 23 deletions(-) diff --git a/web/packages/accounts/components/LoginContents.tsx b/web/packages/accounts/components/LoginContents.tsx index f3107993f5..8b4b471d08 100644 --- a/web/packages/accounts/components/LoginContents.tsx +++ b/web/packages/accounts/components/LoginContents.tsx @@ -18,10 +18,14 @@ import { z } from "zod/v4"; import { AccountsPageTitleWithCaption } from "./LoginComponents"; interface LoginContentsProps { - /** Called when the user clicks the signup option instead. */ - onSignUp: () => void; - /** Reactive value of {@link customAPIHost}. */ + /** + * Reactive value of {@link customAPIHost}. + */ host: string | undefined; + /** + * Called when the user clicks the signup option instead. + */ + onSignUp: () => void; } /** diff --git a/web/packages/accounts/pages/change-password.tsx b/web/packages/accounts/pages/change-password.tsx index c05ed5f527..88b67b42a9 100644 --- a/web/packages/accounts/pages/change-password.tsx +++ b/web/packages/accounts/pages/change-password.tsx @@ -38,7 +38,7 @@ const Page: React.FC = () => { setUser(user); } else { stashRedirect("/change-password"); - void router.push("/"); + void router.replace("/"); } }, [router]); diff --git a/web/packages/accounts/pages/credentials.tsx b/web/packages/accounts/pages/credentials.tsx index 66cbd2680a..17d4f6afd4 100644 --- a/web/packages/accounts/pages/credentials.tsx +++ b/web/packages/accounts/pages/credentials.tsx @@ -161,13 +161,13 @@ const Page: React.FC = () => { const user = savedPartialLocalUser(); const userEmail = user?.email; if (!userEmail) { - void router.push("/"); + await router.replace("/"); return; } await updateSessionFromElectronSafeStorageIfNeeded(); if ((await masterKeyFromSession()) && (await savedAuthToken())) { - void router.push(appHomeRoute); + await router.replace(appHomeRoute); return; } @@ -203,7 +203,7 @@ const Page: React.FC = () => { // TODO(RE): Why? For now, add a dev mode circuit breaker. if (isDevBuild) throw new Error("Unexpected case reached"); clearLocalStorage(); - void router.push("/"); + void router.replace("/"); return; } setKeyAttributes(keyAttributes); @@ -219,7 +219,7 @@ const Page: React.FC = () => { return; } - void router.push("/"); + void router.replace("/"); })(); }, [router, validateSession, postVerification]); diff --git a/web/packages/accounts/pages/generate.tsx b/web/packages/accounts/pages/generate.tsx index c80fa3db7f..d0b900dc45 100644 --- a/web/packages/accounts/pages/generate.tsx +++ b/web/packages/accounts/pages/generate.tsx @@ -55,15 +55,15 @@ const Page: React.FC = () => { useEffect(() => { const user = savedPartialLocalUser(); if (!user?.email || !user?.token) { - void router.push("/"); + void router.replace("/"); } else if (haveMasterKeyInSession()) { if (savedJustSignedUp()) { setOpenRecoveryKey(true); } else { - void router.push(appHomeRoute); + void router.replace(appHomeRoute); } } else if (savedOriginalKeyAttributes()) { - void router.push("/credentials"); + void router.replace("/credentials"); } else { setUserEmail(user.email); } diff --git a/web/packages/accounts/pages/login.tsx b/web/packages/accounts/pages/login.tsx index 9aab2d2105..bfa311a03a 100644 --- a/web/packages/accounts/pages/login.tsx +++ b/web/packages/accounts/pages/login.tsx @@ -143,7 +143,7 @@ const Page: React.FC = () => { useEffect(() => { void customAPIHost().then(setHost); - if (savedPartialLocalUser()?.email) void router.push("/verify"); + if (savedPartialLocalUser()?.email) void router.replace("/verify"); setLoading(false); }, [router]); diff --git a/web/packages/accounts/pages/passkeys/finish.tsx b/web/packages/accounts/pages/passkeys/finish.tsx index 6d25ba5ba5..f695012368 100644 --- a/web/packages/accounts/pages/passkeys/finish.tsx +++ b/web/packages/accounts/pages/passkeys/finish.tsx @@ -37,7 +37,7 @@ const Page: React.FC = () => { if (!passkeySessionID || !response) return; void saveQueryCredentialsAndNavigateTo(passkeySessionID, response).then( - (slug) => router.push(slug), + (slug) => router.replace(slug), ); }, [router]); diff --git a/web/packages/accounts/pages/recover.tsx b/web/packages/accounts/pages/recover.tsx index 104709b91b..aaace99bc6 100644 --- a/web/packages/accounts/pages/recover.tsx +++ b/web/packages/accounts/pages/recover.tsx @@ -46,22 +46,22 @@ const Page: React.FC = () => { void (async () => { const user = savedPartialLocalUser(); if (!user?.email) { - await router.push("/"); + await router.replace("/"); return; } if (!user.encryptedToken && !user.token) { await sendOTT(user.email, undefined); stashRedirect("/recover"); - await router.push("/verify"); + await router.replace("/verify"); return; } const keyAttributes = savedKeyAttributes(); if (!keyAttributes) { - await router.push("/generate"); + await router.replace("/generate"); } else if (haveMasterKeyInSession()) { - await router.push(appHomeRoute); + await router.replace(appHomeRoute); } else { setKeyAttributes(keyAttributes); } diff --git a/web/packages/accounts/pages/signup.tsx b/web/packages/accounts/pages/signup.tsx index cff9575c76..5d06da4dad 100644 --- a/web/packages/accounts/pages/signup.tsx +++ b/web/packages/accounts/pages/signup.tsx @@ -19,7 +19,7 @@ const Page: React.FC = () => { useEffect(() => { void customAPIHost().then(setHost); - if (savedPartialLocalUser()?.email) void router.push("/verify"); + if (savedPartialLocalUser()?.email) void router.replace("/verify"); setLoading(false); }, [router]); diff --git a/web/packages/accounts/pages/two-factor/recover.tsx b/web/packages/accounts/pages/two-factor/recover.tsx index 6b17dfec6d..c92097ca16 100644 --- a/web/packages/accounts/pages/two-factor/recover.tsx +++ b/web/packages/accounts/pages/two-factor/recover.tsx @@ -71,9 +71,9 @@ const Page: React.FC = ({ twoFactorType }) => { ? user?.passkeySessionID : user?.twoFactorSessionID; if (!user?.email || !sessionID) { - await router.push("/"); + await router.replace("/"); } else if (user.encryptedToken || user.token) { - await router.push("/generate"); + await router.replace("/generate"); } else { setSessionID(sessionID); try { diff --git a/web/packages/accounts/pages/two-factor/verify.tsx b/web/packages/accounts/pages/two-factor/verify.tsx index f56c186f8f..e0038ef9ae 100644 --- a/web/packages/accounts/pages/two-factor/verify.tsx +++ b/web/packages/accounts/pages/two-factor/verify.tsx @@ -36,12 +36,12 @@ const Page: React.FC = () => { useEffect(() => { const user = savedPartialLocalUser(); if (!user?.email || !user.twoFactorSessionID) { - void router.push("/"); + void router.replace("/"); } else if ( !user.isTwoFactorEnabled && (user.encryptedToken || user.token) ) { - void router.push("/credentials"); + void router.replace("/credentials"); } else { setTwoFactorSessionID(user.twoFactorSessionID); } diff --git a/web/packages/accounts/pages/verify.tsx b/web/packages/accounts/pages/verify.tsx index 3817b4d1fa..6a1806c41e 100644 --- a/web/packages/accounts/pages/verify.tsx +++ b/web/packages/accounts/pages/verify.tsx @@ -76,7 +76,7 @@ const Page: React.FC = () => { useEffect(() => { void redirectionIfNeededOrEmail().then((redirectOrEmail) => { if (typeof redirectOrEmail == "string") { - void router.push(redirectOrEmail); + void router.replace(redirectOrEmail); } else { setEmail(redirectOrEmail.email); } From 94b4c6b0bb1e517c261185123b1478fa726375ad Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 4 Jul 2025 09:42:42 +0530 Subject: [PATCH 18/18] Touchups Writing the ID is necessary, the first time we get is when e.g. the pk is verified. --- web/apps/photos/src/pages/_app.tsx | 4 +- web/packages/accounts/pages/credentials.tsx | 4 +- .../accounts/pages/passkeys/finish.tsx | 3 +- web/packages/accounts/pages/recover.tsx | 7 ++- .../accounts/pages/two-factor/recover.tsx | 4 +- web/packages/accounts/services/passkey.ts | 12 ++++- web/packages/accounts/services/user.ts | 45 ++++++++++--------- 7 files changed, 50 insertions(+), 29 deletions(-) diff --git a/web/apps/photos/src/pages/_app.tsx b/web/apps/photos/src/pages/_app.tsx index 9547992de3..5458061407 100644 --- a/web/apps/photos/src/pages/_app.tsx +++ b/web/apps/photos/src/pages/_app.tsx @@ -131,7 +131,9 @@ const App: React.FC = ({ Component, pageProps }) => { redirectToFamilyPortal(); router.events.on("routeChangeStart", (url) => { - if (process.env.NEXT_PUBLIC_ENTE_TRACE) console.log("route", url); + if (process.env.NEXT_PUBLIC_ENTE_TRACE_RT) { + log.debug(() => ["route", url]); + } if (needsFamilyRedirect && savedPartialLocalUser()?.token) { redirectToFamilyPortal(); diff --git a/web/packages/accounts/pages/credentials.tsx b/web/packages/accounts/pages/credentials.tsx index 17d4f6afd4..6f5b710261 100644 --- a/web/packages/accounts/pages/credentials.tsx +++ b/web/packages/accounts/pages/credentials.tsx @@ -39,7 +39,7 @@ import { verifySRP, } from "ente-accounts/services/srp"; import { - decryptAndStoreToken, + decryptAndStoreTokenIfNeeded, generateAndSaveInteractiveKeyAttributes, type KeyAttributes, } from "ente-accounts/services/user"; @@ -137,7 +137,7 @@ const Page: React.FC = () => { keyAttributes: KeyAttributes, ) => { await saveMasterKeyInSessionAndSafeStore(masterKey); - await decryptAndStoreToken(keyAttributes, masterKey); + await decryptAndStoreTokenIfNeeded(keyAttributes, masterKey); try { let srpAttributes = savedSRPAttributes(); if (!srpAttributes) { diff --git a/web/packages/accounts/pages/passkeys/finish.tsx b/web/packages/accounts/pages/passkeys/finish.tsx index f695012368..7a1f6ddcf8 100644 --- a/web/packages/accounts/pages/passkeys/finish.tsx +++ b/web/packages/accounts/pages/passkeys/finish.tsx @@ -2,6 +2,7 @@ import { saveKeyAttributes, updateSavedLocalUser, } from "ente-accounts/services/accounts-db"; +import { clearInflightPasskeySessionID } from "ente-accounts/services/passkey"; import { unstashRedirect } from "ente-accounts/services/redirect"; import { resetSavedLocalUserTokens, @@ -85,7 +86,7 @@ const saveQueryCredentialsAndNavigateTo = async ( return "/"; } - sessionStorage.removeItem("inflightPasskeySessionID"); + clearInflightPasskeySessionID(); // Decode response string (inverse of the steps we perform in // `passkeyAuthenticationSuccessRedirectURL`). diff --git a/web/packages/accounts/pages/recover.tsx b/web/packages/accounts/pages/recover.tsx index aaace99bc6..ac4b791e7c 100644 --- a/web/packages/accounts/pages/recover.tsx +++ b/web/packages/accounts/pages/recover.tsx @@ -10,7 +10,10 @@ import { import { recoveryKeyFromMnemonic } from "ente-accounts/services/recovery-key"; import { appHomeRoute, stashRedirect } from "ente-accounts/services/redirect"; import type { KeyAttributes } from "ente-accounts/services/user"; -import { decryptAndStoreToken, sendOTT } from "ente-accounts/services/user"; +import { + decryptAndStoreTokenIfNeeded, + sendOTT, +} from "ente-accounts/services/user"; import { LinkButton } from "ente-base/components/LinkButton"; import { SingleInputForm, @@ -81,7 +84,7 @@ const Page: React.FC = () => { await recoveryKeyFromMnemonic(recoveryKeyMnemonic), ); await saveMasterKeyInSessionAndSafeStore(masterKey); - await decryptAndStoreToken(keyAttr, masterKey); + await decryptAndStoreTokenIfNeeded(keyAttr, masterKey); void router.push("/change-password?op=reset"); } catch (e) { diff --git a/web/packages/accounts/pages/two-factor/recover.tsx b/web/packages/accounts/pages/two-factor/recover.tsx index c92097ca16..42e9b9f9df 100644 --- a/web/packages/accounts/pages/two-factor/recover.tsx +++ b/web/packages/accounts/pages/two-factor/recover.tsx @@ -6,7 +6,7 @@ import { } from "ente-accounts/components/layouts/centered-paper"; import { savedPartialLocalUser } from "ente-accounts/services/accounts-db"; import { - recoverTwoFactor, + getRecoverTwoFactor, recoverTwoFactorFinish, type TwoFactorRecoveryResponse, type TwoFactorType, @@ -78,7 +78,7 @@ const Page: React.FC = ({ twoFactorType }) => { setSessionID(sessionID); try { setRecoveryResponse( - await recoverTwoFactor(twoFactorType, sessionID), + await getRecoverTwoFactor(twoFactorType, sessionID), ); } catch (e) { log.error("Second factor recovery page setup failed", e); diff --git a/web/packages/accounts/services/passkey.ts b/web/packages/accounts/services/passkey.ts index b3cef840a1..0dce3b22bf 100644 --- a/web/packages/accounts/services/passkey.ts +++ b/web/packages/accounts/services/passkey.ts @@ -258,7 +258,7 @@ export const saveCredentialsAndNavigateTo = async ( // goes through the passkey flow in the browser itself (when they are using // the web app). - sessionStorage.removeItem("inflightPasskeySessionID"); + clearInflightPasskeySessionID(); const { id, encryptedToken, keyAttributes } = response; @@ -268,3 +268,13 @@ export const saveCredentialsAndNavigateTo = async ( return unstashRedirect() ?? "/credentials"; }; + +/** + * Remove the inflight passkey session ID, if any, present in session storage. + * + * This should be called whenever we get back control from the passkey app to + * clean up after ourselves. + */ +export const clearInflightPasskeySessionID = () => { + sessionStorage.removeItem("inflightPasskeySessionID"); +}; diff --git a/web/packages/accounts/services/user.ts b/web/packages/accounts/services/user.ts index 8c00ddf100..91b1f1ea5f 100644 --- a/web/packages/accounts/services/user.ts +++ b/web/packages/accounts/services/user.ts @@ -28,7 +28,6 @@ import { ensureOk, publicRequestHeaders, } from "ente-base/http"; -import log from "ente-base/log"; import { apiURL } from "ente-base/origins"; import { ensureMasterKeyFromSession } from "ente-base/session"; import { @@ -39,6 +38,7 @@ import { import { ensure } from "ente-utils/ensure"; import { nullToUndefined } from "ente-utils/transform"; import { z } from "zod/v4"; +import { clearInflightPasskeySessionID } from "./passkey"; import { getUserRecoveryKey, recoveryKeyFromMnemonic } from "./recovery-key"; /** @@ -732,19 +732,20 @@ export const changePassword = async (password: string) => { }; /** - * Update the {@link encryptedToken} present in the saved partial local user. + * Update the {@link id} and {@link encryptedToken} present in the saved partial + * local user. * * This function removes the {@link token}, if any, present in the saved partial * local user and sets the provided {@link encryptedToken}. * * It is expected that the code will subsequently redirect to "/credentials", - * which should call {@link decryptAndStoreToken} which will decrypt the newly - * set {@link encryptedToken} and write out the decrypted value as the + * which should call {@link decryptAndStoreTokenIfNeeded} which will decrypt the + * newly set {@link encryptedToken} and write out the decrypted value as the * {@link token} in the saved local user. * - * @param userID The ID of the user whose token this is. This is used as a - * sanity check to ensure that the we do not overwrite saved partial local user - * data for a different user. + * @param userID The ID of the user whose token this is. This is also saved to + * the partial local user (after doing a sanity check that we're not replacing + * partial data with a different userID). * * @param encryptedToken The newly obtained base64 encoded encrypted token from * remote (e.g. as a result of the user verifying their email). @@ -757,7 +758,12 @@ export const resetSavedLocalUserTokens = async ( if (user?.id && user.id != userID) { throw new Error(`User ID mismatch (${user.id}, ${userID})`); } - replaceSavedLocalUser({ ...user, token: undefined, encryptedToken }); + replaceSavedLocalUser({ + ...user, + id: userID, + token: undefined, + encryptedToken, + }); return removeAuthToken(); }; @@ -769,15 +775,12 @@ export const resetSavedLocalUserTokens = async ( * * @param masterKey The user's master key (base64 encoded). */ -export const decryptAndStoreToken = async ( +export const decryptAndStoreTokenIfNeeded = async ( keyAttributes: KeyAttributes, masterKey: string, ) => { const { encryptedToken } = savedPartialLocalUser() ?? {}; - if (!encryptedToken) { - log.info("Skipping token decryption (no encrypted token found)"); - return; - } + if (!encryptedToken) return; const { encryptedSecretKey, secretKeyDecryptionNonce, publicKey } = keyAttributes; @@ -954,7 +957,7 @@ export type TwoFactorRecoveryResponse = z.infer< * sends a encrypted recovery secret (see {@link configurePasskeyRecovery}). * * 3. When the user wishes to reset or bypass their second factor, the client - * asks remote for these encrypted secrets (using {@link recoverTwoFactor}). + * asks remote for these encrypted secrets (using {@link getRecoverTwoFactor}). * * 4. User then enters their recovery key, which the client uses to decrypt the * recovery secret and provide it back to remote for verification (using @@ -963,7 +966,7 @@ export type TwoFactorRecoveryResponse = z.infer< * 5. If the recovery secret matches, then remote resets (TOTP based) or bypass * (passkey based) the user's second factor. */ -export const recoverTwoFactor = async ( +export const getRecoverTwoFactor = async ( twoFactorType: TwoFactorType, sessionID: string, ): Promise => { @@ -977,22 +980,23 @@ export const recoverTwoFactor = async ( /** * Finish the second factor recovery / bypass initiated by - * {@link recoverTwoFactor} using the provided recovery key mnemonic entered by - * the user. + * {@link getRecoverTwoFactor} using the provided recovery key mnemonic entered + * by the user. * * See: [Note: Second factor recovery]. * * This completes the recovery process both locally, and on remote. * * @param twoFactorType The second factor type (same value as what would've been - * passed to {@link recoverTwoFactor} for obtaining {@link recoveryResponse}). + * passed to {@link getRecoverTwoFactor} for obtaining + * {@link recoveryResponse}). * * @param sessionID The second factor session ID (same value as what would've - * been passed to {@link recoverTwoFactor} for obtaining + * been passed to {@link getRecoverTwoFactor} for obtaining * {@link recoveryResponse}). * * @param recoveryResponse The response to a previous call to - * {@link recoverTwoFactor}. + * {@link getRecoverTwoFactor}. * * @param recoveryKeyMnemonic The 24-word BIP-39 recovery key mnemonic provided * by the user to complete recovery. @@ -1020,6 +1024,7 @@ export const recoverTwoFactorFinish = async ( twoFactorSessionID: undefined, passkeySessionID: undefined, }); + if (twoFactorType == "passkey") clearInflightPasskeySessionID(); saveKeyAttributes(keyAttributes); };