From 6d4e434a8dbf6904f1660a3cd69f70702d9e5045 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 7 Jun 2024 11:29:01 +0530 Subject: [PATCH 01/19] Rearrange --- web/apps/accounts/src/services/passkey.ts | 60 +++++++++++------------ 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/web/apps/accounts/src/services/passkey.ts b/web/apps/accounts/src/services/passkey.ts index d6f5c4bf67..0c6ebb396b 100644 --- a/web/apps/accounts/src/services/passkey.ts +++ b/web/apps/accounts/src/services/passkey.ts @@ -61,36 +61,6 @@ export const deletePasskey = async (id: string) => { } }; -export const getPasskeyRegistrationOptions = async () => { - try { - const token = getToken(); - if (!token) return; - const response = await HTTPService.get( - `${ENDPOINT}/passkeys/registration/begin`, - {}, - { - "X-Auth-Token": token, - }, - ); - return await response.data; - } catch (e) { - log.error("get passkey registration options failed", e); - throw e; - } -}; - -/** - * Return `true` if the given {@link redirectURL} (obtained from the redirect - * query parameter passed around during the passkey verification flow) is one of - * the whitelisted URLs that we allow redirecting to on success. - */ -export const isWhitelistedRedirect = (redirectURL: URL) => - (isDevBuild && redirectURL.hostname.endsWith("localhost")) || - redirectURL.host.endsWith(".ente.io") || - redirectURL.host.endsWith(".ente.sh") || - redirectURL.protocol == "ente:" || - redirectURL.protocol == "enteauth:"; - /** * Add a new passkey as the second factor to the user's account. * @@ -125,6 +95,24 @@ export const registerPasskey = async (name: string) => { await finishPasskeyRegistration(name, credential, response.sessionID); }; +export const getPasskeyRegistrationOptions = async () => { + try { + const token = getToken(); + if (!token) return; + const response = await HTTPService.get( + `${ENDPOINT}/passkeys/registration/begin`, + {}, + { + "X-Auth-Token": token, + }, + ); + return await response.data; + } catch (e) { + log.error("get passkey registration options failed", e); + throw e; + } +}; + const finishPasskeyRegistration = async ( friendlyName: string, credential: Credential, @@ -165,6 +153,18 @@ const finishPasskeyRegistration = async ( return await response.data; }; +/** + * Return `true` if the given {@link redirectURL} (obtained from the redirect + * query parameter passed around during the passkey verification flow) is one of + * the whitelisted URLs that we allow redirecting to on success. + */ +export const isWhitelistedRedirect = (redirectURL: URL) => + (isDevBuild && redirectURL.hostname.endsWith("localhost")) || + redirectURL.host.endsWith(".ente.io") || + redirectURL.host.endsWith(".ente.sh") || + redirectURL.protocol == "ente:" || + redirectURL.protocol == "enteauth:"; + export interface BeginPasskeyAuthenticationResponse { ceremonySessionID: string; options: Options; From dd6f36e03740097238ce3baa9eba07eec1fbb284 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 7 Jun 2024 11:33:49 +0530 Subject: [PATCH 02/19] Unnest --- web/apps/accounts/src/pages/passkeys/handoff.tsx | 8 ++++---- web/apps/accounts/src/pages/passkeys/verify.tsx | 3 +-- web/packages/shared/storage/localStorage/index.ts | 1 - 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/web/apps/accounts/src/pages/passkeys/handoff.tsx b/web/apps/accounts/src/pages/passkeys/handoff.tsx index 1e4fb72118..fa30ac7545 100644 --- a/web/apps/accounts/src/pages/passkeys/handoff.tsx +++ b/web/apps/accounts/src/pages/passkeys/handoff.tsx @@ -16,12 +16,12 @@ 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); HTTPService.setHeaders({ - "X-Client-Package": client, + "X-Client-Package": clientPackage, }); } diff --git a/web/apps/accounts/src/pages/passkeys/verify.tsx b/web/apps/accounts/src/pages/passkeys/verify.tsx index 5c1b1be141..853e5de6de 100644 --- a/web/apps/accounts/src/pages/passkeys/verify.tsx +++ b/web/apps/accounts/src/pages/passkeys/verify.tsx @@ -10,7 +10,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"; @@ -53,7 +52,7 @@ const PasskeysFlow = () => { pkg = clientPackageName["accounts"]; } - setData(LS_KEYS.CLIENT_PACKAGE, { name: pkg }); + localStorage.setItem("clientPackage", pkg); // The server needs to know the app on whose behalf we're trying to log in HTTPService.setHeaders({ "X-Client-Package": pkg, diff --git a/web/packages/shared/storage/localStorage/index.ts b/web/packages/shared/storage/localStorage/index.ts index 2c204ae3c6..beefbf37fe 100644 --- a/web/packages/shared/storage/localStorage/index.ts +++ b/web/packages/shared/storage/localStorage/index.ts @@ -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) => From 242c669de4f2b9c7203799f77cb9cd2b6b7994cd Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 7 Jun 2024 12:00:29 +0530 Subject: [PATCH 03/19] XCP --- web/apps/accounts/src/pages/_app.tsx | 6 +-- web/apps/accounts/src/services/passkey.ts | 3 ++ web/apps/auth/src/pages/_app.tsx | 2 + web/apps/photos/src/pages/_app.tsx | 2 + web/packages/accounts/services/logout.ts | 6 +++ .../new/photos/services/feature-flags.ts | 7 +-- web/packages/next/http.ts | 44 +++++++++++++++++++ web/packages/next/local-user.ts | 11 +++++ 8 files changed, 73 insertions(+), 8 deletions(-) create mode 100644 web/packages/next/http.ts diff --git a/web/apps/accounts/src/pages/_app.tsx b/web/apps/accounts/src/pages/_app.tsx index 25008063dc..c05d41c894 100644 --- a/web/apps/accounts/src/pages/_app.tsx +++ b/web/apps/accounts/src/pages/_app.tsx @@ -12,7 +12,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 +64,10 @@ export default function App({ Component, pageProps }: AppProps) { }, []); const setupPackageName = () => { - const pkg = getData(LS_KEYS.CLIENT_PACKAGE); + const pkg = localStorage.getItem("clientPackage"); if (!pkg) return; HTTPService.setHeaders({ - "X-Client-Package": pkg.name, + "X-Client-Package": pkg, }); }; diff --git a/web/apps/accounts/src/services/passkey.ts b/web/apps/accounts/src/services/passkey.ts index 0c6ebb396b..ba0e1f402c 100644 --- a/web/apps/accounts/src/services/passkey.ts +++ b/web/apps/accounts/src/services/passkey.ts @@ -96,6 +96,9 @@ export const registerPasskey = async (name: string) => { }; export const getPasskeyRegistrationOptions = async () => { + const clientPackage = localStorage.getItem("clientPackage") + const token = ensure(getToken()); + try { const token = getToken(); if (!token) return; diff --git a/web/apps/auth/src/pages/_app.tsx b/web/apps/auth/src/pages/_app.tsx index 50397b63ed..319d4227a2 100644 --- a/web/apps/auth/src/pages/_app.tsx +++ b/web/apps/auth/src/pages/_app.tsx @@ -33,6 +33,7 @@ import { useRouter } from "next/router"; import { createContext, useContext, useEffect, useRef, useState } from "react"; import LoadingBar, { type LoadingBarRef } from "react-top-loading-bar"; import "../../public/css/global.css"; +import { setAppNameForAuthenticatedRequests } from "@/next/http"; /** * Properties available via the {@link AppContext} to the Auth app's React tree. @@ -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], }); diff --git a/web/apps/photos/src/pages/_app.tsx b/web/apps/photos/src/pages/_app.tsx index 406136948c..9c3e199cc0 100644 --- a/web/apps/photos/src/pages/_app.tsx +++ b/web/apps/photos/src/pages/_app.tsx @@ -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], }); diff --git a/web/packages/accounts/services/logout.ts b/web/packages/accounts/services/logout.ts index 7a150384db..59371e1e7c 100644 --- a/web/packages/accounts/services/logout.ts +++ b/web/packages/accounts/services/logout.ts @@ -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); + } }; diff --git a/web/packages/new/photos/services/feature-flags.ts b/web/packages/new/photos/services/feature-flags.ts index 419c6baf26..ab7787b75e 100644 --- a/web/packages/new/photos/services/feature-flags.ts +++ b/web/packages/new/photos/services/feature-flags.ts @@ -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 | 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; diff --git a/web/packages/next/http.ts b/web/packages/next/http.ts new file mode 100644 index 0000000000..26f98ac2df --- /dev/null +++ b/web/packages/next/http.ts @@ -0,0 +1,44 @@ +import { ensureAuthToken } from "./local-user"; +import { clientPackageName, type AppName } from "./types/app"; + +/** + * The client package name to include as the "X-Client-Package" header in + * authenticated requests. + */ +let _clientPackageName: string | undefined; + +/** + * Set the client package name (corresponding to the given {@link appName}) that + * should be included 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) => { + _clientPackageName = clientPackageName[appName]; +}; + +/** + * Forget the effects of a previous {@link setAppNameForAuthenticatedRequests}. + */ +export const clearHTTPState = () => { + _clientPackageName = 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 initialized using + * {@link setAppNameForAuthenticatedRequests}. + */ +export const authenticatedRequestHeaders = (): Record => { + const headers: Record = { + "X-Auth-Token": ensureAuthToken(), + }; + if (_clientPackageName) headers["X-Client-Package"] = _clientPackageName; + return headers; +}; diff --git a/web/packages/next/local-user.ts b/web/packages/next/local-user.ts index 2a351a421b..d657264287 100644 --- a/web/packages/next/local-user.ts +++ b/web/packages/next/local-user.ts @@ -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; From 1e8ec0e814460e9f3c7f4918743f5be562ba05a5 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 7 Jun 2024 12:11:02 +0530 Subject: [PATCH 04/19] Set for accounts headers --- web/apps/accounts/src/pages/_app.tsx | 8 +++++--- web/apps/accounts/src/pages/passkeys/handoff.tsx | 3 ++- web/apps/accounts/src/services/passkey.ts | 3 --- web/packages/next/http.ts | 8 ++++++++ 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/web/apps/accounts/src/pages/_app.tsx b/web/apps/accounts/src/pages/_app.tsx index c05d41c894..b670f52772 100644 --- a/web/apps/accounts/src/pages/_app.tsx +++ b/web/apps/accounts/src/pages/_app.tsx @@ -1,4 +1,5 @@ import { CustomHead } from "@/next/components/Head"; +import { setClientPackageNameForAuthenticatedRequests } from "@/next/http"; import { setupI18n } from "@/next/i18n"; import { logUnhandledErrorsAndRejections } from "@/next/log-web"; import { appTitle, type AppName, type BaseAppContextT } from "@/next/types/app"; @@ -64,10 +65,11 @@ export default function App({ Component, pageProps }: AppProps) { }, []); const setupPackageName = () => { - const pkg = localStorage.getItem("clientPackage"); - if (!pkg) return; + const clientPackage = localStorage.getItem("clientPackage"); + if (!clientPackage) return; + setClientPackageNameForAuthenticatedRequests(clientPackage); HTTPService.setHeaders({ - "X-Client-Package": pkg, + "X-Client-Package": clientPackage, }); }; diff --git a/web/apps/accounts/src/pages/passkeys/handoff.tsx b/web/apps/accounts/src/pages/passkeys/handoff.tsx index fa30ac7545..9679f62103 100644 --- a/web/apps/accounts/src/pages/passkeys/handoff.tsx +++ b/web/apps/accounts/src/pages/passkeys/handoff.tsx @@ -1,3 +1,4 @@ +import { setClientPackageNameForAuthenticatedRequests } from "@/next/http"; import log from "@/next/log"; import { VerticallyCentered } from "@ente/shared/components/Container"; import EnteSpinner from "@ente/shared/components/EnteSpinner"; @@ -20,6 +21,7 @@ const Page: React.FC = () => { if (clientPackage) { // TODO-PK: mobile is not passing it. is that expected? localStorage.setItem("clientPackage", clientPackage); + setClientPackageNameForAuthenticatedRequests(clientPackage); HTTPService.setHeaders({ "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"); diff --git a/web/apps/accounts/src/services/passkey.ts b/web/apps/accounts/src/services/passkey.ts index ba0e1f402c..0c6ebb396b 100644 --- a/web/apps/accounts/src/services/passkey.ts +++ b/web/apps/accounts/src/services/passkey.ts @@ -96,9 +96,6 @@ export const registerPasskey = async (name: string) => { }; export const getPasskeyRegistrationOptions = async () => { - const clientPackage = localStorage.getItem("clientPackage") - const token = ensure(getToken()); - try { const token = getToken(); if (!token) return; diff --git a/web/packages/next/http.ts b/web/packages/next/http.ts index 26f98ac2df..ceba524894 100644 --- a/web/packages/next/http.ts +++ b/web/packages/next/http.ts @@ -21,6 +21,14 @@ export const setAppNameForAuthenticatedRequests = (appName: AppName) => { _clientPackageName = clientPackageName[appName]; }; +/** + * Variant of {@link setAppNameForAuthenticatedRequests} that sets directly sets + * the client package name to the provided string. + */ +export const setClientPackageNameForAuthenticatedRequests = (p: string) => { + _clientPackageName = p; +}; + /** * Forget the effects of a previous {@link setAppNameForAuthenticatedRequests}. */ From 0da13379215a074b81e18a02b9411d6eec1bc6f6 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 7 Jun 2024 12:16:10 +0530 Subject: [PATCH 05/19] Naming --- web/apps/accounts/src/pages/_app.tsx | 4 +-- .../accounts/src/pages/passkeys/handoff.tsx | 4 +-- .../accounts/src/pages/passkeys/verify.tsx | 15 ++++++---- web/packages/next/http.ts | 28 +++++++++---------- 4 files changed, 26 insertions(+), 25 deletions(-) diff --git a/web/apps/accounts/src/pages/_app.tsx b/web/apps/accounts/src/pages/_app.tsx index b670f52772..6f0d4a9b90 100644 --- a/web/apps/accounts/src/pages/_app.tsx +++ b/web/apps/accounts/src/pages/_app.tsx @@ -1,5 +1,5 @@ import { CustomHead } from "@/next/components/Head"; -import { setClientPackageNameForAuthenticatedRequests } from "@/next/http"; +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"; @@ -67,7 +67,7 @@ export default function App({ Component, pageProps }: AppProps) { const setupPackageName = () => { const clientPackage = localStorage.getItem("clientPackage"); if (!clientPackage) return; - setClientPackageNameForAuthenticatedRequests(clientPackage); + setClientPackageForAuthenticatedRequests(clientPackage); HTTPService.setHeaders({ "X-Client-Package": clientPackage, }); diff --git a/web/apps/accounts/src/pages/passkeys/handoff.tsx b/web/apps/accounts/src/pages/passkeys/handoff.tsx index 9679f62103..0f14455291 100644 --- a/web/apps/accounts/src/pages/passkeys/handoff.tsx +++ b/web/apps/accounts/src/pages/passkeys/handoff.tsx @@ -1,4 +1,4 @@ -import { setClientPackageNameForAuthenticatedRequests } from "@/next/http"; +import { setClientPackageForAuthenticatedRequests } from "@/next/http"; import log from "@/next/log"; import { VerticallyCentered } from "@ente/shared/components/Container"; import EnteSpinner from "@ente/shared/components/EnteSpinner"; @@ -21,7 +21,7 @@ const Page: React.FC = () => { if (clientPackage) { // TODO-PK: mobile is not passing it. is that expected? localStorage.setItem("clientPackage", clientPackage); - setClientPackageNameForAuthenticatedRequests(clientPackage); + setClientPackageForAuthenticatedRequests(clientPackage); HTTPService.setHeaders({ "X-Client-Package": clientPackage, }); diff --git a/web/apps/accounts/src/pages/passkeys/verify.tsx b/web/apps/accounts/src/pages/passkeys/verify.tsx index 853e5de6de..bd83f326e5 100644 --- a/web/apps/accounts/src/pages/passkeys/verify.tsx +++ b/web/apps/accounts/src/pages/passkeys/verify.tsx @@ -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"; @@ -45,17 +46,19 @@ const PasskeysFlow = () => { return; } - let pkg = clientPackageName["photos"]; + let clientPackage = clientPackageName["photos"]; if (redirectURL.protocol === "enteauth:") { - pkg = clientPackageName["auth"]; + clientPackage = clientPackageName["auth"]; } else if (redirectURL.hostname.startsWith("accounts")) { - pkg = clientPackageName["accounts"]; + clientPackage = clientPackageName["accounts"]; } - localStorage.setItem("clientPackage", 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 diff --git a/web/packages/next/http.ts b/web/packages/next/http.ts index ceba524894..69327fd53d 100644 --- a/web/packages/next/http.ts +++ b/web/packages/next/http.ts @@ -2,15 +2,13 @@ import { ensureAuthToken } from "./local-user"; import { clientPackageName, type AppName } from "./types/app"; /** - * The client package name to include as the "X-Client-Package" header in - * authenticated requests. + * Value for the the "X-Client-Package" header in authenticated requests. */ -let _clientPackageName: string | undefined; +let _clientPackage: string | undefined; /** - * Set the client package name (corresponding to the given {@link appName}) that - * should be included as the "X-Client-Package" header in authenticated - * requests. + * 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}. @@ -18,35 +16,35 @@ let _clientPackageName: string | undefined; * @param appName The {@link AppName} of the current app. */ export const setAppNameForAuthenticatedRequests = (appName: AppName) => { - _clientPackageName = clientPackageName[appName]; + _clientPackage = clientPackageName[appName]; }; /** * Variant of {@link setAppNameForAuthenticatedRequests} that sets directly sets - * the client package name to the provided string. + * the client package to the provided string. */ -export const setClientPackageNameForAuthenticatedRequests = (p: string) => { - _clientPackageName = p; +export const setClientPackageForAuthenticatedRequests = (p: string) => { + _clientPackage = p; }; /** - * Forget the effects of a previous {@link setAppNameForAuthenticatedRequests}. + * Forget the effects of a previous {@link setAppNameForAuthenticatedRequests} + * or {@link setClientPackageForAuthenticatedRequests}. */ export const clearHTTPState = () => { - _clientPackageName = undefined; + _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 initialized using - * {@link setAppNameForAuthenticatedRequests}. + * This uses in-memory state (See {@link clearHTTPState}). */ export const authenticatedRequestHeaders = (): Record => { const headers: Record = { "X-Auth-Token": ensureAuthToken(), }; - if (_clientPackageName) headers["X-Client-Package"] = _clientPackageName; + if (_clientPackage) headers["X-Client-Package"] = _clientPackage; return headers; }; From 9d670db89c6b7deffccf0c8490eaffe1c886cc48 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 7 Jun 2024 12:23:01 +0530 Subject: [PATCH 06/19] Pass the package explicitly --- web/apps/accounts/src/pages/passkeys/verify.tsx | 15 ++++++++++----- web/packages/accounts/pages/credentials.tsx | 5 ++++- web/packages/accounts/pages/verify.tsx | 5 ++++- web/packages/accounts/services/passkey.ts | 6 +++++- 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/web/apps/accounts/src/pages/passkeys/verify.tsx b/web/apps/accounts/src/pages/passkeys/verify.tsx index bd83f326e5..7c738de19b 100644 --- a/web/apps/accounts/src/pages/passkeys/verify.tsx +++ b/web/apps/accounts/src/pages/passkeys/verify.tsx @@ -46,11 +46,16 @@ const PasskeysFlow = () => { return; } - let clientPackage = clientPackageName["photos"]; - if (redirectURL.protocol === "enteauth:") { - clientPackage = clientPackageName["auth"]; - } else if (redirectURL.hostname.startsWith("accounts")) { - clientPackage = 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"]; + } } localStorage.setItem("clientPackage", clientPackage); diff --git a/web/packages/accounts/pages/credentials.tsx b/web/packages/accounts/pages/credentials.tsx index b7bae310c0..7f8e721c10 100644 --- a/web/packages/accounts/pages/credentials.tsx +++ b/web/packages/accounts/pages/credentials.tsx @@ -167,7 +167,10 @@ const Page: React.FC = ({ 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 = diff --git a/web/packages/accounts/pages/verify.tsx b/web/packages/accounts/pages/verify.tsx index 1672104e9e..c2f653fc55 100644 --- a/web/packages/accounts/pages/verify.tsx +++ b/web/packages/accounts/pages/verify.tsx @@ -85,7 +85,10 @@ const Page: React.FC = ({ appContext }) => { isTwoFactorPasskeysEnabled: true, }); setIsFirstLogin(true); - redirectUserToPasskeyVerificationFlow(passkeySessionID); + redirectUserToPasskeyVerificationFlow( + appName, + passkeySessionID, + ); } else if (twoFactorSessionID) { setData(LS_KEYS.USER, { email, diff --git a/web/packages/accounts/services/passkey.ts b/web/packages/accounts/services/passkey.ts index c091f14db7..d07def747f 100644 --- a/web/packages/accounts/services/passkey.ts +++ b/web/packages/accounts/services/passkey.ts @@ -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()}`; }; From e68a968255b4b6bac74ab7cd02c3bfed257e3eea Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 7 Jun 2024 12:38:48 +0530 Subject: [PATCH 07/19] Use fetch --- web/apps/accounts/src/services/passkey.ts | 24 ++++++++--------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/web/apps/accounts/src/services/passkey.ts b/web/apps/accounts/src/services/passkey.ts index 0c6ebb396b..63974accf0 100644 --- a/web/apps/accounts/src/services/passkey.ts +++ b/web/apps/accounts/src/services/passkey.ts @@ -1,9 +1,10 @@ import { isDevBuild } from "@/next/env"; +import { authenticatedRequestHeaders } from "@/next/http"; import log from "@/next/log"; import { ensure } from "@/utils/ensure"; import { 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"; @@ -96,21 +97,12 @@ export const registerPasskey = async (name: string) => { }; export const getPasskeyRegistrationOptions = async () => { - try { - const token = getToken(); - if (!token) return; - const response = await HTTPService.get( - `${ENDPOINT}/passkeys/registration/begin`, - {}, - { - "X-Auth-Token": token, - }, - ); - return await response.data; - } catch (e) { - log.error("get passkey registration options failed", e); - throw e; - } + const url = `${apiOrigin()}/passkeys/registration/begin`; + const res = await fetch(url, { + headers: authenticatedRequestHeaders(), + }); + if (!res.ok) throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`); + return await res.json(); }; const finishPasskeyRegistration = async ( From b07841f97273c5bd69c53c3a48ad0c1af61dcff5 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 7 Jun 2024 12:49:56 +0530 Subject: [PATCH 08/19] Create a separate variant for accounts --- web/apps/accounts/src/services/passkey.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/web/apps/accounts/src/services/passkey.ts b/web/apps/accounts/src/services/passkey.ts index 63974accf0..010e1e03fd 100644 --- a/web/apps/accounts/src/services/passkey.ts +++ b/web/apps/accounts/src/services/passkey.ts @@ -2,6 +2,7 @@ import { isDevBuild } from "@/next/env"; import { authenticatedRequestHeaders } from "@/next/http"; import log from "@/next/log"; import { ensure } from "@/utils/ensure"; +import { nullToUndefined } from "@/utils/transform"; import { toB64URLSafeNoPadding } from "@ente/shared/crypto/internal/libsodium"; import HTTPService from "@ente/shared/network/HTTPService"; import { apiOrigin, getEndpoint } from "@ente/shared/network/api"; @@ -10,6 +11,23 @@ import _sodium from "libsodium-wrappers"; const ENDPOINT = getEndpoint(); +/** + * 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 => { + const token = getToken(); + if (!token) throw new Error("Missing accounts token"); + const headers: Record = { "X-Auth-Token": token }; + const clientPackage = nullToUndefined(localStorage.get("clientPackage")); + if (clientPackage) headers["X-Client-Package"] = clientPackage; + return headers; +}; + export interface Passkey { id: string; userID: number; @@ -99,7 +117,7 @@ export const registerPasskey = async (name: string) => { export const getPasskeyRegistrationOptions = async () => { const url = `${apiOrigin()}/passkeys/registration/begin`; const res = await fetch(url, { - headers: authenticatedRequestHeaders(), + headers: accountsAuthenticatedRequestHeaders(), }); if (!res.ok) throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`); return await res.json(); From a8834f5d7ed71eefc762fe8ef1aac0ac4b58b924 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 7 Jun 2024 13:15:29 +0530 Subject: [PATCH 09/19] Fix --- web/apps/accounts/src/services/passkey.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/web/apps/accounts/src/services/passkey.ts b/web/apps/accounts/src/services/passkey.ts index 010e1e03fd..75dcd3d2f6 100644 --- a/web/apps/accounts/src/services/passkey.ts +++ b/web/apps/accounts/src/services/passkey.ts @@ -23,7 +23,9 @@ const accountsAuthenticatedRequestHeaders = (): Record => { const token = getToken(); if (!token) throw new Error("Missing accounts token"); const headers: Record = { "X-Auth-Token": token }; - const clientPackage = nullToUndefined(localStorage.get("clientPackage")); + const clientPackage = nullToUndefined( + localStorage.getItem("clientPackage"), + ); if (clientPackage) headers["X-Client-Package"] = clientPackage; return headers; }; @@ -123,6 +125,13 @@ export const getPasskeyRegistrationOptions = async () => { return await res.json(); }; +interface PasskeyRegistrationOptions { + sessionID: string; + options: { + publicKey: PublicKeyCredentialCreationOptions; + }; +} + const finishPasskeyRegistration = async ( friendlyName: string, credential: Credential, From b16bce7f044b416c33375e65ffec5c9c19a9baf4 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 7 Jun 2024 13:43:13 +0530 Subject: [PATCH 10/19] Document what we're intending to do --- web/apps/accounts/src/services/passkey.ts | 68 ++++++++++++++++++++--- 1 file changed, 59 insertions(+), 9 deletions(-) diff --git a/web/apps/accounts/src/services/passkey.ts b/web/apps/accounts/src/services/passkey.ts index 75dcd3d2f6..eda3a0770d 100644 --- a/web/apps/accounts/src/services/passkey.ts +++ b/web/apps/accounts/src/services/passkey.ts @@ -1,5 +1,4 @@ import { isDevBuild } from "@/next/env"; -import { authenticatedRequestHeaders } from "@/next/http"; import log from "@/next/log"; import { ensure } from "@/utils/ensure"; import { nullToUndefined } from "@/utils/transform"; @@ -116,22 +115,73 @@ export const registerPasskey = async (name: string) => { await finishPasskeyRegistration(name, credential, response.sessionID); }; -export const getPasskeyRegistrationOptions = async () => { +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; + }; +} + +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. + return await res.json(); }; -interface PasskeyRegistrationOptions { - sessionID: string; - options: { - publicKey: PublicKeyCredentialCreationOptions; - }; -} - const finishPasskeyRegistration = async ( friendlyName: string, credential: Credential, From a0ca3f2c5ab19af0b8c31de977ce600413aaf36b Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 7 Jun 2024 14:32:13 +0530 Subject: [PATCH 11/19] Begin --- web/apps/accounts/src/services/passkey.ts | 81 ++++++++++++++--------- 1 file changed, 51 insertions(+), 30 deletions(-) diff --git a/web/apps/accounts/src/services/passkey.ts b/web/apps/accounts/src/services/passkey.ts index eda3a0770d..f3b4f7aae8 100644 --- a/web/apps/accounts/src/services/passkey.ts +++ b/web/apps/accounts/src/services/passkey.ts @@ -2,11 +2,13 @@ import { isDevBuild } from "@/next/env"; import log from "@/next/log"; import { ensure } from "@/utils/ensure"; import { nullToUndefined } from "@/utils/transform"; -import { toB64URLSafeNoPadding } from "@ente/shared/crypto/internal/libsodium"; +import { + fromB64URLSafeNoPadding, + toB64URLSafeNoPadding, +} from "@ente/shared/crypto/internal/libsodium"; import HTTPService from "@ente/shared/network/HTTPService"; import { apiOrigin, getEndpoint } from "@ente/shared/network/api"; import { getToken } from "@ente/shared/storage/localStorage/helpers"; -import _sodium from "libsodium-wrappers"; const ENDPOINT = getEndpoint(); @@ -88,31 +90,15 @@ export const deletePasskey = async (id: string) => { * (aka "friendly name"). */ export const registerPasskey = async (name: string) => { - const response: { - options: { - publicKey: PublicKeyCredentialCreationOptions; - }; - sessionID: string; - } = await getPasskeyRegistrationOptions(); + // Get options (and sessionID) from the backend. + const { sessionID, options } = await beginPasskeyRegistration(); - 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 + // Ask the browser to new (public key) credentials using these options. const credential = ensure(await navigator.credentials.create(options)); - await finishPasskeyRegistration(name, credential, response.sessionID); + // Finish by letting the backend know about these credentials so that it can + // save the public key for future authentication. + await finishPasskeyRegistration(name, credential, sessionID); }; interface BeginPasskeyRegistrationResponse { @@ -172,14 +158,49 @@ const beginPasskeyRegistration = async () => { // // 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`. + // 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. + // 2. Convert the two binary data fields that are expected to be in the + // response from URLEncodedBase64 strings to Uint8Arrays. + // + // 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. - return await res.json(); + const { sessionID, options } = + (await res.json()) as BeginPasskeyRegistrationResponse; + + options.publicKey.challenge = await serverB64ToBinary( + options.publicKey.challenge, + ); + + options.publicKey.user.id = await serverB64ToBinary( + options.publicKey.user.id, + ); + + return { sessionID, options }; +}; + +/** + * 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; }; const finishPasskeyRegistration = async ( From a4799a2909761624277d1d787b4816bc9a8086f3 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 7 Jun 2024 14:55:54 +0530 Subject: [PATCH 12/19] Ignore cancels --- web/apps/accounts/src/pages/passkeys/index.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/web/apps/accounts/src/pages/passkeys/index.tsx b/web/apps/accounts/src/pages/passkeys/index.tsx index 77c2438b00..5fe551ebfa 100644 --- a/web/apps/accounts/src/pages/passkeys/index.tsx +++ b/web/apps/accounts/src/pages/passkeys/index.tsx @@ -89,8 +89,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(); From 06ee928aac32474ceffcb8a837ab0669dea6515e Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 7 Jun 2024 15:06:01 +0530 Subject: [PATCH 13/19] Acknowledge that we're not taking care of excludedCredentials currently --- web/apps/accounts/src/services/passkey.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/web/apps/accounts/src/services/passkey.ts b/web/apps/accounts/src/services/passkey.ts index f3b4f7aae8..f1a04afc62 100644 --- a/web/apps/accounts/src/services/passkey.ts +++ b/web/apps/accounts/src/services/passkey.ts @@ -163,10 +163,23 @@ const beginPasskeyRegistration = async () => { // one that has `PublicKeyCredentialCreationOptions`. // // 2. Convert the two binary data fields that are expected to be in the - // response from URLEncodedBase64 strings to Uint8Arrays. + // response from URLEncodedBase64 strings to Uint8Arrays. There is a + // third possibility, excludedCredentials[].id, but that we don't + // currently use. // - // To further complicate things, the libdom.ts typings included with the - // current TypeScript version (5.4) indicate these binary types as a + // 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 // From 9d2441d255e072add22f2fcfd25688847b406241 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 7 Jun 2024 15:42:53 +0530 Subject: [PATCH 14/19] The other direction --- web/apps/accounts/src/services/passkey.ts | 94 ++++++++++++++++------- 1 file changed, 68 insertions(+), 26 deletions(-) diff --git a/web/apps/accounts/src/services/passkey.ts b/web/apps/accounts/src/services/passkey.ts index f1a04afc62..3e0157ffb2 100644 --- a/web/apps/accounts/src/services/passkey.ts +++ b/web/apps/accounts/src/services/passkey.ts @@ -98,7 +98,7 @@ export const registerPasskey = async (name: string) => { // Finish by letting the backend know about these credentials so that it can // save the public key for future authentication. - await finishPasskeyRegistration(name, credential, sessionID); + await finishPasskeyRegistration(name, sessionID, credential); }; interface BeginPasskeyRegistrationResponse { @@ -186,6 +186,13 @@ const beginPasskeyRegistration = async () => { // 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; @@ -216,46 +223,81 @@ const serverB64ToBinary = async (b: BufferSource) => { return bytes as unknown as BufferSource; }; +/** + * 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; +}; + const finishPasskeyRegistration = async ( + sessionID: string, 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 attestationResponse = authenticatorAttestationResponse(credential); + + const attestationObject = await binaryToServerB64( + attestationResponse.attestationObject, ); - const clientDataJSONB64 = await toB64URLSafeNoPadding( - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - new Uint8Array(credential.response.clientDataJSON), + const clientDataJSON = await binaryToServerB64( + attestationResponse.clientDataJSON, ); - const token = ensure(getToken()); - - const response = await HTTPService.post( - `${ENDPOINT}/passkeys/registration/finish`, - JSON.stringify({ + const params = new URLSearchParams({ friendlyName, sessionID }); + const baseURL = `${apiOrigin()}/passkeys/registration/finish`; + const res = await fetch(`${baseURL}?${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: attestationObjectB64, - clientDataJSON: clientDataJSONB64, + attestationObject, + clientDataJSON, }, }), - { - friendlyName, - sessionID, - }, - { - "X-Auth-Token": token, - }, - ); - return await response.data; + }); + if (!res.ok) + throw new Error(`Failed to fetch ${baseURL}: 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 From 56b1365747a7b52fd9ebb670c1f8746ae6daa28f Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 7 Jun 2024 15:52:46 +0530 Subject: [PATCH 15/19] Rename --- .../accounts/src/pages/passkeys/index.tsx | 33 +++++++++-------- web/apps/accounts/src/services/passkey.ts | 35 +++++++++---------- 2 files changed, 33 insertions(+), 35 deletions(-) diff --git a/web/apps/accounts/src/pages/passkeys/index.tsx b/web/apps/accounts/src/pages/passkeys/index.tsx index 5fe551ebfa..aae41dd8bf 100644 --- a/web/apps/accounts/src/pages/passkeys/index.tsx +++ b/web/apps/accounts/src/pages/passkeys/index.tsx @@ -223,8 +223,8 @@ const ManagePasskeyDrawer: React.FC = ({ passkey, onUpdateOrDeletePasskey, }) => { - const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showRenameDialog, setShowRenameDialog] = useState(false); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); return ( <> @@ -268,18 +268,6 @@ const ManagePasskeyDrawer: React.FC = ({ )} - {passkey && ( - setShowDeleteDialog(false)} - passkey={passkey} - onDeletePasskey={() => { - setShowDeleteDialog(false); - onUpdateOrDeletePasskey(); - }} - /> - )} - {passkey && ( = ({ }} /> )} + + {passkey && ( + setShowDeleteDialog(false)} + passkey={passkey} + onDeletePasskey={() => { + setShowDeleteDialog(false); + onUpdateOrDeletePasskey(); + }} + /> + )} ); }; @@ -371,13 +371,12 @@ const RenamePasskeyDialog: React.FC = ({ }) => { const fullScreen = useMediaQuery("(max-width: 428px)"); - const onSubmit = async (inputValue: string) => { + const handleSubmit = async (inputValue: string) => { try { await renamePasskey(passkey.id, inputValue); onRenamePasskey(); } catch (e) { log.error("Failed to rename passkey", e); - return; } }; @@ -388,8 +387,8 @@ const RenamePasskeyDialog: React.FC = ({ attributes={{ title: t("RENAME_PASSKEY") }} > { return await response.data; }; +/** + * 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. aka "friendly name". + */ 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; - } + 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}`); }; export const deletePasskey = async (id: string) => { @@ -253,8 +253,8 @@ const finishPasskeyRegistration = async ( ); const params = new URLSearchParams({ friendlyName, sessionID }); - const baseURL = `${apiOrigin()}/passkeys/registration/finish`; - const res = await fetch(`${baseURL}?${params.toString()}`, { + const url = `${apiOrigin()}/passkeys/registration/finish`; + const res = await fetch(`${url}?${params.toString()}`, { method: "POST", headers: accountsAuthenticatedRequestHeaders(), body: JSON.stringify({ @@ -270,8 +270,7 @@ const finishPasskeyRegistration = async ( }, }), }); - if (!res.ok) - throw new Error(`Failed to fetch ${baseURL}: HTTP ${res.status}`); + if (!res.ok) throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`); }; /** From 076d62a22bfc52281d38554db3607ce20fbf9608 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 7 Jun 2024 15:54:45 +0530 Subject: [PATCH 16/19] Rearrange --- .../accounts/src/pages/passkeys/index.tsx | 94 +++++++++---------- 1 file changed, 47 insertions(+), 47 deletions(-) diff --git a/web/apps/accounts/src/pages/passkeys/index.tsx b/web/apps/accounts/src/pages/passkeys/index.tsx index aae41dd8bf..946946e208 100644 --- a/web/apps/accounts/src/pages/passkeys/index.tsx +++ b/web/apps/accounts/src/pages/passkeys/index.tsx @@ -295,6 +295,53 @@ const ManagePasskeyDrawer: React.FC = ({ ); }; +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 = ({ + 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 ( + + + + ); +}; + interface DeletePasskeyDialogProps { /** If `true`, then the dialog is shown. */ open: boolean; @@ -351,50 +398,3 @@ const DeletePasskeyDialog: React.FC = ({ ); }; - -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 = ({ - 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 ( - - - - ); -}; From 8a5c3a326220c15706ba0c6e0647253092a54885 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 7 Jun 2024 15:59:14 +0530 Subject: [PATCH 17/19] Delete --- web/apps/accounts/src/services/passkey.ts | 25 ++++++++++------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/web/apps/accounts/src/services/passkey.ts b/web/apps/accounts/src/services/passkey.ts index 2d364c3939..d90afc3134 100644 --- a/web/apps/accounts/src/services/passkey.ts +++ b/web/apps/accounts/src/services/passkey.ts @@ -66,21 +66,18 @@ export const renamePasskey = async (id: string, name: string) => { 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) => { - 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; - } + 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}`); }; /** From cbdca9851c77557dfed5ad49e8da434da95d2079 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 7 Jun 2024 16:28:56 +0530 Subject: [PATCH 18/19] Get --- .../accounts/src/pages/passkeys/index.tsx | 3 +- web/apps/accounts/src/services/passkey.ts | 51 +++++++++++++------ 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/web/apps/accounts/src/pages/passkeys/index.tsx b/web/apps/accounts/src/pages/passkeys/index.tsx index 946946e208..fa6ecde21d 100644 --- a/web/apps/accounts/src/pages/passkeys/index.tsx +++ b/web/apps/accounts/src/pages/passkeys/index.tsx @@ -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); } diff --git a/web/apps/accounts/src/services/passkey.ts b/web/apps/accounts/src/services/passkey.ts index d90afc3134..093c1ec8d6 100644 --- a/web/apps/accounts/src/services/passkey.ts +++ b/web/apps/accounts/src/services/passkey.ts @@ -9,6 +9,7 @@ import { import HTTPService from "@ente/shared/network/HTTPService"; import { apiOrigin, getEndpoint } from "@ente/shared/network/api"; import { getToken } from "@ente/shared/storage/localStorage/helpers"; +import { z } from "zod"; const ENDPOINT = getEndpoint(); @@ -31,22 +32,40 @@ const accountsAuthenticatedRequestHeaders = (): Record => { return headers; }; -export interface Passkey { - id: string; - userID: number; - friendlyName: string; - createdAt: number; -} +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; + +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 token = getToken(); - if (!token) return; - const response = await HTTPService.get( - `${ENDPOINT}/passkeys`, - {}, - { "X-Auth-Token": token }, - ); - return await response.data; + 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; }; /** @@ -54,7 +73,7 @@ export const getPasskeys = async () => { * * @param id The `id` of the existing passkey to rename. * - * @param name The new name. aka "friendly name". + * @param name The new name (a.k.a. "friendly name"). */ export const renamePasskey = async (id: string, name: string) => { const params = new URLSearchParams({ friendlyName: name }); @@ -84,7 +103,7 @@ export const deletePasskey = async (id: string) => { * 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"). + * (a.k.a. "friendly name"). */ export const registerPasskey = async (name: string) => { // Get options (and sessionID) from the backend. From 2dca9f17581ba6475cebaf30055ada57cdbc4305 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 7 Jun 2024 16:31:30 +0530 Subject: [PATCH 19/19] Lint fix --- web/apps/auth/src/pages/_app.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/apps/auth/src/pages/_app.tsx b/web/apps/auth/src/pages/_app.tsx index 319d4227a2..1777d2915d 100644 --- a/web/apps/auth/src/pages/_app.tsx +++ b/web/apps/auth/src/pages/_app.tsx @@ -1,4 +1,5 @@ import { CustomHead } from "@/next/components/Head"; +import { setAppNameForAuthenticatedRequests } from "@/next/http"; import { setupI18n } from "@/next/i18n"; import { logStartupBanner, @@ -33,7 +34,6 @@ import { useRouter } from "next/router"; import { createContext, useContext, useEffect, useRef, useState } from "react"; import LoadingBar, { type LoadingBarRef } from "react-top-loading-bar"; import "../../public/css/global.css"; -import { setAppNameForAuthenticatedRequests } from "@/next/http"; /** * Properties available via the {@link AppContext} to the Auth app's React tree.