[web] Passkeys - Various code tweaks (#2042)

This commit is contained in:
Manav Rathi
2024-06-07 09:38:46 +05:30
committed by GitHub
13 changed files with 684 additions and 675 deletions

View File

@@ -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 (
<VerticallyCentered>
<EnteSpinner />
</VerticallyCentered>
);
return <></>;
};
export default AccountHandoff;
export default Page;

View File

@@ -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<Credential | null> => {
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 (
<VerticallyCentered>
<EnteSpinner />
</VerticallyCentered>
);
}
if (invalidInfo) {
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
height="100%"
>
<Box maxWidth="30rem">
<FormPaper
style={{
padding: "1rem",
}}
>
<InfoIcon />
<Typography fontWeight="bold" variant="h1">
{t("PASSKEY_LOGIN_FAILED")}
</Typography>
<Typography marginTop="1rem">
{t("PASSKEY_LOGIN_URL_INVALID")}
</Typography>
</FormPaper>
</Box>
</Box>
);
}
if (errored) {
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
height="100%"
>
<Box maxWidth="30rem">
<FormPaper
style={{
padding: "1rem",
}}
>
<InfoIcon />
<Typography fontWeight="bold" variant="h1">
{t("PASSKEY_LOGIN_FAILED")}
</Typography>
<Typography marginTop="1rem">
{t("PASSKEY_LOGIN_ERRORED")}
</Typography>
<EnteButton
onClick={() => {
setErrored(false);
init();
}}
fullWidth
style={{
marginTop: "1rem",
}}
color="primary"
type="button"
variant="contained"
>
{t("TRY_AGAIN")}
</EnteButton>
<EnteButton
href="/passkeys/recover"
fullWidth
style={{
marginTop: "1rem",
}}
color="primary"
type="button"
variant="text"
>
{t("RECOVER_TWO_FACTOR")}
</EnteButton>
</FormPaper>
</Box>
</Box>
);
}
return (
<>
<Box
display="flex"
justifyContent="center"
alignItems="center"
height="100%"
>
<Box maxWidth="30rem">
<FormPaper
style={{
padding: "1rem",
}}
>
<InfoIcon />
<Typography fontWeight="bold" variant="h1">
{t("LOGIN_WITH_PASSKEY")}
</Typography>
<Typography marginTop="1rem">
{t("PASSKEY_FOLLOW_THE_STEPS_FROM_YOUR_BROWSER")}
</Typography>
<CenteredFlex marginTop="1rem">
<img
alt="ente Logo Circular"
height={150}
width={150}
src="/images/ente-circular.png"
/>
</CenteredFlex>
</FormPaper>
</Box>
</Box>
</>
);
return <></>;
};
export default PasskeysFlow;
export default Page;

View File

@@ -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 (
<VerticallyCentered>
<EnteSpinner />
</VerticallyCentered>
);
};
export default Page;

View File

@@ -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<SetStateAction<Passkey | null>>;
setShowPasskeyDrawer: Dispatch<SetStateAction<boolean>>;
refreshPasskeys: () => void;
},
);
const Passkeys = () => {
const Page: React.FC = () => {
const { showNavBar } = useAppContext();
const [selectedPasskey, setSelectedPasskey] = useState<Passkey | null>(
null,
);
const [showPasskeyDrawer, setShowPasskeyDrawer] = useState(false);
const [passkeys, setPasskeys] = useState<Passkey[]>([]);
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 (
<PasskeysContext.Provider
value={{
selectedPasskey,
setSelectedPasskey,
setShowPasskeyDrawer,
refreshPasskeys: init,
}}
>
<>
<CenteredFlex>
<Box maxWidth="20rem">
<Box marginBottom="1rem">
<Typography>{t("PASSKEYS_DESCRIPTION")}</Typography>
</Box>
<FormPaper
style={{
padding: "1rem",
}}
>
<FormPaper style={{ padding: "1rem" }}>
<SingleInputForm
fieldType="text"
placeholder={t("ENTER_PASSKEY_NAME")}
buttonText={t("ADD_PASSKEY")}
initialValue={""}
callback={handleSubmit}
submitButtonProps={{
sx: {
marginBottom: 1,
},
}}
submitButtonProps={{ sx: { marginBottom: 1 } }}
/>
</FormPaper>
<Box marginTop="1rem">
<PasskeysList passkeys={passkeys} />
<PasskeysList
passkeys={passkeys}
onSelectPasskey={handleSelectPasskey}
/>
</Box>
</Box>
</CenteredFlex>
<ManagePasskeyDrawer open={showPasskeyDrawer} />
</PasskeysContext.Provider>
<ManagePasskeyDrawer
open={showPasskeyDrawer}
onClose={handleDrawerClose}
passkey={selectedPasskey}
onUpdateOrDeletePasskey={handleUpdateOrDeletePasskey}
/>
</>
);
};
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<PasskeysListProps> = ({ passkeys }) => {
const PasskeysList: React.FC<PasskeysListProps> = ({
passkeys,
onSelectPasskey,
}) => {
return (
<MenuItemGroup>
{passkeys.map((passkey, i) => (
<Fragment key={passkey.id}>
<PasskeyListItem passkey={passkey} />
<React.Fragment key={passkey.id}>
<PasskeyListItem
passkey={passkey}
onClick={onSelectPasskey}
/>
{i < passkeys.length - 1 && <MenuItemDivider />}
</Fragment>
</React.Fragment>
))}
</MenuItemGroup>
);
};
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<PasskeyListItemProps> = ({ passkey }) => {
const { setSelectedPasskey, setShowPasskeyDrawer } =
useContext(PasskeysContext);
const PasskeyListItem: React.FC<PasskeyListItemProps> = ({
passkey,
onClick,
}) => {
return (
<EnteMenuItem
onClick={() => {
setSelectedPasskey(passkey);
setShowPasskeyDrawer(true);
}}
onClick={() => onClick(passkey)}
startIcon={<KeyIcon />}
endIcon={<ChevronRightIcon />}
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<ManagePasskeyDrawerProps> = (props) => {
const { setShowPasskeyDrawer, refreshPasskeys, selectedPasskey } =
useContext(PasskeysContext);
const [showDeletePasskeyModal, setShowDeletePasskeyModal] = useState(false);
const [showRenamePasskeyModal, setShowRenamePasskeyModal] = useState(false);
const ManagePasskeyDrawer: React.FC<ManagePasskeyDrawerProps> = ({
open,
onClose,
passkey,
onUpdateOrDeletePasskey,
}) => {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showRenameDialog, setShowRenameDialog] = useState(false);
return (
<>
<EnteDrawer
anchor="right"
open={props.open}
onClose={() => {
setShowPasskeyDrawer(false);
}}
>
{selectedPasskey && (
<>
<Stack spacing={"4px"} py={"12px"}>
<Titlebar
onClose={() => {
setShowPasskeyDrawer(false);
<EnteDrawer anchor="right" {...{ open, onClose }}>
{passkey && (
<Stack spacing={"4px"} py={"12px"}>
<Titlebar
onClose={onClose}
// TODO-PK: Localize (more below too)
title="Manage Passkey"
onRootClose={onClose}
/>
<InfoItem
icon={<CalendarTodayIcon />}
title={t("CREATED_AT")}
caption={formatDateTimeFull(
passkey.createdAt / 1000,
)}
loading={false}
hideEditOption
/>
<MenuItemGroup>
<EnteMenuItem
onClick={() => {
setShowRenameDialog(true);
}}
title="Manage Passkey"
onRootClose={() => {
setShowPasskeyDrawer(false);
startIcon={<EditIcon />}
label={"Rename Passkey"}
/>
<MenuItemDivider />
<EnteMenuItem
onClick={() => {
setShowDeleteDialog(true);
}}
startIcon={<DeleteIcon />}
label={"Delete Passkey"}
color="critical"
/>
<InfoItem
icon={<CalendarTodayIcon />}
title={t("CREATED_AT")}
caption={
`${formatDateTimeFull(
selectedPasskey.createdAt / 1000,
)}` || ""
}
loading={!selectedPasskey}
hideEditOption
/>
<MenuItemGroup>
<EnteMenuItem
onClick={() => {
setShowRenamePasskeyModal(true);
}}
startIcon={<EditIcon />}
label={"Rename Passkey"}
/>
<MenuItemDivider />
<EnteMenuItem
onClick={() => {
setShowDeletePasskeyModal(true);
}}
startIcon={<DeleteIcon />}
label={"Delete Passkey"}
color="critical"
/>
</MenuItemGroup>
</Stack>
</>
</MenuItemGroup>
</Stack>
)}
</EnteDrawer>
<DeletePasskeyModal
open={showDeletePasskeyModal}
onClose={() => {
setShowDeletePasskeyModal(false);
refreshPasskeys();
}}
/>
<RenamePasskeyModal
open={showRenamePasskeyModal}
onClose={() => {
setShowRenamePasskeyModal(false);
refreshPasskeys();
}}
/>
{passkey && (
<DeletePasskeyDialog
open={showDeleteDialog}
onClose={() => setShowDeleteDialog(false)}
passkey={passkey}
onDeletePasskey={() => {
setShowDeleteDialog(false);
onUpdateOrDeletePasskey();
}}
/>
)}
{passkey && (
<RenamePasskeyDialog
open={showRenameDialog}
onClose={() => 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<DeletePasskeyModalProps> = (props) => {
const { selectedPasskey, setShowPasskeyDrawer } =
useContext(PasskeysContext);
const DeletePasskeyDialog: React.FC<DeletePasskeyDialogProps> = ({
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 (
<DialogBoxV2
fullWidth
open={props.open}
onClose={props.onClose}
fullScreen={isMobile}
attributes={{
title: t("DELETE_PASSKEY"),
secondary: {
action: props.onClose,
text: t("CANCEL"),
},
}}
{...{ open, onClose, fullScreen }}
attributes={{ title: t("DELETE_PASSKEY") }}
>
<Stack spacing={"8px"}>
<Typography>{t("DELETE_PASSKEY_CONFIRMATION")}</Typography>
@@ -352,16 +331,12 @@ const DeletePasskeyModal: React.FC<DeletePasskeyModalProps> = (props) => {
type="submit"
size="large"
color="critical"
loading={loading}
onClick={doDelete}
loading={isDeleting}
onClick={handleConfirm}
>
{t("DELETE")}
</EnteButton>
<Button
size="large"
color={"secondary"}
onClick={props.onClose}
>
<Button size="large" color={"secondary"} onClick={onClose}>
{t("CANCEL")}
</Button>
</Stack>
@@ -369,49 +344,48 @@ const DeletePasskeyModal: React.FC<DeletePasskeyModalProps> = (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<RenamePasskeyModalProps> = (props) => {
const { selectedPasskey } = useContext(PasskeysContext);
const isMobile = useMediaQuery("(max-width: 428px)");
const RenamePasskeyDialog: React.FC<RenamePasskeyDialogProps> = ({
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 (
<DialogBoxV2
fullWidth
open={props.open}
onClose={props.onClose}
fullScreen={isMobile}
attributes={{
title: t("RENAME_PASSKEY"),
secondary: {
action: props.onClose,
text: t("CANCEL"),
},
}}
{...{ open, onClose, fullScreen }}
attributes={{ title: t("RENAME_PASSKEY") }}
>
<SingleInputForm
initialValue={selectedPasskey?.friendlyName}
initialValue={passkey?.friendlyName}
callback={onSubmit}
placeholder={t("ENTER_PASSKEY_NAME")}
buttonText={t("RENAME")}
fieldType="text"
secondaryButtonAction={props.onClose}
secondaryButtonAction={onClose}
submitButtonProps={{ sx: { mt: 1, mb: 2 } }}
/>
</DialogBoxV2>

View File

@@ -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<Credential | null> => {
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 (
<VerticallyCentered>
<EnteSpinner />
</VerticallyCentered>
);
}
if (invalidInfo) {
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
height="100%"
>
<Box maxWidth="30rem">
<FormPaper
style={{
padding: "1rem",
}}
>
<InfoIcon />
<Typography fontWeight="bold" variant="h1">
{t("PASSKEY_LOGIN_FAILED")}
</Typography>
<Typography marginTop="1rem">
{t("PASSKEY_LOGIN_URL_INVALID")}
</Typography>
</FormPaper>
</Box>
</Box>
);
}
if (errored) {
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
height="100%"
>
<Box maxWidth="30rem">
<FormPaper
style={{
padding: "1rem",
}}
>
<InfoIcon />
<Typography fontWeight="bold" variant="h1">
{t("PASSKEY_LOGIN_FAILED")}
</Typography>
<Typography marginTop="1rem">
{t("PASSKEY_LOGIN_ERRORED")}
</Typography>
<EnteButton
onClick={() => {
setErrored(false);
init();
}}
fullWidth
style={{
marginTop: "1rem",
}}
color="primary"
type="button"
variant="contained"
>
{t("TRY_AGAIN")}
</EnteButton>
<EnteButton
href="/passkeys/recover"
fullWidth
style={{
marginTop: "1rem",
}}
color="primary"
type="button"
variant="text"
>
{t("RECOVER_TWO_FACTOR")}
</EnteButton>
</FormPaper>
</Box>
</Box>
);
}
return (
<>
<Box
display="flex"
justifyContent="center"
alignItems="center"
height="100%"
>
<Box maxWidth="30rem">
<FormPaper
style={{
padding: "1rem",
}}
>
<InfoIcon />
<Typography fontWeight="bold" variant="h1">
{t("LOGIN_WITH_PASSKEY")}
</Typography>
<Typography marginTop="1rem">
{t("PASSKEY_FOLLOW_THE_STEPS_FROM_YOUR_BROWSER")}
</Typography>
<CenteredFlex marginTop="1rem">
<img
alt="ente Logo Circular"
height={150}
width={150}
src="/images/ente-circular.png"
/>
</CenteredFlex>
</FormPaper>
</Box>
</Box>
</>
);
};
export default PasskeysFlow;

View File

@@ -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;
}

View File

@@ -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<UtilitySectionProps> = ({ 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);

View File

@@ -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=<accountsToken>&package=<app package name>`.
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=<clientPackageName>&token=<accountsToken>`.
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`;
```

View File

@@ -166,7 +166,7 @@ const Page: React.FC<PageProps> = ({ 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;

View File

@@ -85,7 +85,7 @@ const Page: React.FC<PageProps> = ({ 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);

View File

@@ -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":

View File

@@ -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;
}

View File

@@ -45,6 +45,5 @@ export enum ACCOUNTS_PAGES {
VERIFY = "/verify",
ROOT = "/",
PASSKEYS = "/passkeys",
ACCOUNT_HANDOFF = "/account-handoff",
GENERATE = "/generate",
}