From ca748f731e69cb3cc177fa4a5dc3548b23a1dcfe Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 6 Jun 2025 18:42:31 +0530 Subject: [PATCH 01/11] Conv --- .../components/Collections/CollectionShare.tsx | 12 ++++++------ .../photos/src/services/collectionService.ts | 18 ------------------ web/packages/new/photos/services/collection.ts | 13 +++++++++++++ 3 files changed, 19 insertions(+), 24 deletions(-) diff --git a/web/apps/photos/src/components/Collections/CollectionShare.tsx b/web/apps/photos/src/components/Collections/CollectionShare.tsx index 14a965a1f9..da18fe3ce5 100644 --- a/web/apps/photos/src/components/Collections/CollectionShare.tsx +++ b/web/apps/photos/src/components/Collections/CollectionShare.tsx @@ -54,6 +54,7 @@ import type { import { type CollectionUser } from "ente-media/collection"; import { PublicLinkCreated } from "ente-new/photos/components/share/PublicLinkCreated"; import { avatarTextColor } from "ente-new/photos/services/avatar"; +import { deleteShareURL } from "ente-new/photos/services/collection"; import type { CollectionSummary } from "ente-new/photos/services/collection/ui"; import { usePhotosAppContext } from "ente-new/photos/types/context"; import { CustomError, parseSharingErrorCodes } from "ente-shared/error"; @@ -65,7 +66,6 @@ import React, { useContext, useEffect, useMemo, useRef, useState } from "react"; import { Trans } from "react-i18next"; import { createShareableURL, - deleteShareableURL, shareCollection, unshareCollection, updateShareableURL, @@ -1222,16 +1222,16 @@ const ManagePublicShareOptions: React.FC = ({ galleryContext.setBlockingLoad(false); } }; - const disablePublicSharing = async () => { + const handleRemovePublicLink = async () => { try { galleryContext.setBlockingLoad(true); - await deleteShareableURL(collection); + await deleteShareURL(collection.id); setPublicShareProp(null); galleryContext.syncWithRemote(false, true); onClose(); } catch (e) { - const errorMessage = handleSharingErrors(e); - setSharableLinkError(errorMessage); + log.error("Failed to remove public link", e); + setSharableLinkError(t("generic_error")); } finally { galleryContext.setBlockingLoad(false); } @@ -1293,7 +1293,7 @@ const ManagePublicShareOptions: React.FC = ({ } - onClick={disablePublicSharing} + onClick={handleRemovePublicLink} label={t("remove_link")} /> diff --git a/web/apps/photos/src/services/collectionService.ts b/web/apps/photos/src/services/collectionService.ts index 5394dd1678..c85a1a3eed 100644 --- a/web/apps/photos/src/services/collectionService.ts +++ b/web/apps/photos/src/services/collectionService.ts @@ -571,24 +571,6 @@ export const createShareableURL = async (collection: Collection) => { } }; -export const deleteShareableURL = async (collection: Collection) => { - try { - const token = getToken(); - if (!token) { - return null; - } - await HTTPService.delete( - await apiURL(`/collections/share-url/${collection.id}`), - null, - null, - { "X-Auth-Token": token }, - ); - } catch (e) { - log.error("deleteShareableURL failed ", e); - throw e; - } -}; - export const updateShareableURL = async ( request: UpdatePublicURL, ): Promise => { diff --git a/web/packages/new/photos/services/collection.ts b/web/packages/new/photos/services/collection.ts index de486224ce..d809f05d59 100644 --- a/web/packages/new/photos/services/collection.ts +++ b/web/packages/new/photos/services/collection.ts @@ -263,3 +263,16 @@ export const deleteFromTrash = async (fileIDs: number[]) => { ); } }; + +/** + * Delete the public link for the collection with given {@link collectionID}. + * + * Does not modify local state. + */ +export const deleteShareURL = async (collectionID: number) => + ensureOk( + await fetch(await apiURL(`/collections/share-url/${collectionID}`), { + method: "DELETE", + headers: await authenticatedRequestHeaders(), + }), + ); From a94a0f199a2c18a1f689d88bf26a810d35213bc0 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 9 Jun 2025 10:27:23 +0530 Subject: [PATCH 02/11] Rearrange --- .../accounts/pages/two-factor/recover.tsx | 2 +- web/packages/accounts/services/user.ts | 229 +++++++++--------- 2 files changed, 116 insertions(+), 115 deletions(-) diff --git a/web/packages/accounts/pages/two-factor/recover.tsx b/web/packages/accounts/pages/two-factor/recover.tsx index 54f6e6a079..b48f3dc21e 100644 --- a/web/packages/accounts/pages/two-factor/recover.tsx +++ b/web/packages/accounts/pages/two-factor/recover.tsx @@ -34,11 +34,11 @@ export interface RecoverPageProps { const Page: React.FC = ({ twoFactorType }) => { const { logout, showMiniDialog } = useBaseContext(); + const [sessionID, setSessionID] = useState(null); const [encryptedTwoFactorSecret, setEncryptedTwoFactorSecret] = useState<{ encryptedData: string; nonce: string; } | null>(null); - const [sessionID, setSessionID] = useState(null); const [doesHaveEncryptedRecoveryKey, setDoesHaveEncryptedRecoveryKey] = useState(false); diff --git a/web/packages/accounts/services/user.ts b/web/packages/accounts/services/user.ts index 602b67e979..5e5e1d050d 100644 --- a/web/packages/accounts/services/user.ts +++ b/web/packages/accounts/services/user.ts @@ -250,6 +250,44 @@ export const savedKeyAttributes = (): KeyAttributes | undefined => { export const ensureSavedKeyAttributes = (): KeyAttributes => ensureExpectedLoggedInValue(savedKeyAttributes()); +/** + * Update or set the user's {@link KeyAttributes} on remote. + */ +export const putUserKeyAttributes = async (keyAttributes: KeyAttributes) => + ensureOk( + await fetch(await apiURL("/users/attributes"), { + method: "PUT", + headers: await authenticatedRequestHeaders(), + body: JSON.stringify({ keyAttributes }), + }), + ); + +export interface RecoveryKeyAttributes { + masterKeyEncryptedWithRecoveryKey: string; + masterKeyDecryptionNonce: string; + recoveryKeyEncryptedWithMasterKey: string; + recoveryKeyDecryptionNonce: string; +} + +/** + * Update the encrypted recovery key attributes for the logged in user. + * + * In practice, this is not expected to be called and is meant as a rare + * fallback for very old accounts created prior to recovery key related + * attributes being assigned on account setup. Even for these, it'll be called + * only once. + */ +export const putUserRecoveryKeyAttributes = async ( + recoveryKeyAttributes: RecoveryKeyAttributes, +) => + ensureOk( + await fetch(await apiURL("/users/recovery-key"), { + method: "PUT", + headers: await authenticatedRequestHeaders(), + body: JSON.stringify(recoveryKeyAttributes), + }), + ); + export interface UserVerificationResponse { id: number; keyAttributes?: KeyAttributes | undefined; @@ -274,33 +312,6 @@ export interface UserVerificationResponse { srpM2?: string | undefined; } -export interface TwoFactorVerificationResponse { - id: number; - keyAttributes: KeyAttributes; - encryptedToken?: string; - token?: string; -} - -const TwoFactorSecret = z.object({ - secretCode: z.string(), - qrCode: z.string(), -}); - -export type TwoFactorSecret = z.infer; - -export interface TwoFactorRecoveryResponse { - encryptedSecret: string; - secretDecryptionNonce: string; -} - -export interface UpdatedKey { - kekSalt: string; - encryptedKey: string; - keyDecryptionNonce: string; - memLimit: number; - opsLimit: number; -} - /** * Ask remote to send a OTP / OTT to the given email to verify that the user has * access to it. Subsequent the app will pass this OTT back via the @@ -387,34 +398,6 @@ export const EmailOrSRPAuthorizationResponse = z.object({ srpM2: z.string().nullish().transform(nullToUndefined), }); -/** - * The result of a successful two factor verification (totp or passkey). - */ -export const TwoFactorAuthorizationResponse = z.object({ - id: z.number(), - /** TODO: keyAttributes is guaranteed to be returned by museum, update the - * types to reflect that. */ - keyAttributes: RemoteKeyAttributes.nullish().transform(nullToUndefined), - /** TODO: encryptedToken is guaranteed to be returned by museum, update the - * types to reflect that. */ - encryptedToken: z.string().nullish().transform(nullToUndefined), -}); - -export type TwoFactorAuthorizationResponse = z.infer< - typeof TwoFactorAuthorizationResponse ->; - -/** - * Update or set the user's {@link KeyAttributes} on remote. - */ -export const putUserKeyAttributes = async (keyAttributes: KeyAttributes) => - ensureOk( - await fetch(await apiURL("/users/attributes"), { - method: "PUT", - headers: await authenticatedRequestHeaders(), - body: JSON.stringify({ keyAttributes }), - }), - ); /** * Log the user out on remote, if possible and needed. */ @@ -441,43 +424,13 @@ export const remoteLogoutIfNeeded = async () => { ensureOk(res); }; -export const verifyTwoFactor = async (code: string, sessionID: string) => { - const res = await fetch(await apiURL("/users/two-factor/verify"), { - method: "POST", - headers: publicRequestHeaders(), - body: JSON.stringify({ code, sessionID }), - }); - ensureOk(res); - const json = await res.json(); - // TODO: Use zod here - return json as UserVerificationResponse; -}; - -/** The type of the second factor we're trying to act on */ -export type TwoFactorType = "totp" | "passkey"; - -export const recoverTwoFactor = async ( - sessionID: string, - twoFactorType: TwoFactorType, -) => { - const resp = await HTTPService.get( - await apiURL("/users/two-factor/recover"), - { sessionID, twoFactorType }, - ); - return resp.data as TwoFactorRecoveryResponse; -}; - -export const removeTwoFactor = async ( - sessionID: string, - secret: string, - twoFactorType: TwoFactorType, -) => { - const resp = await HTTPService.post( - await apiURL("/users/two-factor/remove"), - { sessionID, secret, twoFactorType }, - ); - return resp.data as TwoFactorVerificationResponse; -}; +export interface UpdatedKey { + kekSalt: string; + encryptedKey: string; + keyDecryptionNonce: string; + memLimit: number; + opsLimit: number; +} export const changeEmail = async (email: string, ott: string) => { await HTTPService.post( @@ -488,6 +441,13 @@ export const changeEmail = async (email: string, ott: string) => { ); }; +const TwoFactorSecret = z.object({ + secretCode: z.string(), + qrCode: z.string(), +}); + +export type TwoFactorSecret = z.infer; + /** * Start the two factor setup process by fetching a secret code (and the * corresponding QR code) from remote. @@ -520,28 +480,69 @@ export const enableTwoFactor = async (req: EnableTwoFactorRequest) => }), ); -export interface RecoveryKeyAttributes { - masterKeyEncryptedWithRecoveryKey: string; - masterKeyDecryptionNonce: string; - recoveryKeyEncryptedWithMasterKey: string; - recoveryKeyDecryptionNonce: string; +/** + * The result of a successful two factor verification (totp or passkey). + */ +export const TwoFactorAuthorizationResponse = z.object({ + id: z.number(), + /** TODO: keyAttributes is guaranteed to be returned by museum, update the + * types to reflect that. */ + keyAttributes: RemoteKeyAttributes.nullish().transform(nullToUndefined), + /** TODO: encryptedToken is guaranteed to be returned by museum, update the + * types to reflect that. */ + encryptedToken: z.string().nullish().transform(nullToUndefined), +}); + +export type TwoFactorAuthorizationResponse = z.infer< + typeof TwoFactorAuthorizationResponse +>; + +export const verifyTwoFactor = async (code: string, sessionID: string) => { + const res = await fetch(await apiURL("/users/two-factor/verify"), { + method: "POST", + headers: publicRequestHeaders(), + body: JSON.stringify({ code, sessionID }), + }); + ensureOk(res); + const json = await res.json(); + // TODO: Use zod here + return json as UserVerificationResponse; +}; + +/** The type of the second factor we're trying to act on */ +export type TwoFactorType = "totp" | "passkey"; + +export interface TwoFactorRecoveryResponse { + encryptedSecret: string; + secretDecryptionNonce: string; } -/** - * Update the encrypted recovery key attributes for the logged in user. - * - * In practice, this is not expected to be called and is meant as a rare - * fallback for very old accounts created prior to recovery key related - * attributes being assigned on account setup. Even for these, it'll be called - * only once. - */ -export const putUserRecoveryKeyAttributes = async ( - recoveryKeyAttributes: RecoveryKeyAttributes, -) => - ensureOk( - await fetch(await apiURL("/users/recovery-key"), { - method: "PUT", - headers: await authenticatedRequestHeaders(), - body: JSON.stringify(recoveryKeyAttributes), - }), +export const recoverTwoFactor = async ( + sessionID: string, + twoFactorType: TwoFactorType, +) => { + const resp = await HTTPService.get( + await apiURL("/users/two-factor/recover"), + { sessionID, twoFactorType }, ); + return resp.data as TwoFactorRecoveryResponse; +}; + +export interface TwoFactorVerificationResponse { + id: number; + keyAttributes: KeyAttributes; + encryptedToken?: string; + token?: string; +} + +export const removeTwoFactor = async ( + sessionID: string, + secret: string, + twoFactorType: TwoFactorType, +) => { + const resp = await HTTPService.post( + await apiURL("/users/two-factor/remove"), + { sessionID, secret, twoFactorType }, + ); + return resp.data as TwoFactorVerificationResponse; +}; From 1832f9f996ce5344f078143aed20512469e3da6e Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 9 Jun 2025 10:40:30 +0530 Subject: [PATCH 03/11] Conv --- web/packages/accounts/services/user.ts | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/web/packages/accounts/services/user.ts b/web/packages/accounts/services/user.ts index 5e5e1d050d..682ebea228 100644 --- a/web/packages/accounts/services/user.ts +++ b/web/packages/accounts/services/user.ts @@ -5,7 +5,6 @@ import { } from "ente-base/http"; import { apiURL } from "ente-base/origins"; import HTTPService from "ente-shared/network/HTTPService"; -import { getToken } from "ente-shared/storage/localStorage/helpers"; import { nullToUndefined } from "ente-utils/transform"; import { z } from "zod/v4"; @@ -432,14 +431,21 @@ export interface UpdatedKey { opsLimit: number; } -export const changeEmail = async (email: string, ott: string) => { - await HTTPService.post( - await apiURL("/users/change-email"), - { email, ott }, - undefined, - { "X-Auth-Token": getToken() }, +/** + * Change the email associated with the user's account on remote. + * + * @param email The new email. + * + * @param ott The verification code that was sent to the new email. + */ +export const changeEmail = async (email: string, ott: string) => + ensureOk( + await fetch(await apiURL("/users/change-email"), { + method: "POST", + headers: await authenticatedRequestHeaders(), + body: JSON.stringify({ email, ott }), + }), ); -}; const TwoFactorSecret = z.object({ secretCode: z.string(), From 719c8f7b9ceed9bbe80dbd57b2078dfe865baf06 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 9 Jun 2025 11:04:33 +0530 Subject: [PATCH 04/11] Move and doc --- .../accounts/pages/two-factor/setup.tsx | 19 +++----- web/packages/accounts/services/user.ts | 45 +++++++++++++++++-- 2 files changed, 46 insertions(+), 18 deletions(-) diff --git a/web/packages/accounts/pages/two-factor/setup.tsx b/web/packages/accounts/pages/two-factor/setup.tsx index 8029b46fb7..135af20f68 100644 --- a/web/packages/accounts/pages/two-factor/setup.tsx +++ b/web/packages/accounts/pages/two-factor/setup.tsx @@ -1,16 +1,16 @@ import { Paper, Stack, styled, Typography } from "@mui/material"; import { CodeBlock } from "ente-accounts/components/CodeBlock"; import { Verify2FACodeForm } from "ente-accounts/components/Verify2FACodeForm"; -import { getUserRecoveryKey } from "ente-accounts/services/recovery-key"; import { appHomeRoute } from "ente-accounts/services/redirect"; import type { TwoFactorSecret } from "ente-accounts/services/user"; -import { enableTwoFactor, setupTwoFactor } from "ente-accounts/services/user"; +import { + setupTwoFactor, + setupTwoFactorFinish, +} from "ente-accounts/services/user"; import { CenteredFill } from "ente-base/components/containers"; import { LinkButton } from "ente-base/components/LinkButton"; import { ActivityIndicator } from "ente-base/components/mui/ActivityIndicator"; import { FocusVisibleButton } from "ente-base/components/mui/FocusVisibleButton"; -import { encryptBox } from "ente-base/crypto"; -import { getData, setLSUser } from "ente-shared/storage/localStorage"; import { t } from "i18next"; import { useRouter } from "next/router"; import React, { useEffect, useState } from "react"; @@ -27,16 +27,7 @@ const Page: React.FC = () => { }, []); const handleSubmit = async (otp: string) => { - const box = await encryptBox( - twoFactorSecret!.secretCode, - await getUserRecoveryKey(), - ); - await enableTwoFactor({ - code: otp, - encryptedTwoFactorSecret: box.encryptedData, - twoFactorSecretDecryptionNonce: box.nonce, - }); - await setLSUser({ ...getData("user"), isTwoFactorEnabled: true }); + await setupTwoFactorFinish(twoFactorSecret!.secretCode, otp); await router.push(appHomeRoute); }; diff --git a/web/packages/accounts/services/user.ts b/web/packages/accounts/services/user.ts index 682ebea228..c6983ae027 100644 --- a/web/packages/accounts/services/user.ts +++ b/web/packages/accounts/services/user.ts @@ -1,3 +1,4 @@ +import { encryptBox } from "ente-base/crypto"; import { authenticatedRequestHeaders, ensureOk, @@ -5,8 +6,10 @@ import { } from "ente-base/http"; import { apiURL } from "ente-base/origins"; import HTTPService from "ente-shared/network/HTTPService"; +import { getData, setLSUser } from "ente-shared/storage/localStorage"; import { nullToUndefined } from "ente-utils/transform"; import { z } from "zod/v4"; +import { getUserRecoveryKey } from "./recovery-key"; export interface User { id: number; @@ -448,17 +451,26 @@ export const changeEmail = async (email: string, ott: string) => ); const TwoFactorSecret = z.object({ + /** + * The 2FA secret code. + */ secretCode: z.string(), + /** + * A base64 encoded "image/png". + */ qrCode: z.string(), }); export type TwoFactorSecret = z.infer; /** - * Start the two factor setup process by fetching a secret code (and the - * corresponding QR code) from remote. + * Start a TOTP based two factor setup process by fetching a secret code (and + * the corresponding QR code) from remote. + * + * Once the user provides us with a TOTP generated using the provided secret, we + * can finish the setup with {@link setupTwoFactorFinish}. */ -export const setupTwoFactor = async () => { +export const setupTwoFactor = async (): Promise => { const res = await fetch(await apiURL("/users/two-factor/setup"), { method: "POST", headers: await authenticatedRequestHeaders(), @@ -467,6 +479,31 @@ export const setupTwoFactor = async () => { return TwoFactorSecret.parse(await res.json()); }; +/** + * Finish the TOTP based two factor setup by provided a previously obtained + * secret (using {@link setupTwoFactor}) and the current TOTP generated using + * that secret. + * + * This updates both the state both locally and on remote. + * + * @param secretCode The value of {@link secretCode} from the + * {@link TwoFactorSecret} obtained by {@link setupTwoFactor}. + * + * @param totp The current TOTP corresponding to {@link secretCode}. + */ +export const setupTwoFactorFinish = async ( + secretCode: string, + totp: string, +) => { + const box = await encryptBox(secretCode, await getUserRecoveryKey()); + await enableTwoFactor({ + code: totp, + encryptedTwoFactorSecret: box.encryptedData, + twoFactorSecretDecryptionNonce: box.nonce, + }); + await setLSUser({ ...getData("user"), isTwoFactorEnabled: true }); +}; + interface EnableTwoFactorRequest { code: string; encryptedTwoFactorSecret: string; @@ -477,7 +514,7 @@ interface EnableTwoFactorRequest { * Enable two factor for the user by providing the 2FA code and the encrypted * secret from a previous call to {@link setupTwoFactor}. */ -export const enableTwoFactor = async (req: EnableTwoFactorRequest) => +const enableTwoFactor = async (req: EnableTwoFactorRequest) => ensureOk( await fetch(await apiURL("/users/two-factor/enable"), { method: "POST", From 8adebbba3f05970fe4937e004b2ea5febd31d2b8 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 9 Jun 2025 11:09:29 +0530 Subject: [PATCH 05/11] Doc --- web/packages/accounts/services/user.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/web/packages/accounts/services/user.ts b/web/packages/accounts/services/user.ts index c6983ae027..63bb1dbccf 100644 --- a/web/packages/accounts/services/user.ts +++ b/web/packages/accounts/services/user.ts @@ -505,14 +505,27 @@ export const setupTwoFactorFinish = async ( }; interface EnableTwoFactorRequest { + /** + * The current value of the TOTP corresponding to the two factor {@link + * secretCode} obtained from a previous call to {@link setupTwoFactor}. + */ code: string; + /** + * The {@link secretCode} encrypted with the user's recovery key. + * + * This is used in the case of second factor recovery. + */ encryptedTwoFactorSecret: string; + /** + * The nonce that was used when encrypting {@link encryptedTwoFactorSecret}. + */ twoFactorSecretDecryptionNonce: string; } /** - * Enable two factor for the user by providing the 2FA code and the encrypted - * secret from a previous call to {@link setupTwoFactor}. + * Enable the TOTP based two factor for the user by providing the current 2FA + * code corresponding the two factor secret, and encrypted secrets for future + * recovery (if needed). */ const enableTwoFactor = async (req: EnableTwoFactorRequest) => ensureOk( From 7bfb2f0fe89f2f231b67e528b28a257e0b5e4c34 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 9 Jun 2025 11:21:31 +0530 Subject: [PATCH 06/11] Update --- web/packages/accounts/services/user.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/web/packages/accounts/services/user.ts b/web/packages/accounts/services/user.ts index 63bb1dbccf..368bf93079 100644 --- a/web/packages/accounts/services/user.ts +++ b/web/packages/accounts/services/user.ts @@ -537,16 +537,22 @@ const enableTwoFactor = async (req: EnableTwoFactorRequest) => ); /** - * The result of a successful two factor verification (totp or passkey). + * The result of a successful two factor verification (TOTP or passkey), + * recovery removal (TOTP) or recovery bypass (passkey). */ export const TwoFactorAuthorizationResponse = z.object({ + /** + * The user's ID + */ id: z.number(), - /** TODO: keyAttributes is guaranteed to be returned by museum, update the - * types to reflect that. */ - keyAttributes: RemoteKeyAttributes.nullish().transform(nullToUndefined), - /** TODO: encryptedToken is guaranteed to be returned by museum, update the - * types to reflect that. */ - encryptedToken: z.string().nullish().transform(nullToUndefined), + /** + * The user's key attributes. + */ + keyAttributes: RemoteKeyAttributes, + /** + * A encrypted auth token. + */ + encryptedToken: z.string(), }); export type TwoFactorAuthorizationResponse = z.infer< From e2d103f20fdd259d7e160311010a05cb4d8c45f1 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 9 Jun 2025 11:34:30 +0530 Subject: [PATCH 07/11] Use correct type --- .../accounts/pages/two-factor/verify.tsx | 18 ++++++++++++++---- web/packages/accounts/services/user.ts | 9 +++++---- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/web/packages/accounts/pages/two-factor/verify.tsx b/web/packages/accounts/pages/two-factor/verify.tsx index dd3a0ab90b..f7872d2f4a 100644 --- a/web/packages/accounts/pages/two-factor/verify.tsx +++ b/web/packages/accounts/pages/two-factor/verify.tsx @@ -38,10 +38,20 @@ const Page: React.FC = () => { const handleSubmit = async (otp: string) => { try { - const resp = await verifyTwoFactor(otp, sessionID); - const { keyAttributes, encryptedToken, token, id } = resp; - await setLSUser({ ...getData("user"), token, encryptedToken, id }); - setData("keyAttributes", keyAttributes!); + const { keyAttributes, encryptedToken, id } = await verifyTwoFactor( + otp, + sessionID, + ); + await setLSUser({ + ...getData("user"), + id, + // 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, + }); + setData("keyAttributes", keyAttributes); await router.push(unstashRedirect() ?? "/credentials"); } catch (e) { if (e instanceof HTTPError && e.res.status == 404) { diff --git a/web/packages/accounts/services/user.ts b/web/packages/accounts/services/user.ts index 368bf93079..2bd80cc64f 100644 --- a/web/packages/accounts/services/user.ts +++ b/web/packages/accounts/services/user.ts @@ -559,16 +559,17 @@ export type TwoFactorAuthorizationResponse = z.infer< typeof TwoFactorAuthorizationResponse >; -export const verifyTwoFactor = async (code: string, sessionID: string) => { +export const verifyTwoFactor = async ( + code: string, + sessionID: string, +): Promise => { const res = await fetch(await apiURL("/users/two-factor/verify"), { method: "POST", headers: publicRequestHeaders(), body: JSON.stringify({ code, sessionID }), }); ensureOk(res); - const json = await res.json(); - // TODO: Use zod here - return json as UserVerificationResponse; + return TwoFactorAuthorizationResponse.parse(await res.json()); }; /** The type of the second factor we're trying to act on */ From d1d7af4f7e6db19f801592bfea89ea56254dcf67 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 9 Jun 2025 11:42:00 +0530 Subject: [PATCH 08/11] Outline --- web/packages/accounts/services/user.ts | 39 ++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/web/packages/accounts/services/user.ts b/web/packages/accounts/services/user.ts index 2bd80cc64f..9074032a37 100644 --- a/web/packages/accounts/services/user.ts +++ b/web/packages/accounts/services/user.ts @@ -20,18 +20,25 @@ export interface User { twoFactorSessionID: string; } -// TODO: During login the only field present is email. Which makes this -// optionality indicated by these types incorrect. +/** + * The local storage data about the user after they've logged in. + */ const LocalUser = z.object({ - /** The user's ID. */ + /** + * The user's ID. + */ id: z.number(), - /** The user's email. */ + /** + * The user's email. + */ email: z.string(), /** * The user's (plaintext) auth token. * * It is used for making API calls on their behalf, by passing this token as * the value of the X-Auth-Token header in the HTTP request. + * + * Deprecated, use `getAuthToken()` instead (which fetches it from IDB). */ token: z.string(), }); @@ -39,9 +46,31 @@ const LocalUser = z.object({ /** Locally available data for the logged in user */ export type LocalUser = z.infer; +/** + * The local storage data about the user before login or signup is complete. + * + * During login or signup, the user object exists in various partial states in + * local storage. + * + * - Initially, there is no user object in local storage. + * + * - When the user enters their email, the email property of the stored object + * is set, but nothing else. + * + * - If they have second factor verification set, then after entering their + * password {@link isTwoFactorEnabled} and {@link twoFactorSessionID} will + * also get filled in. + * + * - Once they verify their TOTP based second factor, their {@link id} and + * {@link encryptedToken} will also get filled in. + */ +// TODO: Start using me. +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const PreLoginLocalUser = LocalUser.partial(); + /** * Return the logged-in user, if someone is indeed logged in. Otherwise return - * `undefined`. + * `undefined` (TODO: That's not what it is doing...). * * The user's data is stored in the browser's localStorage. Thus, this function * only works from the main thread, not from web workers (local storage is not From 5b0a04142f02a6dbce6f9c7a71a2aaea49411066 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 9 Jun 2025 11:56:19 +0530 Subject: [PATCH 09/11] Outline --- web/packages/accounts/services/user.ts | 31 +++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/web/packages/accounts/services/user.ts b/web/packages/accounts/services/user.ts index 9074032a37..020390596c 100644 --- a/web/packages/accounts/services/user.ts +++ b/web/packages/accounts/services/user.ts @@ -571,7 +571,7 @@ const enableTwoFactor = async (req: EnableTwoFactorRequest) => */ export const TwoFactorAuthorizationResponse = z.object({ /** - * The user's ID + * The user's ID. */ id: z.number(), /** @@ -609,6 +609,35 @@ export interface TwoFactorRecoveryResponse { secretDecryptionNonce: string; } +/** + * Initiate second factor reset or bypass by requesting the encrypted second + * factor recovery secret (and nonce) from remote. The user can then decrypt + * these using their recovery key to reset or bypass their second factor. + * + * @param sessionID A two factor session ID ({@link twoFactorSessionID} or + * {@link passkeySessionID}) for the user. + * + * @param twoFactorType The type of second factor to reset or bypass. + * + * [Note: Second factor recovery] + * + * 1. When setting up a TOTP based second factor, client sends a (encrypted 2fa + * recovery secret, nonce) pair to remote. This is a randomly generated + * secret (and nonce) encrypted using the user's recovery key. + * + * 2. Similarly, when setting up a passkey as the second factor, the client + * 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}). + * + * 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 + * {@link removeTwoFactor}). + * + * 5. If the recovery secret matches, then remote resets (TOTP based) or bypass + * (passkey based) the user's second factor. + */ export const recoverTwoFactor = async ( sessionID: string, twoFactorType: TwoFactorType, From 088cf4adef4ab51aae74fc85798ccf926b94bf41 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 9 Jun 2025 12:43:01 +0530 Subject: [PATCH 10/11] Conv --- web/packages/accounts/services/user.ts | 27 +++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/web/packages/accounts/services/user.ts b/web/packages/accounts/services/user.ts index 020390596c..8fe3f7d638 100644 --- a/web/packages/accounts/services/user.ts +++ b/web/packages/accounts/services/user.ts @@ -604,10 +604,18 @@ export const verifyTwoFactor = async ( /** The type of the second factor we're trying to act on */ export type TwoFactorType = "totp" | "passkey"; -export interface TwoFactorRecoveryResponse { - encryptedSecret: string; - secretDecryptionNonce: string; -} +const TwoFactorRecoveryResponse = z.object({ + /** + * The recovery secret, encrypted using the user's recovery key. + */ + encryptedSecret: z.string(), + /** + * The nonce used during encryption of {@link encryptedSecret}. + */ + secretDecryptionNonce: z.string(), +}); + +type TwoFactorRecoveryResponse = z.infer; /** * Initiate second factor reset or bypass by requesting the encrypted second @@ -641,12 +649,13 @@ export interface TwoFactorRecoveryResponse { export const recoverTwoFactor = async ( sessionID: string, twoFactorType: TwoFactorType, -) => { - const resp = await HTTPService.get( - await apiURL("/users/two-factor/recover"), - { sessionID, twoFactorType }, +): Promise => { + const res = await fetch( + await apiURL("/users/two-factor/recover", { sessionID, twoFactorType }), + { headers: publicRequestHeaders() }, ); - return resp.data as TwoFactorRecoveryResponse; + ensureOk(res); + return TwoFactorRecoveryResponse.parse(await res.json()); }; export interface TwoFactorVerificationResponse { From e473c1852c456f1a114116cae78af9c149ca7b49 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 9 Jun 2025 12:57:31 +0530 Subject: [PATCH 11/11] lint --- web/packages/accounts/services/passkey.ts | 2 +- web/packages/accounts/services/user.ts | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/web/packages/accounts/services/passkey.ts b/web/packages/accounts/services/passkey.ts index 782ac710d1..d228e348fe 100644 --- a/web/packages/accounts/services/passkey.ts +++ b/web/packages/accounts/services/passkey.ts @@ -252,7 +252,7 @@ export const saveCredentialsAndNavigateTo = async ( const { id, encryptedToken, keyAttributes } = response; await setLSUser({ ...getData("user"), encryptedToken, id }); - setData("keyAttributes", keyAttributes!); + setData("keyAttributes", keyAttributes); return unstashRedirect() ?? "/credentials"; }; diff --git a/web/packages/accounts/services/user.ts b/web/packages/accounts/services/user.ts index 8fe3f7d638..e7c334174f 100644 --- a/web/packages/accounts/services/user.ts +++ b/web/packages/accounts/services/user.ts @@ -65,8 +65,7 @@ export type LocalUser = z.infer; * {@link encryptedToken} will also get filled in. */ // TODO: Start using me. -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const PreLoginLocalUser = LocalUser.partial(); +export const PreLoginLocalUser = LocalUser.partial(); /** * Return the logged-in user, if someone is indeed logged in. Otherwise return