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