[web] Passkey related changes on the photos web app (#2043)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"];
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user