From 6a0fc218273262114225311ad2259fb1d2df855a Mon Sep 17 00:00:00 2001 From: Rob Knight Date: Thu, 26 Sep 2024 10:02:31 +0100 Subject: [PATCH] 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"