@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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()}`;
|
||||
};
|
||||
|
||||
|
||||
@@ -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
50
web/packages/next/http.ts
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user