diff --git a/web/apps/accounts/src/pages/account-handoff.tsx b/web/apps/accounts/src/pages/account-handoff.tsx
index 45d8fa9682..d97a6e1e29 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 = () => {
+/** Legacy alias, remove once mobile code is updated (it is still in beta). */
+const Page = () => {
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);
- };
-
useEffect(() => {
- getClientPackageName();
- retrieveAccountData();
+ router.push("/passkeys/setup");
}, []);
- 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..391ec63eb8 100644
--- a/web/apps/accounts/src/pages/passkeys/flow.tsx
+++ b/web/apps/accounts/src/pages/passkeys/flow.tsx
@@ -1,305 +1,15 @@
-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";
+import { useRouter } from "next/router";
+import { useEffect } from "react";
-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;
- };
+/** Legacy alias, remove once mobile code is updated (it is still in beta). */
+const Page = () => {
+ const router = useRouter();
useEffect(() => {
- init();
+ router.push("/passkeys/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/index.tsx b/web/apps/accounts/src/pages/passkeys/index.tsx
index b841c030db..89ce1702f1 100644
--- a/web/apps/accounts/src/pages/passkeys/index.tsx
+++ b/web/apps/accounts/src/pages/passkeys/index.tsx
@@ -11,7 +11,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";
@@ -65,7 +64,7 @@ const Passkeys = () => {
const checkLoggedIn = () => {
const token = getToken();
if (!token) {
- router.push(ACCOUNTS_PAGES.LOGIN);
+ router.push("/login");
}
};
diff --git a/web/apps/accounts/src/pages/passkeys/setup.tsx b/web/apps/accounts/src/pages/passkeys/setup.tsx
new file mode 100644
index 0000000000..c9d62fdbbf
--- /dev/null
+++ b/web/apps/accounts/src/pages/passkeys/setup.tsx
@@ -0,0 +1,59 @@
+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 { useEffect } from "react";
+
+const AccountHandoff = () => {
+ const router = useRouter();
+
+ const retrieveAccountData = () => {
+ try {
+ extractAccountsToken();
+
+ router.push("/passkeys");
+ } catch (e) {
+ log.error("Failed to deserialize and set passed user data", e);
+ // Not much we can do here, but this redirect might be misleading.
+ router.push("/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);
+ };
+
+ useEffect(() => {
+ getClientPackageName();
+ retrieveAccountData();
+ }, []);
+
+ return (
+
+
+
+ );
+};
+
+export default AccountHandoff;
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..8cbba4c422
--- /dev/null
+++ b/web/apps/accounts/src/pages/passkeys/verify.tsx
@@ -0,0 +1,305 @@
+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;
+ };
+
+ 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/photos/src/components/Sidebar/index.tsx b/web/apps/photos/src/components/Sidebar/index.tsx
index 08bc50ad9b..b038111106 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 pkg = clientPackageName["photos"];
window.open(
- `${accountsAppURL()}${
- ACCOUNTS_PAGES.ACCOUNT_HANDOFF
- }?package=${clientPackageName["photos"]}&token=${accountsToken}`,
+ `${accountsAppURL()}/passkeys/setup?package=${pkg}&token=${accountsToken}`,
);
} catch (e) {
log.error("failed to redirect to accounts page", e);
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":