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"