[web] Passkeys - Various code tweaks (#2042)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
50
web/apps/accounts/src/pages/passkeys/handoff.tsx
Normal file
50
web/apps/accounts/src/pages/passkeys/handoff.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
306
web/apps/accounts/src/pages/passkeys/verify.tsx
Normal file
306
web/apps/accounts/src/pages/passkeys/verify.tsx
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`;
|
||||
```
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,5 @@ export enum ACCOUNTS_PAGES {
|
||||
VERIFY = "/verify",
|
||||
ROOT = "/",
|
||||
PASSKEYS = "/passkeys",
|
||||
ACCOUNT_HANDOFF = "/account-handoff",
|
||||
GENERATE = "/generate",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user