Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Iframe login via popup #1905

Merged
merged 4 commits into from
Sep 27, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,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,52 @@
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 { IFrameAuthenticationMessage } 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(() => {
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 IFrameAuthenticationMessage,
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,212 @@
import { requestDownloadAndDecryptStorage } from "@pcd/passport-interface";
import { Button, Spacer } from "@pcd/passport-ui";
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 IFrameAuthenticationMessageSchema = v.object({
type: v.literal("auth"),
encryptionKey: v.string()
});
Comment on lines +14 to +17
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i like the migration from zod -- we are the only z that matter 😆

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valibot also produces much smaller bundles!


export type IFrameAuthenticationMessage = v.InferOutput<
typeof IFrameAuthenticationMessageSchema
>;

enum PopupAuthenticationStatus {
Start = "Start",
PopupOpen = "PopupOpen",
PopupBlocked = "PopupBlocked",
PopupClosed = "PopupClosed",
Authenticating = "Authenticating",
AuthenticationError = "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 [status, setStatus] = useState<PopupAuthenticationStatus>(
PopupAuthenticationStatus.Start
);
const dispatch = useDispatch();
const tryToLogin = useCallback(
async (encryptionKey: string): Promise<boolean> => {
// 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
});
return true;
} else {
return false;
}
},
[dispatch]
);

const messageListenerAbortRef = useRef<AbortController | null>(null);
const popupRef = useRef<Window | null>(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"),
"_blank",
"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);
} else {
setStatus(PopupAuthenticationStatus.PopupOpen);
popupRef.current = popup;
}
}, []);

const inProgress =
status === PopupAuthenticationStatus.PopupOpen ||
status === PopupAuthenticationStatus.Authenticating;

const zappName = useSelector((state) => state.connectedZapp?.name);
const zappOrigin = useSelector((state) => state.zappOrigin);

return (
<AppContainer bg="primary">
<Spacer h={64} />
<TextCenter>
<H1>ZUPASS</H1>
<Spacer h={24} />
<TextCenter>
<ZappName>{zappName}</ZappName> ({zappOrigin}) would like to connect
to your Zupass.
</TextCenter>
<Spacer h={24} />
<Button onClick={openPopup} disabled={inProgress}>
<Spinner show={inProgress} text="Connect" />
</Button>
<Spacer h={24} />
{status === PopupAuthenticationStatus.PopupBlocked && (
<TextCenter>
Your browser may be configured to block popup windows. Please check
your browser settings and click the button above to try again.
</TextCenter>
)}
{status === PopupAuthenticationStatus.AuthenticationError && (
<TextCenter>
An unexpected error occurred. Please try again.
</TextCenter>
)}
{status === PopupAuthenticationStatus.PopupClosed && (
<TextCenter>
The popup window was closed before authentication could complete.
Please try again by clicking the button above.
</TextCenter>
)}
</TextCenter>
</AppContainer>
);
}

const ZappName = styled.span`
font-weight: bold;
`;
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
Loading
Loading