Skip to content

Commit

Permalink
Basic popup login working
Browse files Browse the repository at this point in the history
  • Loading branch information
robknight committed Sep 26, 2024
1 parent 0ac1827 commit 6a0fc21
Show file tree
Hide file tree
Showing 12 changed files with 306 additions and 18 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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 = "#/";
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
pendingRequestKeys,
setPendingAddRequest,
setPendingAddSubscriptionRequest,
setPendingAuthenticateIFrameRequest,
setPendingGenericIssuanceCheckinRequest,
setPendingGetWithoutProvingRequest,
setPendingProofRequest,
Expand Down Expand Up @@ -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;

Expand All @@ -104,6 +109,9 @@ export function LoginScreen(): JSX.Element {
pendingGenericIssuanceCheckinRequest
);
pendingRequestForLogging = pendingRequestKeys.genericIssuanceCheckin;
} else if (pendingAuthenticateIFrameRequest) {
setPendingAuthenticateIFrameRequest(pendingAuthenticateIFrameRequest);
pendingRequestForLogging = pendingRequestKeys.authenticateIFrame;
}

if (pendingRequestForLogging) {
Expand All @@ -118,7 +126,8 @@ export function LoginScreen(): JSX.Element {
pendingViewSubscriptionsRequest,
pendingAddSubscriptionRequest,
pendingViewFrogCryptoRequest,
pendingGenericIssuanceCheckinRequest
pendingGenericIssuanceCheckinRequest,
pendingAuthenticateIFrameRequest
]);

const suggestedEmail = query?.get("email");
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<AppContainer bg="gray">
<Spacer h={64} />
<RippleLoader />
</AppContainer>
);
}
Original file line number Diff line number Diff line change
@@ -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<typeof AuthMessage>;

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<string | null>(null);
const [status, setStatus] = useState<PopupAuthenticationStatus>(
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 (
<AppContainer bg="primary">
<Spacer h={64} />
<TextCenter>
<H1>ZUPASS</H1>
<Spacer h={24} />
<Button onClick={openPopup} disabled={inProgress}>
<Spinner show={inProgress} text="Connect" />
</Button>
<Spacer h={8} />
{error && <TextCenter>{error}</TextCenter>}
</TextCenter>
</AppContainer>
);
}
1 change: 1 addition & 0 deletions apps/passport-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
7 changes: 7 additions & 0 deletions apps/passport-client/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -176,6 +178,11 @@ function RouterImpl(): JSX.Element {
path="generic-checkin"
element={<PodboxScannedTicketScreen />}
/>
<Route path="connect-popup" element={<ConnectPopupScreen />} />
<Route
path="authenticate-iframe"
element={<AuthenticateIFrameScreen />}
/>
<Route path="embedded" element={<EmbeddedScreen />} />
<Route path="*" element={<MissingScreen />} />
</Route>
Expand Down
42 changes: 41 additions & 1 deletion apps/passport-client/src/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -189,6 +192,9 @@ export type Action =
type: "zapp-connect";
zapp: Zapp;
origin: string;
}
| {
type: "approve-zapp";
};

export type StateContextValue = {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1516,6 +1524,9 @@ async function showEmbeddedScreen(
update: ZuUpdate,
screen: EmbeddedScreenState["screen"]
): Promise<void> {
if (window.parent !== window.self) {
window.location.hash = "embedded";
}
update({
embeddedScreen: { screen }
});
Expand All @@ -1541,3 +1552,32 @@ async function zappConnect(
connectedZapp: zapp
});
}

async function approveZapp(state: AppState, update: ZuUpdate): Promise<void> {
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");
}
Loading

0 comments on commit 6a0fc21

Please sign in to comment.