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")} - - - ente Logo Circular - - - - - - ); + 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")} + + + ente Logo Circular + + + + + + ); +}; + +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":