Remove indirection and rename

This commit is contained in:
Manav Rathi 2024-06-06 15:46:58 +05:30
parent 633e006b73
commit 229f7cc676
No known key found for this signature in database
7 changed files with 383 additions and 362 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 = () => {
/** Legacy alias, remove once mobile code is updated (it is still in beta). */
const Page = () => {
const router = useRouter();
const retrieveAccountData = () => {
try {
extractAccountsToken();
router.push(ACCOUNTS_PAGES.PASSKEYS);
} catch (e) {
log.error("Failed to deserialize and set passed user data", e);
router.push(ACCOUNTS_PAGES.LOGIN);
}
};
const getClientPackageName = () => {
const urlParams = new URLSearchParams(window.location.search);
const pkg = urlParams.get("package");
if (!pkg) return;
setData(LS_KEYS.CLIENT_PACKAGE, { name: pkg });
HTTPService.setHeaders({
"X-Client-Package": pkg,
});
};
const extractAccountsToken = () => {
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get("token");
if (!token) {
throw new Error("token not found");
}
const user = getData(LS_KEYS.USER) || {};
user.token = token;
setData(LS_KEYS.USER, user);
};
useEffect(() => {
getClientPackageName();
retrieveAccountData();
router.push("/passkeys/setup");
}, []);
return (
<VerticallyCentered>
<EnteSpinner />
</VerticallyCentered>
);
return <></>;
};
export default AccountHandoff;
export default Page;

View File

@ -1,305 +1,15 @@
import log from "@/next/log";
import { clientPackageName } from "@/next/types/app";
import { nullToUndefined } from "@/utils/transform";
import {
CenteredFlex,
VerticallyCentered,
} from "@ente/shared/components/Container";
import EnteButton from "@ente/shared/components/EnteButton";
import EnteSpinner from "@ente/shared/components/EnteSpinner";
import FormPaper from "@ente/shared/components/Form/FormPaper";
import { fromB64URLSafeNoPadding } from "@ente/shared/crypto/internal/libsodium";
import HTTPService from "@ente/shared/network/HTTPService";
import { LS_KEYS, setData } from "@ente/shared/storage/localStorage";
import InfoIcon from "@mui/icons-material/Info";
import { Box, Typography } from "@mui/material";
import { t } from "i18next";
import _sodium from "libsodium-wrappers";
import { useEffect, useState } from "react";
import {
beginPasskeyAuthentication,
finishPasskeyAuthentication,
isWhitelistedRedirect,
type BeginPasskeyAuthenticationResponse,
} from "services/passkey";
import { useRouter } from "next/router";
import { useEffect } from "react";
const PasskeysFlow = () => {
const [errored, setErrored] = useState(false);
const [invalidInfo, setInvalidInfo] = useState(false);
const [loading, setLoading] = useState(true);
const init = async () => {
const searchParams = new URLSearchParams(window.location.search);
// Extract redirect from the query params.
const redirect = nullToUndefined(searchParams.get("redirect"));
const redirectURL = redirect ? new URL(redirect) : undefined;
// Ensure that redirectURL is whitelisted, otherwise show an invalid
// "login" URL error to the user.
if (!redirectURL || !isWhitelistedRedirect(redirectURL)) {
setInvalidInfo(true);
setLoading(false);
return;
}
let pkg = clientPackageName["photos"];
if (redirectURL.protocol === "enteauth:") {
pkg = clientPackageName["auth"];
} else if (redirectURL.hostname.startsWith("accounts")) {
pkg = clientPackageName["accounts"];
}
setData(LS_KEYS.CLIENT_PACKAGE, { name: pkg });
// The server needs to know the app on whose behalf we're trying to log in
HTTPService.setHeaders({
"X-Client-Package": pkg,
});
// get passkeySessionID from the query params
const passkeySessionID = searchParams.get("passkeySessionID") as string;
setLoading(true);
let beginData: BeginPasskeyAuthenticationResponse;
try {
beginData = await beginAuthentication(passkeySessionID);
} catch (e) {
log.error("Couldn't begin passkey authentication", e);
setErrored(true);
return;
} finally {
setLoading(false);
}
let credential: Credential | null = null;
let tries = 0;
const maxTries = 3;
while (tries < maxTries) {
try {
credential = await getCredential(beginData.options.publicKey);
} catch (e) {
log.error("Couldn't get credential", e);
continue;
} finally {
tries++;
}
break;
}
if (!credential) {
if (!isWebAuthnSupported()) {
alert("WebAuthn is not supported in this browser");
}
setErrored(true);
return;
}
setLoading(true);
let finishData;
try {
finishData = await finishAuthentication(
credential,
passkeySessionID,
beginData.ceremonySessionID,
);
} catch (e) {
log.error("Couldn't finish passkey authentication", e);
setErrored(true);
setLoading(false);
return;
}
const encodedResponse = _sodium.to_base64(JSON.stringify(finishData));
// TODO-PK: Shouldn't this be URL encoded?
window.location.href = `${redirect}?response=${encodedResponse}`;
};
const beginAuthentication = async (sessionId: string) => {
const data = await beginPasskeyAuthentication(sessionId);
return data;
};
function isWebAuthnSupported(): boolean {
if (!navigator.credentials) {
return false;
}
return true;
}
const getCredential = async (
publicKey: any,
timeoutMillis: number = 60000, // Default timeout of 60 seconds
): Promise<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;
};
/** Legacy alias, remove once mobile code is updated (it is still in beta). */
const Page = () => {
const router = useRouter();
useEffect(() => {
init();
router.push("/passkeys/verify");
}, []);
if (loading) {
return (
<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

@ -11,7 +11,6 @@ import MenuItemDivider from "@ente/shared/components/Menu/MenuItemDivider";
import { MenuItemGroup } from "@ente/shared/components/Menu/MenuItemGroup";
import SingleInputForm from "@ente/shared/components/SingleInputForm";
import Titlebar from "@ente/shared/components/Titlebar";
import { ACCOUNTS_PAGES } from "@ente/shared/constants/pages";
import { getToken } from "@ente/shared/storage/localStorage/helpers";
import { formatDateTimeFull } from "@ente/shared/time/format";
import CalendarTodayIcon from "@mui/icons-material/CalendarToday";
@ -65,7 +64,7 @@ const Passkeys = () => {
const checkLoggedIn = () => {
const token = getToken();
if (!token) {
router.push(ACCOUNTS_PAGES.LOGIN);
router.push("/login");
}
};

View File

@ -0,0 +1,59 @@
import log from "@/next/log";
import { VerticallyCentered } from "@ente/shared/components/Container";
import EnteSpinner from "@ente/shared/components/EnteSpinner";
import HTTPService from "@ente/shared/network/HTTPService";
import { LS_KEYS, getData, setData } from "@ente/shared/storage/localStorage";
import { useRouter } from "next/router";
import { useEffect } from "react";
const AccountHandoff = () => {
const router = useRouter();
const retrieveAccountData = () => {
try {
extractAccountsToken();
router.push("/passkeys");
} catch (e) {
log.error("Failed to deserialize and set passed user data", e);
// Not much we can do here, but this redirect might be misleading.
router.push("/login");
}
};
const getClientPackageName = () => {
const urlParams = new URLSearchParams(window.location.search);
const pkg = urlParams.get("package");
if (!pkg) return;
setData(LS_KEYS.CLIENT_PACKAGE, { name: pkg });
HTTPService.setHeaders({
"X-Client-Package": pkg,
});
};
const extractAccountsToken = () => {
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get("token");
if (!token) {
throw new Error("token not found");
}
const user = getData(LS_KEYS.USER) || {};
user.token = token;
setData(LS_KEYS.USER, user);
};
useEffect(() => {
getClientPackageName();
retrieveAccountData();
}, []);
return (
<VerticallyCentered>
<EnteSpinner />
</VerticallyCentered>
);
};
export default AccountHandoff;

View File

@ -0,0 +1,305 @@
import log from "@/next/log";
import { clientPackageName } from "@/next/types/app";
import { nullToUndefined } from "@/utils/transform";
import {
CenteredFlex,
VerticallyCentered,
} from "@ente/shared/components/Container";
import EnteButton from "@ente/shared/components/EnteButton";
import EnteSpinner from "@ente/shared/components/EnteSpinner";
import FormPaper from "@ente/shared/components/Form/FormPaper";
import { fromB64URLSafeNoPadding } from "@ente/shared/crypto/internal/libsodium";
import HTTPService from "@ente/shared/network/HTTPService";
import { LS_KEYS, setData } from "@ente/shared/storage/localStorage";
import InfoIcon from "@mui/icons-material/Info";
import { Box, Typography } from "@mui/material";
import { t } from "i18next";
import _sodium from "libsodium-wrappers";
import { useEffect, useState } from "react";
import {
beginPasskeyAuthentication,
finishPasskeyAuthentication,
isWhitelistedRedirect,
type BeginPasskeyAuthenticationResponse,
} from "services/passkey";
const PasskeysFlow = () => {
const [errored, setErrored] = useState(false);
const [invalidInfo, setInvalidInfo] = useState(false);
const [loading, setLoading] = useState(true);
const init = async () => {
const searchParams = new URLSearchParams(window.location.search);
// Extract redirect from the query params.
const redirect = nullToUndefined(searchParams.get("redirect"));
const redirectURL = redirect ? new URL(redirect) : undefined;
// Ensure that redirectURL is whitelisted, otherwise show an invalid
// "login" URL error to the user.
if (!redirectURL || !isWhitelistedRedirect(redirectURL)) {
setInvalidInfo(true);
setLoading(false);
return;
}
let pkg = clientPackageName["photos"];
if (redirectURL.protocol === "enteauth:") {
pkg = clientPackageName["auth"];
} else if (redirectURL.hostname.startsWith("accounts")) {
pkg = clientPackageName["accounts"];
}
setData(LS_KEYS.CLIENT_PACKAGE, { name: pkg });
// The server needs to know the app on whose behalf we're trying to log in
HTTPService.setHeaders({
"X-Client-Package": pkg,
});
// get passkeySessionID from the query params
const passkeySessionID = searchParams.get("passkeySessionID") as string;
setLoading(true);
let beginData: BeginPasskeyAuthenticationResponse;
try {
beginData = await beginAuthentication(passkeySessionID);
} catch (e) {
log.error("Couldn't begin passkey authentication", e);
setErrored(true);
return;
} finally {
setLoading(false);
}
let credential: Credential | null = null;
let tries = 0;
const maxTries = 3;
while (tries < maxTries) {
try {
credential = await getCredential(beginData.options.publicKey);
} catch (e) {
log.error("Couldn't get credential", e);
continue;
} finally {
tries++;
}
break;
}
if (!credential) {
if (!isWebAuthnSupported()) {
alert("WebAuthn is not supported in this browser");
}
setErrored(true);
return;
}
setLoading(true);
let finishData;
try {
finishData = await finishAuthentication(
credential,
passkeySessionID,
beginData.ceremonySessionID,
);
} catch (e) {
log.error("Couldn't finish passkey authentication", e);
setErrored(true);
setLoading(false);
return;
}
const encodedResponse = _sodium.to_base64(JSON.stringify(finishData));
// TODO-PK: Shouldn't this be URL encoded?
window.location.href = `${redirect}?response=${encodedResponse}`;
};
const beginAuthentication = async (sessionId: string) => {
const data = await beginPasskeyAuthentication(sessionId);
return data;
};
function isWebAuthnSupported(): boolean {
if (!navigator.credentials) {
return false;
}
return true;
}
const getCredential = async (
publicKey: any,
timeoutMillis: number = 60000, // Default timeout of 60 seconds
): Promise<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

@ -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 pkg = clientPackageName["photos"];
window.open(
`${accountsAppURL()}${
ACCOUNTS_PAGES.ACCOUNT_HANDOFF
}?package=${clientPackageName["photos"]}&token=${accountsToken}`,
`${accountsAppURL()}/passkeys/setup?package=${pkg}&token=${accountsToken}`,
);
} catch (e) {
log.error("failed to redirect to accounts page", e);

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