From 6a0fc218273262114225311ad2259fb1d2df855a Mon Sep 17 00:00:00 2001 From: Rob Knight Date: Thu, 26 Sep 2024 10:02:31 +0100 Subject: [PATCH 1/3] Basic popup login working --- .../LoginScreens/LoginInterstitialScreen.tsx | 10 ++ .../screens/LoginScreens/LoginScreen.tsx | 11 +- .../ZappScreens/AuthenticateIFrameScreen.tsx | 53 +++++++ .../ZappScreens/ConnectPopupScreen.tsx | 134 ++++++++++++++++++ apps/passport-client/package.json | 1 + apps/passport-client/pages/index.tsx | 7 + apps/passport-client/src/dispatch.ts | 42 +++++- apps/passport-client/src/sessionStorage.ts | 20 ++- apps/passport-client/src/state.ts | 3 +- apps/passport-client/src/util.ts | 4 + .../passport-client/src/zapp/useZappServer.ts | 34 +++-- yarn.lock | 5 + 12 files changed, 306 insertions(+), 18 deletions(-) create mode 100644 apps/passport-client/components/screens/ZappScreens/AuthenticateIFrameScreen.tsx create mode 100644 apps/passport-client/components/screens/ZappScreens/ConnectPopupScreen.tsx diff --git a/apps/passport-client/components/screens/LoginScreens/LoginInterstitialScreen.tsx b/apps/passport-client/components/screens/LoginScreens/LoginInterstitialScreen.tsx index 164459b0dc..6e7d50cbc7 100644 --- a/apps/passport-client/components/screens/LoginScreens/LoginInterstitialScreen.tsx +++ b/apps/passport-client/components/screens/LoginScreens/LoginInterstitialScreen.tsx @@ -17,8 +17,10 @@ export function LoginInterstitialScreen(): JSX.Element { const loadedIssuedPCDs = useLoadedIssuedPCDs(); useLayoutEffect(() => { + console.log("loadedIssuedPCDs", loadedIssuedPCDs); if (loadedIssuedPCDs) { const pendingRequest = getPendingRequest(); + console.log("pendingRequest", pendingRequest); if (pendingRequest) { switch (pendingRequest.key) { case "proof": { @@ -83,6 +85,14 @@ export function LoginInterstitialScreen(): JSX.Element { }); break; } + case "authenticateIFrame": { + console.log("Redirecting to Authenticate IFrame screen"); + clearAllPendingRequests(); + navigate(`/authenticate-iframe`, { + replace: true + }); + break; + } default: window.location.hash = "#/"; } diff --git a/apps/passport-client/components/screens/LoginScreens/LoginScreen.tsx b/apps/passport-client/components/screens/LoginScreens/LoginScreen.tsx index 332828672d..2f34338e87 100644 --- a/apps/passport-client/components/screens/LoginScreens/LoginScreen.tsx +++ b/apps/passport-client/components/screens/LoginScreens/LoginScreen.tsx @@ -23,6 +23,7 @@ import { pendingRequestKeys, setPendingAddRequest, setPendingAddSubscriptionRequest, + setPendingAuthenticateIFrameRequest, setPendingGenericIssuanceCheckinRequest, setPendingGetWithoutProvingRequest, setPendingProofRequest, @@ -78,6 +79,10 @@ export function LoginScreen(): JSX.Element { const pendingGenericIssuanceCheckinRequest = query?.get( pendingRequestKeys.genericIssuanceCheckin ); + const pendingAuthenticateIFrameRequest = query?.get( + pendingRequestKeys.authenticateIFrame + ); + useEffect(() => { let pendingRequestForLogging: string | undefined = undefined; @@ -104,6 +109,9 @@ export function LoginScreen(): JSX.Element { pendingGenericIssuanceCheckinRequest ); pendingRequestForLogging = pendingRequestKeys.genericIssuanceCheckin; + } else if (pendingAuthenticateIFrameRequest) { + setPendingAuthenticateIFrameRequest(pendingAuthenticateIFrameRequest); + pendingRequestForLogging = pendingRequestKeys.authenticateIFrame; } if (pendingRequestForLogging) { @@ -118,7 +126,8 @@ export function LoginScreen(): JSX.Element { pendingViewSubscriptionsRequest, pendingAddSubscriptionRequest, pendingViewFrogCryptoRequest, - pendingGenericIssuanceCheckinRequest + pendingGenericIssuanceCheckinRequest, + pendingAuthenticateIFrameRequest ]); const suggestedEmail = query?.get("email"); diff --git a/apps/passport-client/components/screens/ZappScreens/AuthenticateIFrameScreen.tsx b/apps/passport-client/components/screens/ZappScreens/AuthenticateIFrameScreen.tsx new file mode 100644 index 0000000000..887eb5e48f --- /dev/null +++ b/apps/passport-client/components/screens/ZappScreens/AuthenticateIFrameScreen.tsx @@ -0,0 +1,53 @@ +import { ReactNode, useEffect, useMemo } from "react"; +import { useLoginIfNoSelf, useSyncKey } from "../../../src/appHooks"; +import { pendingRequestKeys } from "../../../src/sessionStorage"; +import { Spacer } from "../../core"; +import { RippleLoader } from "../../core/RippleLoader"; +import { AppContainer } from "../../shared/AppContainer"; +import { AuthMessage } from "./ConnectPopupScreen"; + +export function AuthenticateIFrameScreen(): ReactNode { + const encryptionKey = useSyncKey(); + + useLoginIfNoSelf(pendingRequestKeys.authenticateIFrame, "true"); + + // We should only process Zapp approval requests if this window was opened + // by a Zupass iframe. + const isLegitimateOpener = useMemo(() => { + return ( + !!window.opener && + new URL(window.opener.location.href).origin === window.location.origin + ); + }, []); + + useEffect(() => { + console.log("AuthenticateIFrameScreen", isLegitimateOpener); + if (isLegitimateOpener && encryptionKey) { + const chan = new MessageChannel(); + chan.port1.onmessage = (): void => { + // We're going to send the encryption key to the iframe + // When the iframe gets the encryption key, it will + // send a message back to this port, and we can close + // this window + window.close(); + }; + // Zapp is already approved, return to the Zupass iframe + window.opener.postMessage( + { + type: "auth", + encryptionKey: encryptionKey as string + } satisfies AuthMessage, + origin, + [chan.port2] + ); + window.close(); + } + }, [isLegitimateOpener, encryptionKey]); + + return ( + + + + + ); +} diff --git a/apps/passport-client/components/screens/ZappScreens/ConnectPopupScreen.tsx b/apps/passport-client/components/screens/ZappScreens/ConnectPopupScreen.tsx new file mode 100644 index 0000000000..3ee5305a51 --- /dev/null +++ b/apps/passport-client/components/screens/ZappScreens/ConnectPopupScreen.tsx @@ -0,0 +1,134 @@ +import { requestDownloadAndDecryptStorage } from "@pcd/passport-interface"; +import { Button, Spacer } from "@pcd/passport-ui"; +import { ReactNode, useCallback, useState } from "react"; +import urljoin from "url-join"; +import * as v from "valibot"; +import { appConfig } from "../../../src/appConfig"; +import { useDispatch } from "../../../src/appHooks"; +import { H1, TextCenter } from "../../core"; +import { AppContainer } from "../../shared/AppContainer"; +import { Spinner } from "../../shared/Spinner"; + +const AuthMessage = v.object({ + type: v.literal("auth"), + encryptionKey: v.string() +}); + +export type AuthMessage = v.InferOutput; + +enum PopupAuthenticationStatus { + Start, + PopupOpen, + PopupBlocked, + PopupClosed, + Authenticating, + AuthenticationError +} + +/** + * This screen is only ever shown in a popup modal. It is used when Zupass is + * embedded in an iframe but has not been authenticated yet, and it opens a + * popup window which will handle authentication and post an encryption key + * back to the iframe. + * + * After we get the encryption key, we log in. This will trigger an event in + * {@link useZappServer} which will tell the Zapp to close the modal window. + */ +export function ConnectPopupScreen(): ReactNode { + const [error, setError] = useState(null); + const [status, setStatus] = useState( + PopupAuthenticationStatus.Start + ); + const dispatch = useDispatch(); + const tryToLogin = useCallback( + async (encryptionKey: string) => { + // Try to download and decrypt the storage + const storageRequest = await requestDownloadAndDecryptStorage( + appConfig.zupassServer, + encryptionKey + ); + if (storageRequest.success) { + // Success, log in + dispatch({ + type: "load-after-login", + storage: storageRequest.value, + encryptionKey + }); + } else { + // Something unexpected went wrong + setError( + "Unable to log in automatically, please enter your email to log in" + ); + } + }, + [dispatch] + ); + + const openPopup = useCallback(() => { + const popup = window.open( + urljoin(window.origin, "/#/authenticate-iframe"), + "_blank", + "width=500,height=500,popup=true" + ); + if (!popup) { + setStatus(PopupAuthenticationStatus.PopupBlocked); + return; + } + + // If the user closes the popup, we need to abort the message listener + const abortReceiveMessage = new AbortController(); + + const interval = window.setInterval(() => { + // The user may close the popup without authenticating, in which case we + // need to show a message to the user telling them not to do this. + // There is no reliable event for detecting this, so we have to check + // using a timer. + if (popup.closed) { + setStatus(PopupAuthenticationStatus.PopupClosed); + clearInterval(interval); + // Race conditions are a risk here, so we wait 250ms to ensure that + // the popup has had a chance to send a message. + window.setTimeout(() => { + abortReceiveMessage.abort(); + }, 250); + } + }, 250); + + window.addEventListener( + "message", + (event) => { + console.log("message", event); + if (event.origin !== window.origin || event.source !== popup) { + return; + } + + // Check if the message is an authentication message + const parsed = v.safeParse(AuthMessage, event.data); + if (parsed.success) { + event.ports[0].postMessage("ACK"); + tryToLogin(parsed.output.encryptionKey); + } + }, + { signal: abortReceiveMessage.signal } + ); + }, [tryToLogin]); + + const inProgress = + status === PopupAuthenticationStatus.PopupOpen || + status === PopupAuthenticationStatus.Authenticating; + + return ( + + + +

ZUPASS

+ + + + {error && {error}} +
+
+ ); +} diff --git a/apps/passport-client/package.json b/apps/passport-client/package.json index 1d023f53fb..671db198ec 100644 --- a/apps/passport-client/package.json +++ b/apps/passport-client/package.json @@ -103,6 +103,7 @@ "ua-parser-js": "^1.0.38", "use-file-picker": "^2.1.1", "uuid": "^9.0.0", + "valibot": "^0.42.1", "zod": "^3.22.4" }, "devDependencies": { diff --git a/apps/passport-client/pages/index.tsx b/apps/passport-client/pages/index.tsx index 6a237ee888..1305f693c5 100644 --- a/apps/passport-client/pages/index.tsx +++ b/apps/passport-client/pages/index.tsx @@ -47,6 +47,8 @@ import { PodboxScannedTicketScreen } from "../components/screens/ScannedTicketSc import { ServerErrorScreen } from "../components/screens/ServerErrorScreen"; import { SubscriptionsScreen } from "../components/screens/SubscriptionsScreen"; import { TermsScreen } from "../components/screens/TermsScreen"; +import { AuthenticateIFrameScreen } from "../components/screens/ZappScreens/AuthenticateIFrameScreen"; +import { ConnectPopupScreen } from "../components/screens/ZappScreens/ConnectPopupScreen"; import { AppContainer, Background, @@ -176,6 +178,11 @@ function RouterImpl(): JSX.Element { path="generic-checkin" element={} /> + } /> + } + /> } /> } /> diff --git a/apps/passport-client/src/dispatch.ts b/apps/passport-client/src/dispatch.ts index 5b6bdc8f21..da94d2fb38 100644 --- a/apps/passport-client/src/dispatch.ts +++ b/apps/passport-client/src/dispatch.ts @@ -28,7 +28,9 @@ import { ZupassFeedIds } from "@pcd/passport-interface"; import { PCDCollection, PCDPermission } from "@pcd/pcd-collection"; -import { PCD, SerializedPCD } from "@pcd/pcd-types"; +import { ArgumentTypeName, PCD, SerializedPCD } from "@pcd/pcd-types"; +import { encodePrivateKey } from "@pcd/pod"; +import { PODPCD, PODPCDPackage } from "@pcd/pod-pcd"; import { isPODTicketPCD } from "@pcd/pod-ticket-pcd"; import { isSemaphoreIdentityPCD, @@ -42,6 +44,7 @@ import { StrichSDK } from "@pixelverse/strichjs-sdk"; import { Identity } from "@semaphore-protocol/identity"; import _ from "lodash"; import { createContext } from "react"; +import { v4 as uuidv4 } from "uuid"; import { appConfig } from "./appConfig"; import { notifyLoginToOtherTabs, @@ -189,6 +192,9 @@ export type Action = type: "zapp-connect"; zapp: Zapp; origin: string; + } + | { + type: "approve-zapp"; }; export type StateContextValue = { @@ -322,6 +328,8 @@ export async function dispatch( return hideEmbeddedScreen(state, update); case "zapp-connect": return zappConnect(state, update, action.zapp, action.origin); + case "approve-zapp": + return approveZapp(state, update); default: // We can ensure that we never get here using the type system return assertUnreachable(action); @@ -1516,6 +1524,9 @@ async function showEmbeddedScreen( update: ZuUpdate, screen: EmbeddedScreenState["screen"] ): Promise { + if (window.parent !== window.self) { + window.location.hash = "embedded"; + } update({ embeddedScreen: { screen } }); @@ -1541,3 +1552,32 @@ async function zappConnect( connectedZapp: zapp }); } + +async function approveZapp(state: AppState, update: ZuUpdate): Promise { + const zapp = state.connectedZapp; + if (!zapp || !state.zappOrigin) { + return; + } + const newZapp = (await PODPCDPackage.prove({ + entries: { + argumentType: ArgumentTypeName.Object, + value: { + origin: { type: "string", value: state.zappOrigin }, + name: { type: "string", value: zapp.name } + } + }, + privateKey: { + argumentType: ArgumentTypeName.String, + value: encodePrivateKey( + Buffer.from(v3tov4Identity(state.identityV3).export(), "base64") + ) + }, + id: { + argumentType: ArgumentTypeName.String, + value: uuidv4() + } + })) as PODPCD; + + const newZappSerialized = await PODPCDPackage.serialize(newZapp); + return addPCDs(state, update, [newZappSerialized], false, "Zapps"); +} diff --git a/apps/passport-client/src/sessionStorage.ts b/apps/passport-client/src/sessionStorage.ts index 541ce3072f..d304ab58ca 100644 --- a/apps/passport-client/src/sessionStorage.ts +++ b/apps/passport-client/src/sessionStorage.ts @@ -6,6 +6,7 @@ export function clearAllPendingRequests(): void { clearPendingViewSubscriptionsRequest(); clearPendingAddSubscriptionRequest(); clearPendingGenericIssuanceCheckinRequest(); + clearPendingAuthenticateIFrameRequest(); } export function hasPendingRequest(): boolean { @@ -17,7 +18,8 @@ export function hasPendingRequest(): boolean { getPendingViewSubscriptionsPageRequest() || getPendingAddSubscriptionPageRequest() || getPendingViewFrogCryptoPageRequest() || - getPendingGenericIssuanceCheckinRequest() + getPendingGenericIssuanceCheckinRequest() || + getPendingAuthenticateIFrameRequest() ); } @@ -29,7 +31,8 @@ export const pendingRequestKeys: Record = { viewSubscriptions: "pendingViewSubscriptions", addSubscription: "pendingAddSubscription", viewFrogCrypto: "pendingViewFrogCrypto", - genericIssuanceCheckin: "pendingGenericIssuanceCheckin" + genericIssuanceCheckin: "pendingGenericIssuanceCheckin", + authenticateIFrame: "pendingAuthenticateIFrame" } as const; export function setPendingGetWithoutProvingRequest(request: string): void { @@ -138,6 +141,19 @@ export function getPendingGenericIssuanceCheckinRequest(): string | undefined { return value ?? undefined; } +export function setPendingAuthenticateIFrameRequest(request: string): void { + sessionStorage.setItem(pendingRequestKeys.authenticateIFrame, request); +} + +export function clearPendingAuthenticateIFrameRequest(): void { + sessionStorage.removeItem(pendingRequestKeys.authenticateIFrame); +} + +export function getPendingAuthenticateIFrameRequest(): string | undefined { + const value = sessionStorage.getItem(pendingRequestKeys.authenticateIFrame); + return value ?? undefined; +} + /** * Gets any pending request, if any. Returns undefined if none. */ diff --git a/apps/passport-client/src/state.ts b/apps/passport-client/src/state.ts index 2f2582d40a..5e20fc2c3c 100644 --- a/apps/passport-client/src/state.ts +++ b/apps/passport-client/src/state.ts @@ -137,7 +137,8 @@ export interface AppState { strichSDKstate: "initialized" | "error" | undefined; - // If Zupass is in an embedded iframe, the state of the embedded screen. + // If we're showing a screen in an embedded iframe or a dialog above an + // embedded Zapp, the state of that screen. embeddedScreen?: EmbeddedScreenState; connectedZapp?: Zapp; diff --git a/apps/passport-client/src/util.ts b/apps/passport-client/src/util.ts index 00a0357b33..59c35fb535 100644 --- a/apps/passport-client/src/util.ts +++ b/apps/passport-client/src/util.ts @@ -133,3 +133,7 @@ export function bigintToUint8Array(bigint: bigint): Uint8Array { export function uint8arrayToBigint(uint8Array: Uint8Array): bigint { return BigInt("0x" + Buffer.from(uint8Array).toString("hex")); } + +export function isInIframe(): boolean { + return window !== window.parent; +} diff --git a/apps/passport-client/src/zapp/useZappServer.ts b/apps/passport-client/src/zapp/useZappServer.ts index 46ea9b26ed..245aef773d 100644 --- a/apps/passport-client/src/zapp/useZappServer.ts +++ b/apps/passport-client/src/zapp/useZappServer.ts @@ -6,6 +6,7 @@ import { v3tov4Identity } from "@pcd/semaphore-identity-pcd"; import { useEffect } from "react"; import { v4 as uuidv4 } from "uuid"; import { useStateContext } from "../appHooks"; +import { StateContextValue } from "../dispatch"; import { ZupassRPCProcessor } from "./ZappServer"; export enum ListenMode { @@ -13,6 +14,19 @@ export enum ListenMode { LISTEN_IF_NOT_EMBEDDED } +async function waitForAuthentication( + context: StateContextValue +): Promise { + return new Promise((resolve) => { + const unlisten = context.stateEmitter.listen((state) => { + if (state.self) { + unlisten(); + resolve(); + } + }); + }); +} + export function useZappServer(mode: ListenMode): void { const context = useStateContext(); @@ -31,20 +45,13 @@ export function useZappServer(mode: ListenMode): void { } (async (): Promise => { const { zapp, advice, origin } = await listen(); - - if (!context.getState().self) { + context.dispatch({ type: "zapp-connect", zapp, origin }); + if (mode === ListenMode.LISTEN_IF_EMBEDDED && !context.getState().self) { + // If we're not logged in, we need to show a message to the user + window.location.hash = "connect-popup"; advice.showClient(); - context.dispatch({ type: "zapp-connect", zapp, origin }); - - await new Promise((resolve) => { - const unlisten = context.stateEmitter.listen((state) => { - if (state.self) { - advice.hideClient(); - unlisten(); - resolve(); - } - }); - }); + await waitForAuthentication(context); + advice.hideClient(); } const zapps = context.getState().pcds.getAllPCDsInFolder("Zapps"); @@ -64,6 +71,7 @@ export function useZappServer(mode: ListenMode): void { let approved = !!zappPOD; if (!zappPOD) { + // @todo show a modal instead of using confirm approved = confirm( `Allow ${zapp.name} at ${origin} to connect to your Zupass account?\r\n\r\nThis is HIGHLY EXPERIMENTAL - make sure you trust this website.` ); diff --git a/yarn.lock b/yarn.lock index f7e1edc906..b0c94eeb4b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -22147,6 +22147,11 @@ valibot@^0.42.0: resolved "https://registry.yarnpkg.com/valibot/-/valibot-0.42.0.tgz#fed34043e49d1ee4327d700f01e1a24182ca8aef" integrity sha512-igMdmHXxDiQY714ssh9bGisMqJ2yg7sko1KOmv/omnrIacGtP6mGrbvVT1IuV1bDrHyG9ybgpHwG1UElDiDCLg== +valibot@^0.42.1: + version "0.42.1" + resolved "https://registry.yarnpkg.com/valibot/-/valibot-0.42.1.tgz#a31183d8e9d7552f98e22ca0977172cab8815188" + integrity sha512-3keXV29Ar5b//Hqi4MbSdV7lfVp6zuYLZuA9V1PvQUsXqogr+u5lvLPLk3A4f74VUXDnf/JfWMN6sB+koJ/FFw== + validate-npm-package-license@^3.0.1: version "3.0.4" resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a" From 29df6a8a7d3d6c2ed4cb5300317bbf5e7fe701e5 Mon Sep 17 00:00:00 2001 From: Rob Knight Date: Thu, 26 Sep 2024 18:27:19 +0100 Subject: [PATCH 2/3] Working error messages for failure cases --- .../LoginScreens/LoginInterstitialScreen.tsx | 2 - .../ZappScreens/AuthenticateIFrameScreen.tsx | 5 +- .../ZappScreens/ConnectPopupScreen.tsx | 190 ++++++++++++------ examples/test-zapp/src/utils.ts | 2 +- examples/test-zapp/src/vite-env.d.ts | 9 + test-packaging/vite/src/vite-env.d.ts | 1 - turbo.json | 2 +- 7 files changed, 147 insertions(+), 64 deletions(-) create mode 100644 examples/test-zapp/src/vite-env.d.ts delete mode 100644 test-packaging/vite/src/vite-env.d.ts diff --git a/apps/passport-client/components/screens/LoginScreens/LoginInterstitialScreen.tsx b/apps/passport-client/components/screens/LoginScreens/LoginInterstitialScreen.tsx index 6e7d50cbc7..e4dc3f6867 100644 --- a/apps/passport-client/components/screens/LoginScreens/LoginInterstitialScreen.tsx +++ b/apps/passport-client/components/screens/LoginScreens/LoginInterstitialScreen.tsx @@ -17,10 +17,8 @@ export function LoginInterstitialScreen(): JSX.Element { const loadedIssuedPCDs = useLoadedIssuedPCDs(); useLayoutEffect(() => { - console.log("loadedIssuedPCDs", loadedIssuedPCDs); if (loadedIssuedPCDs) { const pendingRequest = getPendingRequest(); - console.log("pendingRequest", pendingRequest); if (pendingRequest) { switch (pendingRequest.key) { case "proof": { diff --git a/apps/passport-client/components/screens/ZappScreens/AuthenticateIFrameScreen.tsx b/apps/passport-client/components/screens/ZappScreens/AuthenticateIFrameScreen.tsx index 887eb5e48f..82283450da 100644 --- a/apps/passport-client/components/screens/ZappScreens/AuthenticateIFrameScreen.tsx +++ b/apps/passport-client/components/screens/ZappScreens/AuthenticateIFrameScreen.tsx @@ -4,7 +4,7 @@ import { pendingRequestKeys } from "../../../src/sessionStorage"; import { Spacer } from "../../core"; import { RippleLoader } from "../../core/RippleLoader"; import { AppContainer } from "../../shared/AppContainer"; -import { AuthMessage } from "./ConnectPopupScreen"; +import { IFrameAuthenticationMessage } from "./ConnectPopupScreen"; export function AuthenticateIFrameScreen(): ReactNode { const encryptionKey = useSyncKey(); @@ -21,7 +21,6 @@ export function AuthenticateIFrameScreen(): ReactNode { }, []); useEffect(() => { - console.log("AuthenticateIFrameScreen", isLegitimateOpener); if (isLegitimateOpener && encryptionKey) { const chan = new MessageChannel(); chan.port1.onmessage = (): void => { @@ -36,7 +35,7 @@ export function AuthenticateIFrameScreen(): ReactNode { { type: "auth", encryptionKey: encryptionKey as string - } satisfies AuthMessage, + } satisfies IFrameAuthenticationMessage, origin, [chan.port2] ); diff --git a/apps/passport-client/components/screens/ZappScreens/ConnectPopupScreen.tsx b/apps/passport-client/components/screens/ZappScreens/ConnectPopupScreen.tsx index 3ee5305a51..02fb14868f 100644 --- a/apps/passport-client/components/screens/ZappScreens/ConnectPopupScreen.tsx +++ b/apps/passport-client/components/screens/ZappScreens/ConnectPopupScreen.tsx @@ -1,28 +1,32 @@ import { requestDownloadAndDecryptStorage } from "@pcd/passport-interface"; import { Button, Spacer } from "@pcd/passport-ui"; -import { ReactNode, useCallback, useState } from "react"; +import { ReactNode, useCallback, useEffect, useRef, useState } from "react"; +import styled from "styled-components"; import urljoin from "url-join"; import * as v from "valibot"; import { appConfig } from "../../../src/appConfig"; import { useDispatch } from "../../../src/appHooks"; +import { useSelector } from "../../../src/subscribe"; import { H1, TextCenter } from "../../core"; import { AppContainer } from "../../shared/AppContainer"; import { Spinner } from "../../shared/Spinner"; -const AuthMessage = v.object({ +const IFrameAuthenticationMessageSchema = v.object({ type: v.literal("auth"), encryptionKey: v.string() }); -export type AuthMessage = v.InferOutput; +export type IFrameAuthenticationMessage = v.InferOutput< + typeof IFrameAuthenticationMessageSchema +>; enum PopupAuthenticationStatus { - Start, - PopupOpen, - PopupBlocked, - PopupClosed, - Authenticating, - AuthenticationError + Start = "Start", + PopupOpen = "PopupOpen", + PopupBlocked = "PopupBlocked", + PopupClosed = "PopupClosed", + Authenticating = "Authenticating", + AuthenticationError = "AuthenticationError" } /** @@ -35,13 +39,12 @@ enum PopupAuthenticationStatus { * {@link useZappServer} which will tell the Zapp to close the modal window. */ export function ConnectPopupScreen(): ReactNode { - const [error, setError] = useState(null); const [status, setStatus] = useState( PopupAuthenticationStatus.Start ); const dispatch = useDispatch(); const tryToLogin = useCallback( - async (encryptionKey: string) => { + async (encryptionKey: string): Promise => { // Try to download and decrypt the storage const storageRequest = await requestDownloadAndDecryptStorage( appConfig.zupassServer, @@ -54,16 +57,95 @@ export function ConnectPopupScreen(): ReactNode { storage: storageRequest.value, encryptionKey }); + return true; } else { - // Something unexpected went wrong - setError( - "Unable to log in automatically, please enter your email to log in" - ); + return false; } }, [dispatch] ); + const messageListenerAbortRef = useRef(null); + const popupRef = useRef(null); + + useEffect(() => { + let closeCheckInterval: number; + let closeNotifyTimeout: number; + if (status === PopupAuthenticationStatus.PopupOpen) { + closeCheckInterval = window.setInterval(() => { + // The user may close the popup without authenticating, in which case we + // need to show a message to the user telling them not to do this. + // There is no reliable event for detecting this, so we have to check + // using a timer. + if (popupRef.current && popupRef.current.closed) { + clearInterval(closeCheckInterval); + // Race conditions are a risk here, so we wait 250ms to ensure that + // the popup has had a chance to send a message. + closeNotifyTimeout = window.setTimeout(() => { + if (status === PopupAuthenticationStatus.PopupOpen) { + setStatus(PopupAuthenticationStatus.PopupClosed); + messageListenerAbortRef.current?.abort(); + } + }, 250); + } + }, 250); + return () => { + if (closeCheckInterval) { + clearInterval(closeCheckInterval); + } + if (closeNotifyTimeout) { + clearTimeout(closeNotifyTimeout); + } + }; + } + }, [status]); + + useEffect(() => { + if ( + status === PopupAuthenticationStatus.PopupOpen || + status === PopupAuthenticationStatus.PopupBlocked + ) { + // If the user closes the popup, we need to abort the message listener + const messageListenerAbort = new AbortController(); + + // If we already have a message listener, abort it + if (messageListenerAbortRef.current) { + messageListenerAbortRef.current.abort(); + } + messageListenerAbortRef.current = messageListenerAbort; + + window.addEventListener( + "message", + async (event) => { + if (event.origin !== window.origin) { + return; + } + + // Check if the message is an authentication message + const parsed = v.safeParse( + IFrameAuthenticationMessageSchema, + event.data + ); + if ( + parsed.success && + status === PopupAuthenticationStatus.PopupOpen + ) { + setStatus(PopupAuthenticationStatus.Authenticating); + // Sending this message back to the iframe lets it know that + // we've received the authentication message and it's okay to + // close + event.ports[0].postMessage("ACK"); + const loginResult = await tryToLogin(parsed.output.encryptionKey); + if (!loginResult) { + setStatus(PopupAuthenticationStatus.AuthenticationError); + } + } + }, + { signal: messageListenerAbort.signal } + ); + } + }, [status, tryToLogin]); + const openPopup = useCallback(() => { const popup = window.open( urljoin(window.origin, "/#/authenticate-iframe"), @@ -71,64 +153,60 @@ export function ConnectPopupScreen(): ReactNode { "width=500,height=500,popup=true" ); if (!popup) { + // Although the popup was blocked, the user may cause it to open by + // allowing the browser to open it, so we should continue to set up + // the message listener. setStatus(PopupAuthenticationStatus.PopupBlocked); - return; + } else { + setStatus(PopupAuthenticationStatus.PopupOpen); + popupRef.current = popup; } - - // If the user closes the popup, we need to abort the message listener - const abortReceiveMessage = new AbortController(); - - const interval = window.setInterval(() => { - // The user may close the popup without authenticating, in which case we - // need to show a message to the user telling them not to do this. - // There is no reliable event for detecting this, so we have to check - // using a timer. - if (popup.closed) { - setStatus(PopupAuthenticationStatus.PopupClosed); - clearInterval(interval); - // Race conditions are a risk here, so we wait 250ms to ensure that - // the popup has had a chance to send a message. - window.setTimeout(() => { - abortReceiveMessage.abort(); - }, 250); - } - }, 250); - - window.addEventListener( - "message", - (event) => { - console.log("message", event); - if (event.origin !== window.origin || event.source !== popup) { - return; - } - - // Check if the message is an authentication message - const parsed = v.safeParse(AuthMessage, event.data); - if (parsed.success) { - event.ports[0].postMessage("ACK"); - tryToLogin(parsed.output.encryptionKey); - } - }, - { signal: abortReceiveMessage.signal } - ); - }, [tryToLogin]); + }, []); const inProgress = status === PopupAuthenticationStatus.PopupOpen || status === PopupAuthenticationStatus.Authenticating; + const zappName = useSelector((state) => state.connectedZapp?.name); + const zappOrigin = useSelector((state) => state.zappOrigin); + return (

ZUPASS

+ + {zappName} ({zappOrigin}) would like to connect + to your Zupass. + + - - {error && {error}} + + {status === PopupAuthenticationStatus.PopupBlocked && ( + + Your browser may be configured to block popup windows. Please check + your browser settings and click the button above to try again. + + )} + {status === PopupAuthenticationStatus.AuthenticationError && ( + + An unexpected error occurred. Please try again. + + )} + {status === PopupAuthenticationStatus.PopupClosed && ( + + The popup window was closed before authentication could complete. + Please try again by clicking the button above. + + )}
); } + +const ZappName = styled.span` + font-weight: bold; +`; diff --git a/examples/test-zapp/src/utils.ts b/examples/test-zapp/src/utils.ts index 63231a94c7..0cb65e35d0 100644 --- a/examples/test-zapp/src/utils.ts +++ b/examples/test-zapp/src/utils.ts @@ -5,7 +5,7 @@ export function cn(...classes: string[]): string { } export const DEFAULT_CONNECTION_INFO: ClientConnectionInfo = { - url: process.env.CLIENT_URL ?? "https://staging-rob.zupass.org", + url: import.meta.env.VITE_CLIENT_URL ?? "https://staging-rob.zupass.org", type: "iframe" }; diff --git a/examples/test-zapp/src/vite-env.d.ts b/examples/test-zapp/src/vite-env.d.ts new file mode 100644 index 0000000000..c1ea77d600 --- /dev/null +++ b/examples/test-zapp/src/vite-env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly VITE_CLIENT_URL: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/test-packaging/vite/src/vite-env.d.ts b/test-packaging/vite/src/vite-env.d.ts deleted file mode 100644 index 11f02fe2a0..0000000000 --- a/test-packaging/vite/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/turbo.json b/turbo.json index 635ffaa2c7..0d8a7eedb4 100644 --- a/turbo.json +++ b/turbo.json @@ -115,7 +115,7 @@ "NODE_OPTIONS", "ZAPP_RESTRICT_ORIGINS", "ZAPP_ALLOWED_SIGNER_ORIGINS", - "CLIENT_URL", + "VITE_CLIENT_URL", "EMBEDDED_ZAPPS", "//// add env vars above this line ////" ], From ae1152d7de5cc7182ec63c4ea062002d704cca30 Mon Sep 17 00:00:00 2001 From: Rob Knight Date: Fri, 27 Sep 2024 09:03:15 +0200 Subject: [PATCH 3/3] Remove Chrome local storage access request (#1906) This is a follow-up to #1905, which removes the deprecated Chrome "local storage access" request, now that we have agreed on the popup flow as the primary login method. --- .../screens/LoginScreens/LoginScreen.tsx | 194 +++--------------- 1 file changed, 25 insertions(+), 169 deletions(-) diff --git a/apps/passport-client/components/screens/LoginScreens/LoginScreen.tsx b/apps/passport-client/components/screens/LoginScreens/LoginScreen.tsx index 2f34338e87..7f83c79c06 100644 --- a/apps/passport-client/components/screens/LoginScreens/LoginScreen.tsx +++ b/apps/passport-client/components/screens/LoginScreens/LoginScreen.tsx @@ -1,8 +1,4 @@ -import { - requestDownloadAndDecryptStorage, - requestLogToServer -} from "@pcd/passport-interface"; -import { TextButton } from "@pcd/passport-ui"; +import { requestLogToServer } from "@pcd/passport-interface"; import { validateEmail } from "@pcd/util"; import { ChangeEvent, @@ -11,7 +7,6 @@ import { useEffect, useState } from "react"; -import { UAParser } from "ua-parser-js"; import { appConfig } from "../../../src/appConfig"; import { useDispatch, @@ -30,7 +25,6 @@ import { setPendingViewFrogCryptoRequest, setPendingViewSubscriptionsRequest } from "../../../src/sessionStorage"; -import { useSelector } from "../../../src/subscribe"; import { BigInput, Button, @@ -44,23 +38,12 @@ import { RippleLoader } from "../../core/RippleLoader"; import { AppContainer } from "../../shared/AppContainer"; import { InlineError } from "../../shared/InlineError"; -enum StorageAccessStatus { - None, // Default status - CanRequest, // Suitable browser, show the option to request - Requesting, // Request dialog visible - Granted, // Access granted - NoLocalStorage, // Access granted but no relevant storage values found - Denied // Access denied -} - export function LoginScreen(): JSX.Element { const dispatch = useDispatch(); const state = useStateContext().getState(); const [error, setError] = useState(); const query = useQuery(); const redirectedFromAction = query?.get("redirectedFromAction") === "true"; - const connectedZapp = useSelector((state) => state.connectedZapp); - const zappOrigin = useSelector((state) => state.zappOrigin); const pendingGetWithoutProvingRequest = query?.get( pendingRequestKeys.getWithoutProving @@ -152,104 +135,6 @@ export function LoginScreen(): JSX.Element { [dispatch, email] ); - const [storageAccessStatus, setStorageAccessStatus] = useState( - StorageAccessStatus.None - ); - - /** - * Assuming we're in Chrome and an iframe, and we've successfully loaded an - * encryption key from local storage, try to use it to log in. - */ - const tryToLogin = useCallback( - async (encryptionKey: string) => { - // Try to download and decrypt the storage - const storageRequest = await requestDownloadAndDecryptStorage( - appConfig.zupassServer, - encryptionKey - ); - if (storageRequest.success) { - // Success, log in - dispatch({ - type: "load-after-login", - storage: storageRequest.value, - encryptionKey - }); - } else { - // Something unexpected went wrong - setError( - "Unable to log in automatically, please enter your email to log in" - ); - setStorageAccessStatus(StorageAccessStatus.Denied); - } - }, - [dispatch] - ); - - /** - * This will only be called if we're in an iframe and Chrome. - */ - const requestStorageAndLogIn = useCallback(async () => { - try { - setStorageAccessStatus(StorageAccessStatus.Requesting); - // @ts-expect-error Chrome-only API - const handle: { localStorage: Storage } = - // @ts-expect-error Chrome-only API - await document.requestStorageAccess({ localStorage: true }); - - setStorageAccessStatus(StorageAccessStatus.Granted); - // Access granted, try reading the local storage - const encryptionKey = handle.localStorage.getItem("encryption_key"); - if (encryptionKey) { - await tryToLogin(encryptionKey); - } else { - setStorageAccessStatus(StorageAccessStatus.NoLocalStorage); - } - } catch (_e) { - // If the user rejected the storage access request, set an error message. - // The finally block will return the user to the regular login flow. - setError( - "Unable to log in automatically, please enter your email to log in" - ); - setStorageAccessStatus(StorageAccessStatus.Denied); - } - }, [tryToLogin]); - - useEffect(() => { - (async (): Promise => { - // Are we in an iframe? If so, we might be able to skip requesting the - // user's email and password by retrieving their encryption key from the - // first-party local storage. Currently this only works on Chrome 125+. - const parser = new UAParser(); - const browserName = parser.getBrowser().name; - const browserVersion = parser.getBrowser().version; - const isChrome125OrAbove = - browserName === "Chrome" && - browserVersion && - parseInt(browserVersion) >= 125; - - if (window.parent !== window && isChrome125OrAbove) { - // Do we already have access? - const hasAccess = await document.hasStorageAccess(); - if (!hasAccess) { - // No access, try requesting it interactively - // Setting this state will trigger the UI to show the "Connect to - // Zupass" button. To request storage access, the user must click - // the button and approve the dialog. - // Storage access requests must occur in response to a user action, - // so we can't request it automatically here and must wait for the - // user to click the button. - setStorageAccessStatus(StorageAccessStatus.CanRequest); - } else { - // Access is allowed in principle, now we can request storage - // Show a spinner: - setStorageAccessStatus(StorageAccessStatus.Requesting); - // Try to read from storage and log in - requestStorageAndLogIn(); - } - } - })(); - }, [dispatch, requestStorageAndLogIn, tryToLogin]); - useEffect(() => { // Redirect to home if already logged in if (self) { @@ -290,61 +175,32 @@ export function LoginScreen(): JSX.Element { )} - {storageAccessStatus === StorageAccessStatus.CanRequest && ( - - - Do you want to allow {connectedZapp?.name} ({zappOrigin}) to - connect to Zupass? - - {" "} - - setStorageAccessStatus(StorageAccessStatus.Denied)} - > - Log in manually - - - )} - - {(storageAccessStatus === StorageAccessStatus.Requesting || - storageAccessStatus === StorageAccessStatus.Granted) && ( - + {!state.loggingOut && ( + <> - - + +
+ ) => + setEmail(e.target.value) + } + /> + + + + +
+ + )} - - {(storageAccessStatus === StorageAccessStatus.None || - storageAccessStatus === StorageAccessStatus.Denied || - storageAccessStatus === StorageAccessStatus.NoLocalStorage) && - !state.loggingOut && ( - <> - - -
- ) => - setEmail(e.target.value) - } - /> - - - - -
- - - )} ); }