[web] Cleanup passkeys manage page (#2049)

Tested on localhost
This commit is contained in:
Manav Rathi
2024-06-07 16:34:13 +05:30
committed by GitHub
15 changed files with 480 additions and 227 deletions

View File

@@ -1,4 +1,5 @@
import { CustomHead } from "@/next/components/Head";
import { setClientPackageForAuthenticatedRequests } from "@/next/http";
import { setupI18n } from "@/next/i18n";
import { logUnhandledErrorsAndRejections } from "@/next/log-web";
import { appTitle, type AppName, type BaseAppContextT } from "@/next/types/app";
@@ -12,7 +13,7 @@ import EnteSpinner from "@ente/shared/components/EnteSpinner";
import { AppNavbar } from "@ente/shared/components/Navbar/app";
import { useLocalState } from "@ente/shared/hooks/useLocalState";
import HTTPService from "@ente/shared/network/HTTPService";
import { LS_KEYS, getData } from "@ente/shared/storage/localStorage";
import { LS_KEYS } from "@ente/shared/storage/localStorage";
import { getTheme } from "@ente/shared/themes";
import { THEME_COLOR } from "@ente/shared/themes/constants";
import { CssBaseline, useMediaQuery } from "@mui/material";
@@ -64,10 +65,11 @@ export default function App({ Component, pageProps }: AppProps) {
}, []);
const setupPackageName = () => {
const pkg = getData(LS_KEYS.CLIENT_PACKAGE);
if (!pkg) return;
const clientPackage = localStorage.getItem("clientPackage");
if (!clientPackage) return;
setClientPackageForAuthenticatedRequests(clientPackage);
HTTPService.setHeaders({
"X-Client-Package": pkg.name,
"X-Client-Package": clientPackage,
});
};

View File

@@ -1,3 +1,4 @@
import { setClientPackageForAuthenticatedRequests } from "@/next/http";
import log from "@/next/log";
import { VerticallyCentered } from "@ente/shared/components/Container";
import EnteSpinner from "@ente/shared/components/EnteSpinner";
@@ -16,12 +17,13 @@ const Page: React.FC = () => {
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const client = urlParams.get("client");
if (client) {
const clientPackage = urlParams.get("client");
if (clientPackage) {
// TODO-PK: mobile is not passing it. is that expected?
setData(LS_KEYS.CLIENT_PACKAGE, { name: client });
localStorage.setItem("clientPackage", clientPackage);
setClientPackageForAuthenticatedRequests(clientPackage);
HTTPService.setHeaders({
"X-Client-Package": client,
"X-Client-Package": clientPackage,
});
}
@@ -34,7 +36,6 @@ const Page: React.FC = () => {
const user = getData(LS_KEYS.USER) || {};
user.token = token;
setData(LS_KEYS.USER, user);
router.push("/passkeys");

View File

@@ -43,8 +43,7 @@ const Page: React.FC = () => {
const refreshPasskeys = async () => {
try {
const { passkeys } = await getPasskeys();
setPasskeys(passkeys || []);
setPasskeys(await getPasskeys());
} catch (e) {
log.error("Failed to fetch passkeys", e);
}
@@ -89,8 +88,16 @@ const Page: React.FC = () => {
await registerPasskey(inputValue);
} catch (e) {
log.error("Failed to register a new passkey", e);
// TODO-PK: localize
setFieldError("Could not add passkey");
// If the user cancels the operation, then an error with name
// "NotAllowedError" is thrown.
//
// Ignore this, but in other cases add an error indicator to the add
// passkey text field. The browser is expected to already have shown
// an error dialog to the user.
if (!(e instanceof Error && e.name == "NotAllowedError")) {
// TODO-PK: localize
setFieldError("Could not add passkey");
}
return;
}
await refreshPasskeys();
@@ -215,8 +222,8 @@ const ManagePasskeyDrawer: React.FC<ManagePasskeyDrawerProps> = ({
passkey,
onUpdateOrDeletePasskey,
}) => {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showRenameDialog, setShowRenameDialog] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
return (
<>
@@ -260,18 +267,6 @@ const ManagePasskeyDrawer: React.FC<ManagePasskeyDrawerProps> = ({
)}
</EnteDrawer>
{passkey && (
<DeletePasskeyDialog
open={showDeleteDialog}
onClose={() => setShowDeleteDialog(false)}
passkey={passkey}
onDeletePasskey={() => {
setShowDeleteDialog(false);
onUpdateOrDeletePasskey();
}}
/>
)}
{passkey && (
<RenamePasskeyDialog
open={showRenameDialog}
@@ -283,10 +278,69 @@ const ManagePasskeyDrawer: React.FC<ManagePasskeyDrawerProps> = ({
}}
/>
)}
{passkey && (
<DeletePasskeyDialog
open={showDeleteDialog}
onClose={() => setShowDeleteDialog(false)}
passkey={passkey}
onDeletePasskey={() => {
setShowDeleteDialog(false);
onUpdateOrDeletePasskey();
}}
/>
)}
</>
);
};
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 RenamePasskeyDialog: React.FC<RenamePasskeyDialogProps> = ({
open,
onClose,
passkey,
onRenamePasskey,
}) => {
const fullScreen = useMediaQuery("(max-width: 428px)");
const handleSubmit = async (inputValue: string) => {
try {
await renamePasskey(passkey.id, inputValue);
onRenamePasskey();
} catch (e) {
log.error("Failed to rename passkey", e);
}
};
return (
<DialogBoxV2
fullWidth
{...{ open, onClose, fullScreen }}
attributes={{ title: t("RENAME_PASSKEY") }}
>
<SingleInputForm
initialValue={passkey.friendlyName}
callback={handleSubmit}
placeholder={t("ENTER_PASSKEY_NAME")}
buttonText={t("RENAME")}
fieldType="text"
secondaryButtonAction={onClose}
submitButtonProps={{ sx: { mt: 1, mb: 2 } }}
/>
</DialogBoxV2>
);
};
interface DeletePasskeyDialogProps {
/** If `true`, then the dialog is shown. */
open: boolean;
@@ -343,51 +397,3 @@ const DeletePasskeyDialog: React.FC<DeletePasskeyDialogProps> = ({
</DialogBoxV2>
);
};
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 RenamePasskeyDialog: React.FC<RenamePasskeyDialogProps> = ({
open,
onClose,
passkey,
onRenamePasskey,
}) => {
const fullScreen = useMediaQuery("(max-width: 428px)");
const onSubmit = async (inputValue: string) => {
try {
await renamePasskey(passkey.id, inputValue);
onRenamePasskey();
} catch (e) {
log.error("Failed to rename passkey", e);
return;
}
};
return (
<DialogBoxV2
fullWidth
{...{ open, onClose, fullScreen }}
attributes={{ title: t("RENAME_PASSKEY") }}
>
<SingleInputForm
initialValue={passkey?.friendlyName}
callback={onSubmit}
placeholder={t("ENTER_PASSKEY_NAME")}
buttonText={t("RENAME")}
fieldType="text"
secondaryButtonAction={onClose}
submitButtonProps={{ sx: { mt: 1, mb: 2 } }}
/>
</DialogBoxV2>
);
};

View File

@@ -1,3 +1,4 @@
import { setClientPackageForAuthenticatedRequests } from "@/next/http";
import log from "@/next/log";
import { clientPackageName } from "@/next/types/app";
import { nullToUndefined } from "@/utils/transform";
@@ -10,7 +11,6 @@ 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";
@@ -46,17 +46,24 @@ const PasskeysFlow = () => {
return;
}
let pkg = clientPackageName["photos"];
if (redirectURL.protocol === "enteauth:") {
pkg = clientPackageName["auth"];
} else if (redirectURL.hostname.startsWith("accounts")) {
pkg = clientPackageName["accounts"];
let clientPackage = nullToUndefined(searchParams.get("client"));
// Mobile apps don't pass the client header, deduce their client package
// name from the redirect URL that they provide. TODO-PK: Pass?
if (!clientPackage) {
clientPackage = clientPackageName["photos"];
if (redirectURL.protocol === "enteauth:") {
clientPackage = clientPackageName["auth"];
} else if (redirectURL.hostname.startsWith("accounts")) {
clientPackage = 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
localStorage.setItem("clientPackage", clientPackage);
// The server needs to know the app on whose behalf we're trying to
// authenticate.
setClientPackageForAuthenticatedRequests(clientPackage);
HTTPService.setHeaders({
"X-Client-Package": pkg,
"X-Client-Package": clientPackage,
});
// get passkeySessionID from the query params

View File

@@ -1,84 +1,318 @@
import { isDevBuild } from "@/next/env";
import log from "@/next/log";
import { ensure } from "@/utils/ensure";
import { toB64URLSafeNoPadding } from "@ente/shared/crypto/internal/libsodium";
import { nullToUndefined } from "@/utils/transform";
import {
fromB64URLSafeNoPadding,
toB64URLSafeNoPadding,
} from "@ente/shared/crypto/internal/libsodium";
import HTTPService from "@ente/shared/network/HTTPService";
import { getEndpoint } from "@ente/shared/network/api";
import { apiOrigin, getEndpoint } from "@ente/shared/network/api";
import { getToken } from "@ente/shared/storage/localStorage/helpers";
import _sodium from "libsodium-wrappers";
import { z } from "zod";
const ENDPOINT = getEndpoint();
export interface Passkey {
id: string;
userID: number;
friendlyName: string;
createdAt: number;
/**
* Variant of {@link authenticatedRequestHeaders} but for authenticated requests
* made by the accounts app.
*
* We cannot use {@link authenticatedRequestHeaders} directly because the
* accounts app does not save a full user and instead only saves the user's
* token (and that token too is scoped to the accounts APIs).
*/
const accountsAuthenticatedRequestHeaders = (): Record<string, string> => {
const token = getToken();
if (!token) throw new Error("Missing accounts token");
const headers: Record<string, string> = { "X-Auth-Token": token };
const clientPackage = nullToUndefined(
localStorage.getItem("clientPackage"),
);
if (clientPackage) headers["X-Client-Package"] = clientPackage;
return headers;
};
const Passkey = z.object({
/** A unique ID for the passkey */
id: z.string(),
/**
* An arbitrary name associated by the user with the passkey (a.k.a
* its "friendly name").
*/
friendlyName: z.string(),
/**
* Epoch milliseconds when this passkey was created.
*/
createdAt: z.number(),
});
export type Passkey = z.infer<typeof Passkey>;
const GetPasskeysResponse = z.object({
passkeys: z.array(Passkey),
});
/**
* Fetch the existing passkeys for the user.
*
* @returns An array of {@link Passkey}s. The array will be empty if the user
* has no passkeys.
*/
export const getPasskeys = async () => {
const url = `${apiOrigin()}/passkeys`;
const res = await fetch(url, {
headers: accountsAuthenticatedRequestHeaders(),
});
if (!res.ok) throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`);
const { passkeys } = GetPasskeysResponse.parse(await res.json());
return passkeys;
};
/**
* Rename one of the user's existing passkey with the given {@link id}.
*
* @param id The `id` of the existing passkey to rename.
*
* @param name The new name (a.k.a. "friendly name").
*/
export const renamePasskey = async (id: string, name: string) => {
const params = new URLSearchParams({ friendlyName: name });
const url = `${apiOrigin()}/passkeys/${id}`;
const res = await fetch(`${url}?${params.toString()}`, {
method: "PATCH",
headers: accountsAuthenticatedRequestHeaders(),
});
if (!res.ok) throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`);
};
/**
* Delete one of the user's existing passkeys.
*
* @param id The `id` of the existing passkey to delete.
*/
export const deletePasskey = async (id: string) => {
const url = `${apiOrigin()}/passkeys/${id}`;
const res = await fetch(url, {
method: "DELETE",
headers: accountsAuthenticatedRequestHeaders(),
});
if (!res.ok) throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`);
};
/**
* 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
* (a.k.a. "friendly name").
*/
export const registerPasskey = async (name: string) => {
// Get options (and sessionID) from the backend.
const { sessionID, options } = await beginPasskeyRegistration();
// Ask the browser to new (public key) credentials using these options.
const credential = ensure(await navigator.credentials.create(options));
// Finish by letting the backend know about these credentials so that it can
// save the public key for future authentication.
await finishPasskeyRegistration(name, sessionID, credential);
};
interface BeginPasskeyRegistrationResponse {
/**
* An identifier for this registration ceremony / session.
*
* This sessionID is subsequently passed to the API when finish credential
* creation to tie things together.
*/
sessionID: string;
/**
* Options that should be passed to `navigator.credential.create` when
* creating the new {@link Credential}.
*/
options: {
publicKey: PublicKeyCredentialCreationOptions;
};
}
export const getPasskeys = async () => {
const token = getToken();
if (!token) return;
const response = await HTTPService.get(
`${ENDPOINT}/passkeys`,
{},
{ "X-Auth-Token": token },
const beginPasskeyRegistration = async () => {
const url = `${apiOrigin()}/passkeys/registration/begin`;
const res = await fetch(url, {
headers: accountsAuthenticatedRequestHeaders(),
});
if (!res.ok) throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`);
// [Note: Converting binary data in WebAuthn API payloads]
//
// The server returns a JSON containing a "sessionID" (to tie together the
// beginning and the end of the registration), and "options" that we should
// pass on to the browser when asking it to create credentials.
//
// However, some massaging needs to be done first. On the backend, we use
// the [go-webauthn](https://github.com/go-webauthn/webauthn) library to
// begin the registration ceremony, and we verbatim credential creation
// options that the library returns to us. These are meant to plug directly
// into `CredentialCreationOptions` that `navigator.credential.create`
// expects. Specifically, since we're creating a public key credential, the
// `publicKey` attribute of the returned options will be in the shape of the
// `PublicKeyCredentialCreationOptions` expected by the browser). Except,
// binary data.
//
// Binary data in the returned `PublicKeyCredentialCreationOptions` are
// serialized as a "URLEncodedBase64", which is a URL-encoded Base64 string
// without any padding. The library is following the WebAuthn recommendation
// when it does this:
//
// > The term "Base64url Encoding refers" to the base64 encoding using the
// > URL- and filename-safe character set defined in Section 5 of RFC4648,
// > which all trailing '=' characters omitted (as permitted by Section 3.2)
// >
// > https://www.w3.org/TR/webauthn-3/#base64url-encoding
//
// However, the browser expects binary data as an "ArrayBuffer, TypedArray
// or DataView".
// https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredentialCreationOptions
//
// So we do the conversion here.
//
// 1. To avoid inventing an intermediary type and the boilerplate that'd
// come with it, we do a force typecast the options in the response to
// one that has `PublicKeyCredentialCreationOptions`.
//
// 2. Convert the two binary data fields that are expected to be in the
// response from URLEncodedBase64 strings to Uint8Arrays. There is a
// third possibility, excludedCredentials[].id, but that we don't
// currently use.
//
// The web.dev guide calls this out too:
//
// > ArrayBuffer values transferred from the server such as `challenge`,
// > `user.id` and credential `id` for `excludeCredentials` need to be
// > encoded on transmission. Don't forget to decode them on the frontend
// > before passing to the WebAuthn API call. We recommend using Base64URL
// > encode.
// >
// > https://web.dev/articles/passkey-registration
//
// So that's that. But to further complicate things, the libdom.ts typings
// included with the current TypeScript version (5.4) indicate these binary
// types as a:
//
// type BufferSource = ArrayBufferView | ArrayBuffer
//
// However MDN documentation states that they can be TypedArrays (e.g.
// Uint8Arrays), and using Uint8Arrays works in practice too. So another
// force cast is needed.
//
// ----
//
// Finally, the same process needs to happen, in reverse, when we're sending
// the browser's response to credential creation to our backend for storing
// that credential (for future authentication). Binary fields need to be
// converted to URL-safe B64 before transmission.
const { sessionID, options } =
(await res.json()) as BeginPasskeyRegistrationResponse;
options.publicKey.challenge = await serverB64ToBinary(
options.publicKey.challenge,
);
return await response.data;
options.publicKey.user.id = await serverB64ToBinary(
options.publicKey.user.id,
);
return { sessionID, options };
};
export const renamePasskey = async (id: string, name: string) => {
try {
const token = getToken();
if (!token) return;
const response = await HTTPService.patch(
`${ENDPOINT}/passkeys/${id}`,
{},
{ friendlyName: name },
{ "X-Auth-Token": token },
);
return await response.data;
} catch (e) {
log.error("rename passkey failed", e);
throw e;
}
/**
* This is the function that does the dirty work for the binary conversion,
* including the unfortunate typecasts.
*
* See: [Note: Converting binary data in WebAuthn API payloads]
*/
const serverB64ToBinary = async (b: BufferSource) => {
// This is actually a URL-safe B64 string without trailing padding.
const b64String = b as unknown as string;
// Convert it to a Uint8Array by doing the appropriate B64 decoding.
const bytes = await fromB64URLSafeNoPadding(b64String);
// Cast again to satisfy the incomplete BufferSource type.
return bytes as unknown as BufferSource;
};
export const deletePasskey = async (id: string) => {
try {
const token = getToken();
if (!token) return;
const response = await HTTPService.delete(
`${ENDPOINT}/passkeys/${id}`,
{},
{},
{ "X-Auth-Token": token },
);
return await response.data;
} catch (e) {
log.error("delete passkey failed", e);
throw e;
}
/**
* This is the sibling of {@link serverB64ToBinary} that does the conversions in
* the other direction.
*
* See: [Note: Converting binary data in WebAuthn API payloads]
*/
const binaryToServerB64 = async (b: ArrayBuffer) => {
// Convert it to a Uint8Array
const bytes = new Uint8Array(b);
// Convert to a URL-safe B64 string without any trailing padding.
const b64String = await toB64URLSafeNoPadding(bytes);
// Lie about the types to make the compiler happy.
return b64String as unknown as BufferSource;
};
export const getPasskeyRegistrationOptions = async () => {
try {
const token = getToken();
if (!token) return;
const response = await HTTPService.get(
`${ENDPOINT}/passkeys/registration/begin`,
{},
{
"X-Auth-Token": token,
const finishPasskeyRegistration = async (
sessionID: string,
friendlyName: string,
credential: Credential,
) => {
const attestationResponse = authenticatorAttestationResponse(credential);
const attestationObject = await binaryToServerB64(
attestationResponse.attestationObject,
);
const clientDataJSON = await binaryToServerB64(
attestationResponse.clientDataJSON,
);
const params = new URLSearchParams({ friendlyName, sessionID });
const url = `${apiOrigin()}/passkeys/registration/finish`;
const res = await fetch(`${url}?${params.toString()}`, {
method: "POST",
headers: accountsAuthenticatedRequestHeaders(),
body: JSON.stringify({
id: credential.id,
// This is meant to be the ArrayBuffer version of the (base64
// encoded) `id`, but since we then would need to base64 encode it
// anyways for transmission, we can just reuse the same string.
rawId: credential.id,
type: credential.type,
response: {
attestationObject,
clientDataJSON,
},
);
return await response.data;
} catch (e) {
log.error("get passkey registration options failed", e);
throw e;
}
}),
});
if (!res.ok) throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`);
};
/**
* A function to hide the type casts necessary to extract an
* {@link AuthenticatorAttestationResponse} from the {@link Credential} we
* obtain during a new passkey registration.
*/
const authenticatorAttestationResponse = (credential: Credential) => {
// We passed `options: { publicKey }` to `navigator.credentials.create`, and
// so we will get back an `PublicKeyCredential`:
// https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredentialCreationOptions#creating_a_public_key_credential
//
// However, the return type of `create` is the base `Credential`, so we need
// to cast.
const pkCredential = credential as PublicKeyCredential;
// Further, since this was a `create` and not a `get`, the
// PublicKeyCredential.response will be an
// `AuthenticatorAttestationResponse` (See same MDN reference).
//
// We need to cast again.
const attestationResponse =
pkCredential.response as AuthenticatorAttestationResponse;
return attestationResponse;
};
/**
* Return `true` if the given {@link redirectURL} (obtained from the redirect
* query parameter passed around during the passkey verification flow) is one of
@@ -91,80 +325,6 @@ export const isWhitelistedRedirect = (redirectURL: URL) =>
redirectURL.protocol == "ente:" ||
redirectURL.protocol == "enteauth:";
/**
* 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,
) => {
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 = 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,
},
{
"X-Auth-Token": token,
},
);
return await response.data;
};
export interface BeginPasskeyAuthenticationResponse {
ceremonySessionID: string;
options: Options;

View File

@@ -1,4 +1,5 @@
import { CustomHead } from "@/next/components/Head";
import { setAppNameForAuthenticatedRequests } from "@/next/http";
import { setupI18n } from "@/next/i18n";
import {
logStartupBanner,
@@ -78,6 +79,7 @@ export default function App({ Component, pageProps }: AppProps) {
const userId = (getData(LS_KEYS.USER) as User)?.id;
logStartupBanner(appName, userId);
logUnhandledErrorsAndRejections(true);
setAppNameForAuthenticatedRequests(appName);
HTTPService.setHeaders({
"X-Client-Package": clientPackageName[appName],
});

View File

@@ -1,5 +1,6 @@
import { WhatsNew } from "@/new/photos/components/WhatsNew";
import { CustomHead } from "@/next/components/Head";
import { setAppNameForAuthenticatedRequests } from "@/next/http";
import { setupI18n } from "@/next/i18n";
import log from "@/next/log";
import {
@@ -155,6 +156,7 @@ export default function App({ Component, pageProps }: AppProps) {
const userId = (getData(LS_KEYS.USER) as User)?.id;
logStartupBanner(appName, userId);
logUnhandledErrorsAndRejections(true);
setAppNameForAuthenticatedRequests(appName);
HTTPService.setHeaders({
"X-Client-Package": clientPackageName[appName],
});

View File

@@ -167,7 +167,10 @@ const Page: React.FC<PageProps> = ({ appContext }) => {
isTwoFactorPasskeysEnabled: true,
});
InMemoryStore.set(MS_KEYS.REDIRECT_URL, PAGES.ROOT);
redirectUserToPasskeyVerificationFlow(passkeySessionID);
redirectUserToPasskeyVerificationFlow(
appName,
passkeySessionID,
);
throw Error(CustomError.TWO_FACTOR_ENABLED);
} else if (twoFactorSessionID) {
const sessionKeyAttributes =

View File

@@ -85,7 +85,10 @@ const Page: React.FC<PageProps> = ({ appContext }) => {
isTwoFactorPasskeysEnabled: true,
});
setIsFirstLogin(true);
redirectUserToPasskeyVerificationFlow(passkeySessionID);
redirectUserToPasskeyVerificationFlow(
appName,
passkeySessionID,
);
} else if (twoFactorSessionID) {
setData(LS_KEYS.USER, {
email,

View File

@@ -1,4 +1,5 @@
import { clearBlobCaches } from "@/next/blob-cache";
import { clearHTTPState } from "@/next/http";
import log from "@/next/log";
import InMemoryStore from "@ente/shared/storage/InMemoryStore";
import localForage from "@ente/shared/storage/localForage";
@@ -50,4 +51,9 @@ export const accountLogout = async () => {
} catch (e) {
ignoreError("cache", e);
}
try {
clearHTTPState();
} catch (e) {
ignoreError("http", e);
}
};

View File

@@ -19,14 +19,18 @@ import { getToken } from "@ente/shared/storage/localStorage/helpers";
* On successful verification, the accounts app will redirect back to our
* `/passkeys/finish` page.
*
* @param appName The {@link AppName} of the app which is calling this function.
*
* @param passkeySessionID An identifier provided by museum for this passkey
* verification session.
*/
export const redirectUserToPasskeyVerificationFlow = (
appName: AppName,
passkeySessionID: string,
) => {
const client = clientPackageName[appName];
const redirect = `${window.location.origin}/passkeys/finish`;
const params = new URLSearchParams({ passkeySessionID, redirect });
const params = new URLSearchParams({ client, passkeySessionID, redirect });
window.location.href = `${accountsAppURL()}/passkeys/verify?${params.toString()}`;
};

View File

@@ -1,10 +1,9 @@
import { isDevBuild } from "@/next/env";
import { authenticatedRequestHeaders } from "@/next/http";
import { localUser } from "@/next/local-user";
import log from "@/next/log";
import { ensure } from "@/utils/ensure";
import { nullToUndefined } from "@/utils/transform";
import { apiOrigin } from "@ente/shared/network/api";
import { getToken } from "@ente/shared/storage/localStorage/helpers";
import { z } from "zod";
let _fetchTimeout: ReturnType<typeof setTimeout> | undefined;
@@ -68,9 +67,7 @@ const fetchAndSaveFeatureFlags = () =>
const fetchFeatureFlags = async () => {
const url = `${apiOrigin()}/remote-store/feature-flags`;
const res = await fetch(url, {
headers: {
"X-Auth-Token": ensure(getToken()),
},
headers: authenticatedRequestHeaders(),
});
if (!res.ok) throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`);
return res;

50
web/packages/next/http.ts Normal file
View File

@@ -0,0 +1,50 @@
import { ensureAuthToken } from "./local-user";
import { clientPackageName, type AppName } from "./types/app";
/**
* Value for the the "X-Client-Package" header in authenticated requests.
*/
let _clientPackage: string | undefined;
/**
* Remember that we should include the client package corresponding to the given
* {@link appName} as the "X-Client-Package" header in authenticated requests.
*
* This state is persisted in memory, and can be cleared using
* {@link clearHTTPState}.
*
* @param appName The {@link AppName} of the current app.
*/
export const setAppNameForAuthenticatedRequests = (appName: AppName) => {
_clientPackage = clientPackageName[appName];
};
/**
* Variant of {@link setAppNameForAuthenticatedRequests} that sets directly sets
* the client package to the provided string.
*/
export const setClientPackageForAuthenticatedRequests = (p: string) => {
_clientPackage = p;
};
/**
* Forget the effects of a previous {@link setAppNameForAuthenticatedRequests}
* or {@link setClientPackageForAuthenticatedRequests}.
*/
export const clearHTTPState = () => {
_clientPackage = undefined;
};
/**
* Return headers that should be passed alongwith (almost) all authenticated
* `fetch` calls that we make to our API servers.
*
* This uses in-memory state (See {@link clearHTTPState}).
*/
export const authenticatedRequestHeaders = (): Record<string, string> => {
const headers: Record<string, string> = {
"X-Auth-Token": ensureAuthToken(),
};
if (_clientPackage) headers["X-Client-Package"] = _clientPackage;
return headers;
};

View File

@@ -38,3 +38,14 @@ export const ensureLocalUser = (): LocalUser => {
if (!user) throw new Error("Not logged in");
return user;
};
/**
* Return the user's auth token, or throw an error.
*
* The user's auth token is stored in local storage after they have successfully
* logged in. This function returns that saved auth token.
*
* If no such token is found (which should only happen if the user is not logged
* in), then it throws an error.
*/
export const ensureAuthToken = (): string => ensureLocalUser().token;

View File

@@ -26,7 +26,6 @@ export enum LS_KEYS {
SRP_ATTRIBUTES = "srpAttributes",
CF_PROXY_DISABLED = "cfProxyDisabled",
REFERRAL_SOURCE = "referralSource",
CLIENT_PACKAGE = "clientPackage",
}
export const setData = (key: LS_KEYS, value: object) =>