diff --git a/web/apps/accounts/src/pages/account-handoff.tsx b/web/apps/accounts/src/pages/account-handoff.tsx
index 45d8fa9682..cb80e49f3f 100644
--- a/web/apps/accounts/src/pages/account-handoff.tsx
+++ b/web/apps/accounts/src/pages/account-handoff.tsx
@@ -1,59 +1,15 @@
-import log from "@/next/log";
-import { VerticallyCentered } from "@ente/shared/components/Container";
-import EnteSpinner from "@ente/shared/components/EnteSpinner";
-import { ACCOUNTS_PAGES } from "@ente/shared/constants/pages";
-import HTTPService from "@ente/shared/network/HTTPService";
-import { LS_KEYS, getData, setData } from "@ente/shared/storage/localStorage";
-import { useRouter } from "next/router";
import { useEffect } from "react";
-const AccountHandoff = () => {
- const router = useRouter();
-
- const retrieveAccountData = () => {
- try {
- extractAccountsToken();
-
- router.push(ACCOUNTS_PAGES.PASSKEYS);
- } catch (e) {
- log.error("Failed to deserialize and set passed user data", e);
- router.push(ACCOUNTS_PAGES.LOGIN);
- }
- };
-
- const getClientPackageName = () => {
- const urlParams = new URLSearchParams(window.location.search);
- const pkg = urlParams.get("package");
- if (!pkg) return;
- setData(LS_KEYS.CLIENT_PACKAGE, { name: pkg });
- HTTPService.setHeaders({
- "X-Client-Package": pkg,
- });
- };
-
- const extractAccountsToken = () => {
- const urlParams = new URLSearchParams(window.location.search);
- const token = urlParams.get("token");
- if (!token) {
- throw new Error("token not found");
- }
-
- const user = getData(LS_KEYS.USER) || {};
- user.token = token;
-
- setData(LS_KEYS.USER, user);
- };
-
+/** Legacy alias, remove once mobile code is updated (it is still in beta). */
+const Page = () => {
useEffect(() => {
- getClientPackageName();
- retrieveAccountData();
+ window.location.href = window.location.href.replace(
+ "account-handoff",
+ "passkeys/handoff",
+ );
}, []);
- return (
-
-
-
- );
+ return <>>;
};
-export default AccountHandoff;
+export default Page;
diff --git a/web/apps/accounts/src/pages/passkeys/flow.tsx b/web/apps/accounts/src/pages/passkeys/flow.tsx
index 8cbba4c422..bd7d5d96be 100644
--- a/web/apps/accounts/src/pages/passkeys/flow.tsx
+++ b/web/apps/accounts/src/pages/passkeys/flow.tsx
@@ -1,305 +1,12 @@
-import log from "@/next/log";
-import { clientPackageName } from "@/next/types/app";
-import { nullToUndefined } from "@/utils/transform";
-import {
- CenteredFlex,
- VerticallyCentered,
-} from "@ente/shared/components/Container";
-import EnteButton from "@ente/shared/components/EnteButton";
-import EnteSpinner from "@ente/shared/components/EnteSpinner";
-import FormPaper from "@ente/shared/components/Form/FormPaper";
-import { fromB64URLSafeNoPadding } from "@ente/shared/crypto/internal/libsodium";
-import HTTPService from "@ente/shared/network/HTTPService";
-import { LS_KEYS, setData } from "@ente/shared/storage/localStorage";
-import InfoIcon from "@mui/icons-material/Info";
-import { Box, Typography } from "@mui/material";
-import { t } from "i18next";
-import _sodium from "libsodium-wrappers";
-import { useEffect, useState } from "react";
-import {
- beginPasskeyAuthentication,
- finishPasskeyAuthentication,
- isWhitelistedRedirect,
- type BeginPasskeyAuthenticationResponse,
-} from "services/passkey";
-
-const PasskeysFlow = () => {
- const [errored, setErrored] = useState(false);
-
- const [invalidInfo, setInvalidInfo] = useState(false);
-
- const [loading, setLoading] = useState(true);
-
- const init = async () => {
- const searchParams = new URLSearchParams(window.location.search);
-
- // Extract redirect from the query params.
- const redirect = nullToUndefined(searchParams.get("redirect"));
- const redirectURL = redirect ? new URL(redirect) : undefined;
-
- // Ensure that redirectURL is whitelisted, otherwise show an invalid
- // "login" URL error to the user.
- if (!redirectURL || !isWhitelistedRedirect(redirectURL)) {
- setInvalidInfo(true);
- setLoading(false);
- return;
- }
-
- let pkg = clientPackageName["photos"];
- if (redirectURL.protocol === "enteauth:") {
- pkg = clientPackageName["auth"];
- } else if (redirectURL.hostname.startsWith("accounts")) {
- pkg = clientPackageName["accounts"];
- }
-
- setData(LS_KEYS.CLIENT_PACKAGE, { name: pkg });
- // The server needs to know the app on whose behalf we're trying to log in
- HTTPService.setHeaders({
- "X-Client-Package": pkg,
- });
-
- // get passkeySessionID from the query params
- const passkeySessionID = searchParams.get("passkeySessionID") as string;
-
- setLoading(true);
-
- let beginData: BeginPasskeyAuthenticationResponse;
-
- try {
- beginData = await beginAuthentication(passkeySessionID);
- } catch (e) {
- log.error("Couldn't begin passkey authentication", e);
- setErrored(true);
- return;
- } finally {
- setLoading(false);
- }
-
- let credential: Credential | null = null;
-
- let tries = 0;
- const maxTries = 3;
-
- while (tries < maxTries) {
- try {
- credential = await getCredential(beginData.options.publicKey);
- } catch (e) {
- log.error("Couldn't get credential", e);
- continue;
- } finally {
- tries++;
- }
-
- break;
- }
-
- if (!credential) {
- if (!isWebAuthnSupported()) {
- alert("WebAuthn is not supported in this browser");
- }
- setErrored(true);
- return;
- }
-
- setLoading(true);
-
- let finishData;
-
- try {
- finishData = await finishAuthentication(
- credential,
- passkeySessionID,
- beginData.ceremonySessionID,
- );
- } catch (e) {
- log.error("Couldn't finish passkey authentication", e);
- setErrored(true);
- setLoading(false);
- return;
- }
-
- const encodedResponse = _sodium.to_base64(JSON.stringify(finishData));
-
- // TODO-PK: Shouldn't this be URL encoded?
- window.location.href = `${redirect}?response=${encodedResponse}`;
- };
-
- const beginAuthentication = async (sessionId: string) => {
- const data = await beginPasskeyAuthentication(sessionId);
- return data;
- };
-
- function isWebAuthnSupported(): boolean {
- if (!navigator.credentials) {
- return false;
- }
- return true;
- }
-
- const getCredential = async (
- publicKey: any,
- timeoutMillis: number = 60000, // Default timeout of 60 seconds
- ): Promise => {
- publicKey.challenge = await fromB64URLSafeNoPadding(
- publicKey.challenge,
- );
- for (const listItem of publicKey.allowCredentials ?? []) {
- listItem.id = await fromB64URLSafeNoPadding(listItem.id);
- // note: we are orverwriting the transports array with all possible values.
- // This is because the browser will only prompt the user for the transport that is available.
- // Warning: In case of invalid transport value, the webauthn will fail on Safari & iOS browsers
- listItem.transports = ["usb", "nfc", "ble", "internal"];
- }
- publicKey.timeout = timeoutMillis;
- const publicKeyCredentialCreationOptions: CredentialRequestOptions = {
- publicKey: publicKey,
- };
- const credential = await navigator.credentials.get(
- publicKeyCredentialCreationOptions,
- );
- return credential;
- };
-
- const finishAuthentication = async (
- credential: Credential,
- sessionId: string,
- ceremonySessionId: string,
- ) => {
- const data = await finishPasskeyAuthentication(
- credential,
- sessionId,
- ceremonySessionId,
- );
- return data;
- };
+import { useEffect } from "react";
+/** Legacy alias, remove once mobile code is updated (it is still in beta). */
+const Page = () => {
useEffect(() => {
- init();
+ window.location.href = window.location.href.replace("flow", "verify");
}, []);
- if (loading) {
- return (
-
-
-
- );
- }
-
- if (invalidInfo) {
- return (
-
-
-
-
-
- {t("PASSKEY_LOGIN_FAILED")}
-
-
- {t("PASSKEY_LOGIN_URL_INVALID")}
-
-
-
-
- );
- }
-
- if (errored) {
- return (
-
-
-
-
-
- {t("PASSKEY_LOGIN_FAILED")}
-
-
- {t("PASSKEY_LOGIN_ERRORED")}
-
- {
- setErrored(false);
- init();
- }}
- fullWidth
- style={{
- marginTop: "1rem",
- }}
- color="primary"
- type="button"
- variant="contained"
- >
- {t("TRY_AGAIN")}
-
-
- {t("RECOVER_TWO_FACTOR")}
-
-
-
-
- );
- }
-
- return (
- <>
-
-
-
-
-
- {t("LOGIN_WITH_PASSKEY")}
-
-
- {t("PASSKEY_FOLLOW_THE_STEPS_FROM_YOUR_BROWSER")}
-
-
-
-
-
-
-
- >
- );
+ return <>>;
};
-export default PasskeysFlow;
+export default Page;
diff --git a/web/apps/accounts/src/pages/passkeys/handoff.tsx b/web/apps/accounts/src/pages/passkeys/handoff.tsx
new file mode 100644
index 0000000000..1e4fb72118
--- /dev/null
+++ b/web/apps/accounts/src/pages/passkeys/handoff.tsx
@@ -0,0 +1,50 @@
+import log from "@/next/log";
+import { VerticallyCentered } from "@ente/shared/components/Container";
+import EnteSpinner from "@ente/shared/components/EnteSpinner";
+import HTTPService from "@ente/shared/network/HTTPService";
+import { LS_KEYS, getData, setData } from "@ente/shared/storage/localStorage";
+import { useRouter } from "next/router";
+import React, { useEffect } from "react";
+
+/**
+ * Parse credentials passed as query parameters by one of our client apps, save
+ * them to local storage, and then redirect to the passkeys listing.
+ */
+const Page: React.FC = () => {
+ const router = useRouter();
+
+ useEffect(() => {
+ const urlParams = new URLSearchParams(window.location.search);
+
+ const client = urlParams.get("client");
+ if (client) {
+ // TODO-PK: mobile is not passing it. is that expected?
+ setData(LS_KEYS.CLIENT_PACKAGE, { name: client });
+ HTTPService.setHeaders({
+ "X-Client-Package": client,
+ });
+ }
+
+ const token = urlParams.get("token");
+ if (!token) {
+ log.error("Missing accounts token");
+ router.push("/login");
+ return;
+ }
+
+ const user = getData(LS_KEYS.USER) || {};
+ user.token = token;
+
+ setData(LS_KEYS.USER, user);
+
+ router.push("/passkeys");
+ }, []);
+
+ return (
+
+
+
+ );
+};
+
+export default Page;
diff --git a/web/apps/accounts/src/pages/passkeys/index.tsx b/web/apps/accounts/src/pages/passkeys/index.tsx
index b841c030db..77c2438b00 100644
--- a/web/apps/accounts/src/pages/passkeys/index.tsx
+++ b/web/apps/accounts/src/pages/passkeys/index.tsx
@@ -1,5 +1,4 @@
import log from "@/next/log";
-import { ensure } from "@/utils/ensure";
import { CenteredFlex } from "@ente/shared/components/Container";
import DialogBoxV2 from "@ente/shared/components/DialogBoxV2";
import EnteButton from "@ente/shared/components/EnteButton";
@@ -11,7 +10,6 @@ import MenuItemDivider from "@ente/shared/components/Menu/MenuItemDivider";
import { MenuItemGroup } from "@ente/shared/components/Menu/MenuItemGroup";
import SingleInputForm from "@ente/shared/components/SingleInputForm";
import Titlebar from "@ente/shared/components/Titlebar";
-import { ACCOUNTS_PAGES } from "@ente/shared/constants/pages";
import { getToken } from "@ente/shared/storage/localStorage/helpers";
import { formatDateTimeFull } from "@ente/shared/time/format";
import CalendarTodayIcon from "@mui/icons-material/CalendarToday";
@@ -21,330 +19,311 @@ import EditIcon from "@mui/icons-material/Edit";
import KeyIcon from "@mui/icons-material/Key";
import { Box, Button, Stack, Typography, useMediaQuery } from "@mui/material";
import { t } from "i18next";
-import _sodium from "libsodium-wrappers";
import { useRouter } from "next/router";
import { useAppContext } from "pages/_app";
-import type { Dispatch, SetStateAction } from "react";
+import React, { useEffect, useState } from "react";
import {
- Fragment,
- createContext,
- useContext,
- useEffect,
- useState,
-} from "react";
-import { deletePasskey, renamePasskey } from "services/passkey";
-import {
- finishPasskeyRegistration,
- getPasskeyRegistrationOptions,
+ deletePasskey,
getPasskeys,
+ registerPasskey,
+ renamePasskey,
type Passkey,
-} from "../../services/passkey";
+} from "services/passkey";
-export const PasskeysContext = createContext(
- {} as {
- selectedPasskey: Passkey | null;
- setSelectedPasskey: Dispatch>;
- setShowPasskeyDrawer: Dispatch>;
- refreshPasskeys: () => void;
- },
-);
-
-const Passkeys = () => {
+const Page: React.FC = () => {
const { showNavBar } = useAppContext();
- const [selectedPasskey, setSelectedPasskey] = useState(
- null,
- );
-
- const [showPasskeyDrawer, setShowPasskeyDrawer] = useState(false);
-
const [passkeys, setPasskeys] = useState([]);
+ const [showPasskeyDrawer, setShowPasskeyDrawer] = useState(false);
+ const [selectedPasskey, setSelectedPasskey] = useState<
+ Passkey | undefined
+ >();
const router = useRouter();
- const checkLoggedIn = () => {
- const token = getToken();
- if (!token) {
- router.push(ACCOUNTS_PAGES.LOGIN);
+ const refreshPasskeys = async () => {
+ try {
+ const { passkeys } = await getPasskeys();
+ setPasskeys(passkeys || []);
+ } catch (e) {
+ log.error("Failed to fetch passkeys", e);
}
};
- const init = async () => {
- checkLoggedIn();
- const data = await getPasskeys();
- setPasskeys(data.passkeys || []);
+ useEffect(() => {
+ if (!getToken()) {
+ router.push("/login");
+ return;
+ }
+
+ showNavBar(true);
+ void refreshPasskeys();
+ }, []);
+
+ const handleSelectPasskey = (passkey: Passkey) => {
+ setSelectedPasskey(passkey);
+ setShowPasskeyDrawer(true);
};
- useEffect(() => {
- showNavBar(true);
- init();
- }, []);
+ const handleDrawerClose = () => {
+ setShowPasskeyDrawer(false);
+ // Don't clear the selected passkey, let the stale value be so that the
+ // drawer closing animation is nicer.
+ //
+ // The value will get overwritten the next time we open the drawer for a
+ // different passkey, so this will not have a functional impact.
+ };
+
+ const handleUpdateOrDeletePasskey = () => {
+ setShowPasskeyDrawer(false);
+ setSelectedPasskey(undefined);
+ void refreshPasskeys();
+ };
const handleSubmit = async (
inputValue: string,
setFieldError: (errorMessage: string) => void,
resetForm: () => void,
) => {
- let response: {
- options: {
- publicKey: PublicKeyCredentialCreationOptions;
- };
- sessionID: string;
- };
-
try {
- response = await getPasskeyRegistrationOptions();
- } catch {
- setFieldError("Failed to begin registration");
- return;
- }
-
- const options = response.options;
-
- // TODO-PK: The types don't match.
- options.publicKey.challenge = _sodium.from_base64(
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore
- options.publicKey.challenge,
- );
- options.publicKey.user.id = _sodium.from_base64(
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore
- options.publicKey.user.id,
- );
-
- // create new credential
- let newCredential: Credential;
-
- try {
- newCredential = ensure(await navigator.credentials.create(options));
+ await registerPasskey(inputValue);
} catch (e) {
- log.error("Error creating credential", e);
- setFieldError("Failed to create credential");
+ log.error("Failed to register a new passkey", e);
+ // TODO-PK: localize
+ setFieldError("Could not add passkey");
return;
}
-
- try {
- await finishPasskeyRegistration(
- inputValue,
- newCredential,
- response.sessionID,
- );
- } catch {
- setFieldError("Failed to finish registration");
- return;
- }
-
- await init();
+ await refreshPasskeys();
resetForm();
};
return (
-
+ <>
{t("PASSKEYS_DESCRIPTION")}
-
+
-
+
-
-
+
+ >
);
};
-export default Passkeys;
+export default Page;
interface PasskeysListProps {
+ /** The list of {@link Passkey}s to show. */
passkeys: Passkey[];
+ /**
+ * Callback to invoke when an passkey in the list is clicked.
+ *
+ * It is passed the corresponding {@link Passkey}.
+ */
+ onSelectPasskey: (passkey: Passkey) => void;
}
-const PasskeysList: React.FC = ({ passkeys }) => {
+const PasskeysList: React.FC = ({
+ passkeys,
+ onSelectPasskey,
+}) => {
return (
{passkeys.map((passkey, i) => (
-
-
+
+
{i < passkeys.length - 1 && }
-
+
))}
);
};
interface PasskeyListItemProps {
+ /** The passkey to show in the item. */
passkey: Passkey;
+ /**
+ * Callback to invoke when the item is clicked.
+ *
+ * It is passed the item's {@link passkey}.
+ */
+ onClick: (passkey: Passkey) => void;
}
-const PasskeyListItem: React.FC = ({ passkey }) => {
- const { setSelectedPasskey, setShowPasskeyDrawer } =
- useContext(PasskeysContext);
-
+const PasskeyListItem: React.FC = ({
+ passkey,
+ onClick,
+}) => {
return (
{
- setSelectedPasskey(passkey);
- setShowPasskeyDrawer(true);
- }}
+ onClick={() => onClick(passkey)}
startIcon={}
endIcon={}
- label={passkey?.friendlyName}
+ label={passkey.friendlyName}
/>
);
};
interface ManagePasskeyDrawerProps {
+ /** If `true`, then the drawer is shown. */
open: boolean;
+ /** Callback to invoke when the drawer wants to be closed. */
+ onClose: () => void;
+ /**
+ * The {@link Passkey} whose details should be shown in the drawer.
+ *
+ * It is guaranteed that this will be defined when `open` is true.
+ */
+ passkey: Passkey | undefined;
+ /**
+ * Callback to invoke when the passkey in the modifed or deleted.
+ *
+ * The passkey that the drawer is showing will be out of date at this point,
+ * so the list of passkeys should be refreshed and the drawer closed.
+ */
+ onUpdateOrDeletePasskey: () => void;
}
-const ManagePasskeyDrawer: React.FC = (props) => {
- const { setShowPasskeyDrawer, refreshPasskeys, selectedPasskey } =
- useContext(PasskeysContext);
-
- const [showDeletePasskeyModal, setShowDeletePasskeyModal] = useState(false);
- const [showRenamePasskeyModal, setShowRenamePasskeyModal] = useState(false);
+const ManagePasskeyDrawer: React.FC = ({
+ open,
+ onClose,
+ passkey,
+ onUpdateOrDeletePasskey,
+}) => {
+ const [showDeleteDialog, setShowDeleteDialog] = useState(false);
+ const [showRenameDialog, setShowRenameDialog] = useState(false);
return (
<>
- {
- setShowPasskeyDrawer(false);
- }}
- >
- {selectedPasskey && (
- <>
-
- {
- setShowPasskeyDrawer(false);
+
+ {passkey && (
+
+
+ }
+ title={t("CREATED_AT")}
+ caption={formatDateTimeFull(
+ passkey.createdAt / 1000,
+ )}
+ loading={false}
+ hideEditOption
+ />
+
+ {
+ setShowRenameDialog(true);
}}
- title="Manage Passkey"
- onRootClose={() => {
- setShowPasskeyDrawer(false);
+ startIcon={}
+ label={"Rename Passkey"}
+ />
+
+ {
+ setShowDeleteDialog(true);
}}
+ startIcon={}
+ label={"Delete Passkey"}
+ color="critical"
/>
- }
- title={t("CREATED_AT")}
- caption={
- `${formatDateTimeFull(
- selectedPasskey.createdAt / 1000,
- )}` || ""
- }
- loading={!selectedPasskey}
- hideEditOption
- />
-
- {
- setShowRenamePasskeyModal(true);
- }}
- startIcon={}
- label={"Rename Passkey"}
- />
-
- {
- setShowDeletePasskeyModal(true);
- }}
- startIcon={}
- label={"Delete Passkey"}
- color="critical"
- />
-
-
- >
+
+
)}
- {
- setShowDeletePasskeyModal(false);
- refreshPasskeys();
- }}
- />
- {
- setShowRenamePasskeyModal(false);
- refreshPasskeys();
- }}
- />
+
+ {passkey && (
+ setShowDeleteDialog(false)}
+ passkey={passkey}
+ onDeletePasskey={() => {
+ setShowDeleteDialog(false);
+ onUpdateOrDeletePasskey();
+ }}
+ />
+ )}
+
+ {passkey && (
+ setShowRenameDialog(false)}
+ passkey={passkey}
+ onRenamePasskey={() => {
+ setShowRenameDialog(false);
+ onUpdateOrDeletePasskey();
+ }}
+ />
+ )}
>
);
};
-interface DeletePasskeyModalProps {
+interface DeletePasskeyDialogProps {
+ /** If `true`, then the dialog is shown. */
open: boolean;
+ /** Callback to invoke when the dialog wants to be closed. */
onClose: () => void;
+ /** The {@link Passkey} to delete. */
+ passkey: Passkey;
+ /** Callback to invoke when the passkey is deleted. */
+ onDeletePasskey: () => void;
}
-const DeletePasskeyModal: React.FC = (props) => {
- const { selectedPasskey, setShowPasskeyDrawer } =
- useContext(PasskeysContext);
+const DeletePasskeyDialog: React.FC = ({
+ open,
+ onClose,
+ passkey,
+ onDeletePasskey,
+}) => {
+ const [isDeleting, setIsDeleting] = useState(false);
+ const fullScreen = useMediaQuery("(max-width: 428px)");
- const [loading, setLoading] = useState(false);
-
- const isMobile = useMediaQuery("(max-width: 428px)");
-
- const doDelete = async () => {
- if (!selectedPasskey) return;
- setLoading(true);
+ const handleConfirm = async () => {
+ setIsDeleting(true);
try {
- await deletePasskey(selectedPasskey.id);
- } catch (error) {
- console.error(error);
- return;
+ await deletePasskey(passkey.id);
+ onDeletePasskey();
+ } catch (e) {
+ log.error("Failed to delete passkey", e);
} finally {
- setLoading(false);
+ setIsDeleting(false);
}
- props.onClose();
- setShowPasskeyDrawer(false);
};
return (
{t("DELETE_PASSKEY_CONFIRMATION")}
@@ -352,16 +331,12 @@ const DeletePasskeyModal: React.FC = (props) => {
type="submit"
size="large"
color="critical"
- loading={loading}
- onClick={doDelete}
+ loading={isDeleting}
+ onClick={handleConfirm}
>
{t("DELETE")}
-
@@ -369,49 +344,48 @@ const DeletePasskeyModal: React.FC = (props) => {
);
};
-interface RenamePasskeyModalProps {
+interface RenamePasskeyDialogProps {
+ /** If `true`, then the dialog is shown. */
open: boolean;
+ /** Callback to invoke when the dialog wants to be closed. */
onClose: () => void;
+ /** The {@link Passkey} to rename. */
+ passkey: Passkey;
+ /** Callback to invoke when the passkey is renamed. */
+ onRenamePasskey: () => void;
}
-const RenamePasskeyModal: React.FC = (props) => {
- const { selectedPasskey } = useContext(PasskeysContext);
-
- const isMobile = useMediaQuery("(max-width: 428px)");
+const RenamePasskeyDialog: React.FC = ({
+ open,
+ onClose,
+ passkey,
+ onRenamePasskey,
+}) => {
+ const fullScreen = useMediaQuery("(max-width: 428px)");
const onSubmit = async (inputValue: string) => {
- if (!selectedPasskey) return;
try {
- await renamePasskey(selectedPasskey.id, inputValue);
- } catch (error) {
- console.error(error);
+ await renamePasskey(passkey.id, inputValue);
+ onRenamePasskey();
+ } catch (e) {
+ log.error("Failed to rename passkey", e);
return;
}
-
- props.onClose();
};
return (
diff --git a/web/apps/accounts/src/pages/passkeys/verify.tsx b/web/apps/accounts/src/pages/passkeys/verify.tsx
new file mode 100644
index 0000000000..5c1b1be141
--- /dev/null
+++ b/web/apps/accounts/src/pages/passkeys/verify.tsx
@@ -0,0 +1,306 @@
+import log from "@/next/log";
+import { clientPackageName } from "@/next/types/app";
+import { nullToUndefined } from "@/utils/transform";
+import {
+ CenteredFlex,
+ VerticallyCentered,
+} from "@ente/shared/components/Container";
+import EnteButton from "@ente/shared/components/EnteButton";
+import EnteSpinner from "@ente/shared/components/EnteSpinner";
+import FormPaper from "@ente/shared/components/Form/FormPaper";
+import { fromB64URLSafeNoPadding } from "@ente/shared/crypto/internal/libsodium";
+import HTTPService from "@ente/shared/network/HTTPService";
+import { LS_KEYS, setData } from "@ente/shared/storage/localStorage";
+import InfoIcon from "@mui/icons-material/Info";
+import { Box, Typography } from "@mui/material";
+import { t } from "i18next";
+import _sodium from "libsodium-wrappers";
+import { useEffect, useState } from "react";
+import {
+ beginPasskeyAuthentication,
+ finishPasskeyAuthentication,
+ isWhitelistedRedirect,
+ type BeginPasskeyAuthenticationResponse,
+} from "services/passkey";
+
+const PasskeysFlow = () => {
+ const [errored, setErrored] = useState(false);
+
+ const [invalidInfo, setInvalidInfo] = useState(false);
+
+ const [loading, setLoading] = useState(true);
+
+ const init = async () => {
+ const searchParams = new URLSearchParams(window.location.search);
+
+ // Extract redirect from the query params.
+ const redirect = nullToUndefined(searchParams.get("redirect"));
+ const redirectURL = redirect ? new URL(redirect) : undefined;
+
+ // Ensure that redirectURL is whitelisted, otherwise show an invalid
+ // "login" URL error to the user.
+ if (!redirectURL || !isWhitelistedRedirect(redirectURL)) {
+ log.error(`Redirect URL '${redirectURL}' is not whitelisted`);
+ setInvalidInfo(true);
+ setLoading(false);
+ return;
+ }
+
+ let pkg = clientPackageName["photos"];
+ if (redirectURL.protocol === "enteauth:") {
+ pkg = clientPackageName["auth"];
+ } else if (redirectURL.hostname.startsWith("accounts")) {
+ pkg = clientPackageName["accounts"];
+ }
+
+ setData(LS_KEYS.CLIENT_PACKAGE, { name: pkg });
+ // The server needs to know the app on whose behalf we're trying to log in
+ HTTPService.setHeaders({
+ "X-Client-Package": pkg,
+ });
+
+ // get passkeySessionID from the query params
+ const passkeySessionID = searchParams.get("passkeySessionID") as string;
+
+ setLoading(true);
+
+ let beginData: BeginPasskeyAuthenticationResponse;
+
+ try {
+ beginData = await beginAuthentication(passkeySessionID);
+ } catch (e) {
+ log.error("Couldn't begin passkey authentication", e);
+ setErrored(true);
+ return;
+ } finally {
+ setLoading(false);
+ }
+
+ let credential: Credential | null = null;
+
+ let tries = 0;
+ const maxTries = 3;
+
+ while (tries < maxTries) {
+ try {
+ credential = await getCredential(beginData.options.publicKey);
+ } catch (e) {
+ log.error("Couldn't get credential", e);
+ continue;
+ } finally {
+ tries++;
+ }
+
+ break;
+ }
+
+ if (!credential) {
+ if (!isWebAuthnSupported()) {
+ alert("WebAuthn is not supported in this browser");
+ }
+ setErrored(true);
+ return;
+ }
+
+ setLoading(true);
+
+ let finishData;
+
+ try {
+ finishData = await finishAuthentication(
+ credential,
+ passkeySessionID,
+ beginData.ceremonySessionID,
+ );
+ } catch (e) {
+ log.error("Couldn't finish passkey authentication", e);
+ setErrored(true);
+ setLoading(false);
+ return;
+ }
+
+ const encodedResponse = _sodium.to_base64(JSON.stringify(finishData));
+
+ // TODO-PK: Shouldn't this be URL encoded?
+ window.location.href = `${redirect}?response=${encodedResponse}`;
+ };
+
+ const beginAuthentication = async (sessionId: string) => {
+ const data = await beginPasskeyAuthentication(sessionId);
+ return data;
+ };
+
+ function isWebAuthnSupported(): boolean {
+ if (!navigator.credentials) {
+ return false;
+ }
+ return true;
+ }
+
+ const getCredential = async (
+ publicKey: any,
+ timeoutMillis: number = 60000, // Default timeout of 60 seconds
+ ): Promise => {
+ publicKey.challenge = await fromB64URLSafeNoPadding(
+ publicKey.challenge,
+ );
+ for (const listItem of publicKey.allowCredentials ?? []) {
+ listItem.id = await fromB64URLSafeNoPadding(listItem.id);
+ // note: we are orverwriting the transports array with all possible values.
+ // This is because the browser will only prompt the user for the transport that is available.
+ // Warning: In case of invalid transport value, the webauthn will fail on Safari & iOS browsers
+ listItem.transports = ["usb", "nfc", "ble", "internal"];
+ }
+ publicKey.timeout = timeoutMillis;
+ const publicKeyCredentialCreationOptions: CredentialRequestOptions = {
+ publicKey: publicKey,
+ };
+ const credential = await navigator.credentials.get(
+ publicKeyCredentialCreationOptions,
+ );
+ return credential;
+ };
+
+ const finishAuthentication = async (
+ credential: Credential,
+ sessionId: string,
+ ceremonySessionId: string,
+ ) => {
+ const data = await finishPasskeyAuthentication(
+ credential,
+ sessionId,
+ ceremonySessionId,
+ );
+ return data;
+ };
+
+ useEffect(() => {
+ init();
+ }, []);
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ if (invalidInfo) {
+ return (
+
+
+
+
+
+ {t("PASSKEY_LOGIN_FAILED")}
+
+
+ {t("PASSKEY_LOGIN_URL_INVALID")}
+
+
+
+
+ );
+ }
+
+ if (errored) {
+ return (
+
+
+
+
+
+ {t("PASSKEY_LOGIN_FAILED")}
+
+
+ {t("PASSKEY_LOGIN_ERRORED")}
+
+ {
+ setErrored(false);
+ init();
+ }}
+ fullWidth
+ style={{
+ marginTop: "1rem",
+ }}
+ color="primary"
+ type="button"
+ variant="contained"
+ >
+ {t("TRY_AGAIN")}
+
+
+ {t("RECOVER_TWO_FACTOR")}
+
+
+
+
+ );
+ }
+
+ return (
+ <>
+
+
+
+
+
+ {t("LOGIN_WITH_PASSKEY")}
+
+
+ {t("PASSKEY_FOLLOW_THE_STEPS_FROM_YOUR_BROWSER")}
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export default PasskeysFlow;
diff --git a/web/apps/accounts/src/services/passkey.ts b/web/apps/accounts/src/services/passkey.ts
index a3f4b94a5e..d6f5c4bf67 100644
--- a/web/apps/accounts/src/services/passkey.ts
+++ b/web/apps/accounts/src/services/passkey.ts
@@ -1,9 +1,11 @@
import { isDevBuild } from "@/next/env";
import log from "@/next/log";
+import { ensure } from "@/utils/ensure";
import { toB64URLSafeNoPadding } from "@ente/shared/crypto/internal/libsodium";
import HTTPService from "@ente/shared/network/HTTPService";
import { getEndpoint } from "@ente/shared/network/api";
import { getToken } from "@ente/shared/storage/localStorage/helpers";
+import _sodium from "libsodium-wrappers";
const ENDPOINT = getEndpoint();
@@ -15,19 +17,14 @@ export interface Passkey {
}
export const getPasskeys = async () => {
- try {
- const token = getToken();
- if (!token) return;
- const response = await HTTPService.get(
- `${ENDPOINT}/passkeys`,
- {},
- { "X-Auth-Token": token },
- );
- return await response.data;
- } catch (e) {
- log.error("get passkeys failed", e);
- throw e;
- }
+ const token = getToken();
+ if (!token) return;
+ const response = await HTTPService.get(
+ `${ENDPOINT}/passkeys`,
+ {},
+ { "X-Auth-Token": token },
+ );
+ return await response.data;
};
export const renamePasskey = async (id: string, name: string) => {
@@ -88,62 +85,91 @@ export const getPasskeyRegistrationOptions = async () => {
* the whitelisted URLs that we allow redirecting to on success.
*/
export const isWhitelistedRedirect = (redirectURL: URL) =>
- (isDevBuild && redirectURL.host.endsWith("localhost")) ||
+ (isDevBuild && redirectURL.hostname.endsWith("localhost")) ||
redirectURL.host.endsWith(".ente.io") ||
redirectURL.host.endsWith(".ente.sh") ||
redirectURL.protocol == "ente:" ||
redirectURL.protocol == "enteauth:";
-export const finishPasskeyRegistration = async (
+/**
+ * Add a new passkey as the second factor to the user's account.
+ *
+ * @param name An arbitrary name that the user wishes to label this passkey with
+ * (aka "friendly name").
+ */
+export const registerPasskey = async (name: string) => {
+ const response: {
+ options: {
+ publicKey: PublicKeyCredentialCreationOptions;
+ };
+ sessionID: string;
+ } = await getPasskeyRegistrationOptions();
+
+ const options = response.options;
+
+ // TODO-PK: The types don't match.
+ options.publicKey.challenge = _sodium.from_base64(
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ options.publicKey.challenge,
+ );
+ options.publicKey.user.id = _sodium.from_base64(
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ options.publicKey.user.id,
+ );
+
+ // create new credential
+ const credential = ensure(await navigator.credentials.create(options));
+
+ await finishPasskeyRegistration(name, credential, response.sessionID);
+};
+
+const finishPasskeyRegistration = async (
friendlyName: string,
credential: Credential,
- sessionId: string,
+ sessionID: string,
) => {
- try {
- const attestationObjectB64 = await toB64URLSafeNoPadding(
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore
- new Uint8Array(credential.response.attestationObject),
- );
- const clientDataJSONB64 = await toB64URLSafeNoPadding(
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore
- new Uint8Array(credential.response.clientDataJSON),
- );
+ const attestationObjectB64 = await toB64URLSafeNoPadding(
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ new Uint8Array(credential.response.attestationObject),
+ );
+ const clientDataJSONB64 = await toB64URLSafeNoPadding(
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ new Uint8Array(credential.response.clientDataJSON),
+ );
- const token = getToken();
- if (!token) return;
+ const token = ensure(getToken());
- const response = await HTTPService.post(
- `${ENDPOINT}/passkeys/registration/finish`,
- JSON.stringify({
- id: credential.id,
- rawId: credential.id,
- type: credential.type,
- response: {
- attestationObject: attestationObjectB64,
- clientDataJSON: clientDataJSONB64,
- },
- }),
- {
- friendlyName,
- sessionID: sessionId,
+ const response = await HTTPService.post(
+ `${ENDPOINT}/passkeys/registration/finish`,
+ JSON.stringify({
+ id: credential.id,
+ rawId: credential.id,
+ type: credential.type,
+ response: {
+ attestationObject: attestationObjectB64,
+ clientDataJSON: clientDataJSONB64,
},
- {
- "X-Auth-Token": token,
- },
- );
- return await response.data;
- } catch (e) {
- log.error("finish passkey registration failed", e);
- throw e;
- }
+ }),
+ {
+ friendlyName,
+ sessionID,
+ },
+ {
+ "X-Auth-Token": token,
+ },
+ );
+ return await response.data;
};
export interface BeginPasskeyAuthenticationResponse {
ceremonySessionID: string;
options: Options;
}
+
interface Options {
publicKey: PublicKeyCredentialRequestOptions;
}
diff --git a/web/apps/photos/src/components/Sidebar/index.tsx b/web/apps/photos/src/components/Sidebar/index.tsx
index 08bc50ad9b..49a5566ca1 100644
--- a/web/apps/photos/src/components/Sidebar/index.tsx
+++ b/web/apps/photos/src/components/Sidebar/index.tsx
@@ -11,10 +11,7 @@ import EnteSpinner from "@ente/shared/components/EnteSpinner";
import { EnteMenuItem } from "@ente/shared/components/Menu/EnteMenuItem";
import RecoveryKey from "@ente/shared/components/RecoveryKey";
import ThemeSwitcher from "@ente/shared/components/ThemeSwitcher";
-import {
- ACCOUNTS_PAGES,
- PHOTOS_PAGES as PAGES,
-} from "@ente/shared/constants/pages";
+import { PHOTOS_PAGES as PAGES } from "@ente/shared/constants/pages";
import ComlinkCryptoWorker from "@ente/shared/crypto";
import { getRecoveryKey } from "@ente/shared/crypto/helpers";
import {
@@ -509,11 +506,10 @@ const UtilitySection: React.FC = ({ closeSidebar }) => {
// Ente Accounts specific JWT token.
const accountsToken = await getAccountsToken();
+ const client = clientPackageName["photos"];
window.open(
- `${accountsAppURL()}${
- ACCOUNTS_PAGES.ACCOUNT_HANDOFF
- }?package=${clientPackageName["photos"]}&token=${accountsToken}`,
+ `${accountsAppURL()}/passkeys/handoff?token=${accountsToken}&client=${client}`,
);
} catch (e) {
log.error("failed to redirect to accounts page", e);
diff --git a/web/docs/webauthn-passkeys.md b/web/docs/webauthn-passkeys.md
index 0156726029..ea0a98c191 100644
--- a/web/docs/webauthn-passkeys.md
+++ b/web/docs/webauthn-passkeys.md
@@ -53,9 +53,8 @@ used.** This restriction is a byproduct of the enablement for automatic login.
### Automatically logging into Accounts
Clients open a WebView with the URL
-`https://accounts.ente.io/accounts-handoff?token=&package=`.
-This page will appear like a normal loading screen to the user, but in the
-background, the app parses the token and package for usage in subsequent
+`https://accounts.ente.io/passkeys/handoff?client=&token=`.
+This page will parse the token and client package name for usage in subsequent
Accounts-related API calls.
If valid, the user will be automatically redirected to the passkeys management
@@ -342,7 +341,7 @@ credential authentication. We use Accounts as the central WebAuthn hub because
credentials are locked to an FQDN.
```tsx
-window.location.href = `${accountsAppURL()}/passkeys/flow?passkeySessionID=${passkeySessionID}&redirect=${
+window.location.href = `${accountsAppURL()}/passkeys/verify?passkeySessionID=${passkeySessionID}&redirect=${
window.location.origin
}/passkeys/finish`;
```
diff --git a/web/packages/accounts/pages/credentials.tsx b/web/packages/accounts/pages/credentials.tsx
index 4d130dd336..33741d3dee 100644
--- a/web/packages/accounts/pages/credentials.tsx
+++ b/web/packages/accounts/pages/credentials.tsx
@@ -166,7 +166,7 @@ const Page: React.FC = ({ appContext }) => {
isTwoFactorPasskeysEnabled: true,
});
InMemoryStore.set(MS_KEYS.REDIRECT_URL, PAGES.ROOT);
- window.location.href = `${accountsAppURL()}/passkeys/flow?passkeySessionID=${passkeySessionID}&redirect=${
+ window.location.href = `${accountsAppURL()}/passkeys/verify?passkeySessionID=${passkeySessionID}&redirect=${
window.location.origin
}/passkeys/finish`;
return undefined;
diff --git a/web/packages/accounts/pages/verify.tsx b/web/packages/accounts/pages/verify.tsx
index 852995a88f..4ebc738f15 100644
--- a/web/packages/accounts/pages/verify.tsx
+++ b/web/packages/accounts/pages/verify.tsx
@@ -85,7 +85,7 @@ const Page: React.FC = ({ appContext }) => {
isTwoFactorPasskeysEnabled: true,
});
setIsFirstLogin(true);
- window.location.href = `${accountsAppURL()}/passkeys/flow?passkeySessionID=${passkeySessionID}&redirect=${
+ window.location.href = `${accountsAppURL()}/passkeys/verify?passkeySessionID=${passkeySessionID}&redirect=${
window.location.origin
}/passkeys/finish`;
router.push(PAGES.CREDENTIALS);
diff --git a/web/packages/accounts/services/redirect.ts b/web/packages/accounts/services/redirect.ts
index 2b3f704956..b4b0322335 100644
--- a/web/packages/accounts/services/redirect.ts
+++ b/web/packages/accounts/services/redirect.ts
@@ -1,9 +1,5 @@
import type { AppName } from "@/next/types/app";
-import {
- ACCOUNTS_PAGES,
- AUTH_PAGES,
- PHOTOS_PAGES,
-} from "@ente/shared/constants/pages";
+import { AUTH_PAGES, PHOTOS_PAGES } from "@ente/shared/constants/pages";
/**
* The default page ("home route") for each of our apps.
@@ -13,7 +9,7 @@ import {
export const appHomeRoute = (appName: AppName): string => {
switch (appName) {
case "accounts":
- return ACCOUNTS_PAGES.PASSKEYS;
+ return "/passkeys";
case "auth":
return AUTH_PAGES.AUTH;
case "photos":
diff --git a/web/packages/new/photos/components/WhatsNew.tsx b/web/packages/new/photos/components/WhatsNew.tsx
index 2660e5ede8..f6128b860a 100644
--- a/web/packages/new/photos/components/WhatsNew.tsx
+++ b/web/packages/new/photos/components/WhatsNew.tsx
@@ -14,9 +14,9 @@ import type { TransitionProps } from "@mui/material/transitions";
import React from "react";
interface WhatsNewProps {
- /** Set this to `true` to show the dialog. */
+ /** If `true`, then the dialog is shown. */
open: boolean;
- /** Invoked by the dialog when it wants to get closed. */
+ /** Callback to invoke when the dialog wants to be closed. */
onClose: () => void;
}
diff --git a/web/packages/shared/constants/pages.tsx b/web/packages/shared/constants/pages.tsx
index c2e01d794c..5fef798a01 100644
--- a/web/packages/shared/constants/pages.tsx
+++ b/web/packages/shared/constants/pages.tsx
@@ -45,6 +45,5 @@ export enum ACCOUNTS_PAGES {
VERIFY = "/verify",
ROOT = "/",
PASSKEYS = "/passkeys",
- ACCOUNT_HANDOFF = "/account-handoff",
GENERATE = "/generate",
}