[web] Passkey related changes on the photos web app (#2043)

This commit is contained in:
Manav Rathi
2024-06-07 11:07:45 +05:30
committed by GitHub
11 changed files with 122 additions and 90 deletions

View File

@@ -1,3 +1,6 @@
import Page from "@ente/accounts/pages/passkeys/finish";
import Page_ from "@ente/accounts/pages/passkeys/finish";
import { useAppContext } from "../_app";
const Page = () => <Page_ appContext={useAppContext()} />;
export default Page;

View File

@@ -1,10 +1,6 @@
import log from "@/next/log";
import { savedLogs } from "@/next/log-web";
import { clientPackageName } from "@/next/types/app";
import {
configurePasskeyRecovery,
isPasskeyRecoveryEnabled,
} from "@ente/accounts/services/passkey";
import { openAccountsManagePasskeysPage } from "@ente/accounts/services/passkey";
import { SpaceBetweenFlex } from "@ente/shared/components/Container";
import { EnteLogo } from "@ente/shared/components/EnteLogo";
import EnteSpinner from "@ente/shared/components/EnteSpinner";
@@ -12,14 +8,7 @@ import { EnteMenuItem } from "@ente/shared/components/Menu/EnteMenuItem";
import RecoveryKey from "@ente/shared/components/RecoveryKey";
import ThemeSwitcher from "@ente/shared/components/ThemeSwitcher";
import { PHOTOS_PAGES as PAGES } from "@ente/shared/constants/pages";
import ComlinkCryptoWorker from "@ente/shared/crypto";
import { getRecoveryKey } from "@ente/shared/crypto/helpers";
import {
encryptToB64,
generateEncryptionKey,
} from "@ente/shared/crypto/internal/libsodium";
import { useLocalState } from "@ente/shared/hooks/useLocalState";
import { accountsAppURL } from "@ente/shared/network/api";
import { LS_KEYS, getData, setData } from "@ente/shared/storage/localStorage";
import { THEME_COLOR } from "@ente/shared/themes/constants";
import { downloadAsFile } from "@ente/shared/utils";
@@ -65,7 +54,7 @@ import { Trans } from "react-i18next";
import billingService from "services/billingService";
import { getUncategorizedCollection } from "services/collectionService";
import exportService from "services/export";
import { getAccountsToken, getUserDetailsV2 } from "services/userService";
import { getUserDetailsV2 } from "services/userService";
import { CollectionSummaries } from "types/collection";
import { UserDetails } from "types/user";
import {
@@ -439,6 +428,7 @@ const UtilitySection: React.FC<UtilitySectionProps> = ({ closeSidebar }) => {
const router = useRouter();
const appContext = useContext(AppContext);
const {
appName,
setDialogMessage,
startLoading,
watchFolderView,
@@ -483,34 +473,7 @@ const UtilitySection: React.FC<UtilitySectionProps> = ({ closeSidebar }) => {
closeSidebar();
try {
// check if the user has passkey recovery enabled
const recoveryEnabled = await isPasskeyRecoveryEnabled();
if (!recoveryEnabled) {
// let's create the necessary recovery information
const recoveryKey = await getRecoveryKey();
const resetSecret = await generateEncryptionKey();
const cryptoWorker = await ComlinkCryptoWorker.getInstance();
const encryptionResult = await encryptToB64(
resetSecret,
await cryptoWorker.fromHex(recoveryKey),
);
await configurePasskeyRecovery(
resetSecret,
encryptionResult.encryptedData,
encryptionResult.nonce,
);
}
// Ente Accounts specific JWT token.
const accountsToken = await getAccountsToken();
const client = clientPackageName["photos"];
window.open(
`${accountsAppURL()}/passkeys/handoff?token=${accountsToken}&client=${client}`,
);
await openAccountsManagePasskeysPage(appName);
} catch (e) {
log.error("failed to redirect to accounts page", e);
}

View File

@@ -1,3 +1,6 @@
import Page from "@ente/accounts/pages/passkeys/finish";
import Page_ from "@ente/accounts/pages/passkeys/finish";
import { useAppContext } from "../_app";
const Page = () => <Page_ appContext={useAppContext()} />;
export default Page;

View File

@@ -65,24 +65,6 @@ export const getFamiliesToken = async () => {
}
};
export const getAccountsToken = async () => {
try {
const token = getToken();
const resp = await HTTPService.get(
`${ENDPOINT}/users/accounts-token`,
null,
{
"X-Auth-Token": token,
},
);
return resp.data["accountsToken"];
} catch (e) {
log.error("failed to get accounts token", e);
throw e;
}
};
export const getRoadmapRedirectURL = async () => {
try {
const token = getToken();

View File

@@ -25,11 +25,13 @@ As of Feb 2024, Ente clients have a button to navigate to a WebView of Ente
Accounts. Ente Accounts allows users to add and manage their registered
passkeys.
❗ Your WebView MUST invoke the operating-system's default browser, or an
equivalent browser with matching API parity. Otherwise, the user will not be
able to register or use registered WebAuthn credentials.
> [!NOTE]
>
> Your WebView MUST invoke the operating-system's default browser, or an
> equivalent browser with matching API parity. Otherwise, the user will not be
> able to register or use registered WebAuthn credentials.
### Accounts-Specific Session Token
### Ente Accounts specific session token
When a user clicks this button, the client sends a request for an
Accounts-specific JWT session token as shown below. **The Ente Accounts API is
@@ -50,15 +52,16 @@ used.** This restriction is a byproduct of the enablement for automatic login.
| ------------- | ------ | ----------------------------------------------------------------- |
| accountsToken | string | The Accounts-specific JWT session token. It is encoded in base64. |
### Automatically logging into Accounts
### Automatically logging into Ente Accounts
Clients open a WebView with the URL
`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
page. Otherwise, they will be required to login with their Ente credentials.
If the token is valid, the user will be automatically redirected to the passkeys
management page. Otherwise, they will be required to login with their Ente
credentials.
## Registering a WebAuthn credential

View File

@@ -14,11 +14,6 @@
"build:payments": "yarn workspace payments build",
"build:photos": "yarn workspace photos next build",
"build:staff": "yarn workspace staff build",
"deploy:accounts": "open 'https://github.com/ente-io/ente/compare/deploy/accounts...main?quick_pull=1&title=[web]+Deploy+accounts&body=Deploy+accounts.ente.io'",
"deploy:auth": "open 'https://github.com/ente-io/ente/compare/deploy/auth...main?quick_pull=1&title=[web]+Deploy+auth&body=Deploy+auth.ente.io'",
"deploy:cast": "open 'https://github.com/ente-io/ente/compare/deploy/cast...main?quick_pull=1&title=[web]+Deploy+cast&body=Deploy+cast.ente.io'",
"deploy:payments": "open 'https://github.com/ente-io/ente/compare/deploy/payments...main?quick_pull=1&title=[web]+Deploy+payments&body=Deploy+payments.ente.io'",
"deploy:photos": "open 'https://github.com/ente-io/ente/compare/deploy/photos...main?quick_pull=1&title=[web]+Deploy+photos&body=Deploy+web.ente.io'",
"dev": "yarn dev:photos",
"dev:accounts": "yarn workspace accounts next dev -p 3001",
"dev:albums": "yarn workspace photos next dev -p 3002",

View File

@@ -18,7 +18,7 @@ import {
} from "@ente/shared/crypto/helpers";
import type { B64EncryptionResult } from "@ente/shared/crypto/types";
import { CustomError } from "@ente/shared/error";
import { accountsAppURL, apiOrigin } from "@ente/shared/network/api";
import { apiOrigin } from "@ente/shared/network/api";
import InMemoryStore, { MS_KEYS } from "@ente/shared/storage/InMemoryStore";
import {
LS_KEYS,
@@ -44,6 +44,7 @@ import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { getSRPAttributes } from "../api/srp";
import { PAGES } from "../constants/pages";
import { redirectUserToPasskeyVerificationFlow } from "../services/passkey";
import { appHomeRoute } from "../services/redirect";
import {
configureSRP,
@@ -166,10 +167,8 @@ const Page: React.FC<PageProps> = ({ appContext }) => {
isTwoFactorPasskeysEnabled: true,
});
InMemoryStore.set(MS_KEYS.REDIRECT_URL, PAGES.ROOT);
window.location.href = `${accountsAppURL()}/passkeys/verify?passkeySessionID=${passkeySessionID}&redirect=${
window.location.origin
}/passkeys/finish`;
return undefined;
redirectUserToPasskeyVerificationFlow(passkeySessionID);
throw Error(CustomError.TWO_FACTOR_ENABLED);
} else if (twoFactorSessionID) {
const sessionKeyAttributes =
await cryptoWorker.generateKeyAndEncryptToB64(kek);

View File

@@ -1,10 +1,11 @@
import { PAGES } from "@ente/accounts/constants/pages";
import { VerticallyCentered } from "@ente/shared/components/Container";
import EnteSpinner from "@ente/shared/components/EnteSpinner";
import InMemoryStore, { MS_KEYS } from "@ente/shared/storage/InMemoryStore";
import { LS_KEYS, getData, setData } from "@ente/shared/storage/localStorage";
import { useRouter } from "next/router";
import React, { useEffect } from "react";
import { PAGES } from "../../constants/pages";
import type { PageProps } from "../../types/page";
/**
* [Note: Finish passkey flow in the requesting app]
@@ -13,7 +14,7 @@ import React, { useEffect } from "react";
* invoked the passkey flow since it needs to save the obtained credentials
* in local storage (which is tied to the current origin).
*/
const Page: React.FC = () => {
const Page: React.FC<PageProps> = () => {
const router = useRouter();
useEffect(() => {
@@ -26,7 +27,7 @@ const Page: React.FC = () => {
const redirectURL = InMemoryStore.get(MS_KEYS.REDIRECT_URL);
InMemoryStore.delete(MS_KEYS.REDIRECT_URL);
router.push(redirectURL ?? PAGES.ROOT);
router.push(redirectURL ?? PAGES.CREDENTIALS);
}, []);
return (

View File

@@ -10,7 +10,6 @@ import SingleInputForm, {
type SingleInputFormProps,
} from "@ente/shared/components/SingleInputForm";
import { ApiError } from "@ente/shared/error";
import { accountsAppURL } from "@ente/shared/network/api";
import InMemoryStore, { MS_KEYS } from "@ente/shared/storage/InMemoryStore";
import localForage from "@ente/shared/storage/localForage";
import { LS_KEYS, getData, setData } from "@ente/shared/storage/localStorage";
@@ -28,6 +27,7 @@ import { useEffect, useState } from "react";
import { Trans } from "react-i18next";
import { putAttributes, sendOtt, verifyOtt } from "../api/user";
import { PAGES } from "../constants/pages";
import { redirectUserToPasskeyVerificationFlow } from "../services/passkey";
import { configureSRP } from "../services/srp";
import type { PageProps } from "../types/page";
import type { SRPSetupAttributes } from "../types/srp";
@@ -85,10 +85,7 @@ const Page: React.FC<PageProps> = ({ appContext }) => {
isTwoFactorPasskeysEnabled: true,
});
setIsFirstLogin(true);
window.location.href = `${accountsAppURL()}/passkeys/verify?passkeySessionID=${passkeySessionID}&redirect=${
window.location.origin
}/passkeys/finish`;
router.push(PAGES.CREDENTIALS);
redirectUserToPasskeyVerificationFlow(passkeySessionID);
} else if (twoFactorSessionID) {
setData(LS_KEYS.USER, {
email,

View File

@@ -1,15 +1,76 @@
import log from "@/next/log";
import type { AppName } from "@/next/types/app";
import { clientPackageName } from "@/next/types/app";
import ComlinkCryptoWorker from "@ente/shared/crypto";
import { getRecoveryKey } from "@ente/shared/crypto/helpers";
import {
encryptToB64,
generateEncryptionKey,
} from "@ente/shared/crypto/internal/libsodium";
import { CustomError } from "@ente/shared/error";
import HTTPService from "@ente/shared/network/HTTPService";
import { getEndpoint } from "@ente/shared/network/api";
import { accountsAppURL, apiOrigin } from "@ente/shared/network/api";
import { getToken } from "@ente/shared/storage/localStorage/helpers";
/**
* Redirect user to Ente accounts app to authenticate using their second factor,
* a passkey they've configured.
*
* On successful verification, the accounts app will redirect back to our
* `/passkeys/finish` page.
*
* @param passkeySessionID An identifier provided by museum for this passkey
* verification session.
*/
export const redirectUserToPasskeyVerificationFlow = (
passkeySessionID: string,
) => {
const redirect = `${window.location.origin}/passkeys/finish`;
const params = new URLSearchParams({ passkeySessionID, redirect });
window.location.href = `${accountsAppURL()}/passkeys/verify?${params.toString()}`;
};
/**
* Open a new window showing a page on the Ente accounts app where the user can
* see and their manage their passkeys.
*
* @param appName The {@link AppName} of the app which is calling this function.
*/
export const openAccountsManagePasskeysPage = async (appName: AppName) => {
// check if the user has passkey recovery enabled
const recoveryEnabled = await isPasskeyRecoveryEnabled();
if (!recoveryEnabled) {
// let's create the necessary recovery information
const recoveryKey = await getRecoveryKey();
const resetSecret = await generateEncryptionKey();
const cryptoWorker = await ComlinkCryptoWorker.getInstance();
const encryptionResult = await encryptToB64(
resetSecret,
await cryptoWorker.fromHex(recoveryKey),
);
await configurePasskeyRecovery(
resetSecret,
encryptionResult.encryptedData,
encryptionResult.nonce,
);
}
const token = await getAccountsToken();
const client = clientPackageName[appName];
const params = new URLSearchParams({ token, client });
window.open(`${accountsAppURL()}/passkeys/handoff?${params.toString()}`);
};
export const isPasskeyRecoveryEnabled = async () => {
try {
const token = getToken();
const resp = await HTTPService.get(
`${getEndpoint()}/users/two-factor/recovery-status`,
`${apiOrigin()}/users/two-factor/recovery-status`,
{},
{
"X-Auth-Token": token,
@@ -27,7 +88,7 @@ export const isPasskeyRecoveryEnabled = async () => {
}
};
export const configurePasskeyRecovery = async (
const configurePasskeyRecovery = async (
secret: string,
userSecretCipher: string,
userSecretNonce: string,
@@ -36,7 +97,7 @@ export const configurePasskeyRecovery = async (
const token = getToken();
const resp = await HTTPService.post(
`${getEndpoint()}/users/two-factor/passkeys/configure-recovery`,
`${apiOrigin()}/users/two-factor/passkeys/configure-recovery`,
{
secret,
userSecretCipher,
@@ -56,3 +117,21 @@ export const configurePasskeyRecovery = async (
throw e;
}
};
/**
* Fetch an Ente Accounts specific JWT token.
*
* This token can be used to authenticate with the Ente accounts app.
*/
const getAccountsToken = async () => {
const token = getToken();
const resp = await HTTPService.get(
`${apiOrigin()}/users/accounts-token`,
undefined,
{
"X-Auth-Token": token,
},
);
return resp.data["accountsToken"];
};

View File

@@ -20,6 +20,13 @@ export interface VerifyMasterPasswordFormProps {
) => void;
buttonText: string;
submitButtonProps?: ButtonProps;
/**
* A callback invoked when the form wants to get {@link KeyAttributes}.
*
* This function can throw an `CustomError.TWO_FACTOR_ENABLED` to signal to
* the form that some other form of second factor is enabled and the user
* has been redirected to a two factor verification page.
*/
getKeyAttributes?: (kek: string) => Promise<KeyAttributes | undefined>;
srpAttributes?: SRPAttributes;
}