diff --git a/web/apps/accounts/src/pages/_app.tsx b/web/apps/accounts/src/pages/_app.tsx index 25008063dc..6f0d4a9b90 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 { 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, }); }; diff --git a/web/apps/accounts/src/pages/passkeys/handoff.tsx b/web/apps/accounts/src/pages/passkeys/handoff.tsx index 1e4fb72118..0f14455291 100644 --- a/web/apps/accounts/src/pages/passkeys/handoff.tsx +++ b/web/apps/accounts/src/pages/passkeys/handoff.tsx @@ -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"); diff --git a/web/apps/accounts/src/pages/passkeys/index.tsx b/web/apps/accounts/src/pages/passkeys/index.tsx index 77c2438b00..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); } @@ -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 = ({ 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 = ({ )} - {passkey && ( - setShowDeleteDialog(false)} - passkey={passkey} - onDeletePasskey={() => { - setShowDeleteDialog(false); - onUpdateOrDeletePasskey(); - }} - /> - )} - {passkey && ( = ({ }} /> )} + + {passkey && ( + 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 = ({ + 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; @@ -343,51 +397,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 onSubmit = async (inputValue: string) => { - try { - await renamePasskey(passkey.id, inputValue); - onRenamePasskey(); - } catch (e) { - log.error("Failed to rename passkey", e); - return; - } - }; - - return ( - - - - ); -}; diff --git a/web/apps/accounts/src/pages/passkeys/verify.tsx b/web/apps/accounts/src/pages/passkeys/verify.tsx index 5c1b1be141..7c738de19b 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"; @@ -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 diff --git a/web/apps/accounts/src/services/passkey.ts b/web/apps/accounts/src/services/passkey.ts index d6f5c4bf67..093c1ec8d6 100644 --- a/web/apps/accounts/src/services/passkey.ts +++ b/web/apps/accounts/src/services/passkey.ts @@ -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 => { + const token = getToken(); + if (!token) throw new Error("Missing accounts token"); + const headers: Record = { "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; + +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; diff --git a/web/apps/auth/src/pages/_app.tsx b/web/apps/auth/src/pages/_app.tsx index 50397b63ed..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, @@ -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/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/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/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()}`; }; 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..69327fd53d --- /dev/null +++ b/web/packages/next/http.ts @@ -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 => { + const headers: Record = { + "X-Auth-Token": ensureAuthToken(), + }; + if (_clientPackage) headers["X-Client-Package"] = _clientPackage; + 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; 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) =>