Skip to content

Commit

Permalink
Iframe login via popup (#1905)
Browse files Browse the repository at this point in the history
This allows the user to log in via a popup window opened from within the
Zupass embedded iframe.

On visiting a Zapp for the first time, the user is asked to connect to
Zupass by clicking a button, which causes a popup window to open.

If the user is already logged in to Zupass in another tab, then the
popup window will pick up the authentication state from that other
session, and forward the user's sync key back to the iframe, which will
use it to log in.

If the user is not already logged in to Zupass in another tab, then they
can provide their email and password in the popup window, and after
authenticating the sync key will be sent to the iframe.

Three failure cases are covered:

1) If the browser blocks popups, a message is shown to the user advising
them to change their browser settings and try again
2) If the user closes the popup window before authentication completes,
they are informed of this and asked to try again
3) If logging in using the sync key fails, the user is asked to try
again
  • Loading branch information
robknight authored Sep 27, 2024
1 parent 898afef commit 8062504
Show file tree
Hide file tree
Showing 16 changed files with 417 additions and 190 deletions.
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
205 changes: 35 additions & 170 deletions apps/passport-client/components/screens/LoginScreens/LoginScreen.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -11,7 +7,6 @@ import {
useEffect,
useState
} from "react";
import { UAParser } from "ua-parser-js";
import { appConfig } from "../../../src/appConfig";
import {
useDispatch,
Expand All @@ -23,13 +18,13 @@ import {
pendingRequestKeys,
setPendingAddRequest,
setPendingAddSubscriptionRequest,
setPendingAuthenticateIFrameRequest,
setPendingGenericIssuanceCheckinRequest,
setPendingGetWithoutProvingRequest,
setPendingProofRequest,
setPendingViewFrogCryptoRequest,
setPendingViewSubscriptionsRequest
} from "../../../src/sessionStorage";
import { useSelector } from "../../../src/subscribe";
import {
BigInput,
Button,
Expand All @@ -43,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<string | undefined>();
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
Expand All @@ -78,6 +62,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 +92,9 @@ export function LoginScreen(): JSX.Element {
pendingGenericIssuanceCheckinRequest
);
pendingRequestForLogging = pendingRequestKeys.genericIssuanceCheckin;
} else if (pendingAuthenticateIFrameRequest) {
setPendingAuthenticateIFrameRequest(pendingAuthenticateIFrameRequest);
pendingRequestForLogging = pendingRequestKeys.authenticateIFrame;
}

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

const suggestedEmail = query?.get("email");
Expand All @@ -143,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<void> => {
// 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) {
Expand Down Expand Up @@ -281,61 +175,32 @@ export function LoginScreen(): JSX.Element {
</>
)}

{storageAccessStatus === StorageAccessStatus.CanRequest && (
<TextCenter>
<Spacer h={24} />
Do you want to allow <em>{connectedZapp?.name}</em> ({zappOrigin}) to
connect to Zupass?
<Spacer h={24} />
<Button onClick={requestStorageAndLogIn}>
Connect to Zupass
</Button>{" "}
<Spacer h={24} />
<TextButton
onClick={() => setStorageAccessStatus(StorageAccessStatus.Denied)}
>
Log in manually
</TextButton>
</TextCenter>
)}

{(storageAccessStatus === StorageAccessStatus.Requesting ||
storageAccessStatus === StorageAccessStatus.Granted) && (
<TextCenter>
{!state.loggingOut && (
<>
<Spacer h={24} />
<RippleLoader />
</TextCenter>
<CenterColumn>
<form onSubmit={onGenPass}>
<BigInput
autoCapitalize="off"
autoCorrect="off"
type="text"
autoFocus
placeholder="email address"
value={email}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setEmail(e.target.value)
}
/>
<InlineError error={error} />
<Spacer h={8} />
<Button style="primary" type="submit">
Continue
</Button>
</form>
</CenterColumn>
<Spacer h={64} />
</>
)}

{(storageAccessStatus === StorageAccessStatus.None ||
storageAccessStatus === StorageAccessStatus.Denied ||
storageAccessStatus === StorageAccessStatus.NoLocalStorage) &&
!state.loggingOut && (
<>
<Spacer h={24} />
<CenterColumn>
<form onSubmit={onGenPass}>
<BigInput
autoCapitalize="off"
autoCorrect="off"
type="text"
autoFocus
placeholder="email address"
value={email}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setEmail(e.target.value)
}
/>
<InlineError error={error} />
<Spacer h={8} />
<Button style="primary" type="submit">
Continue
</Button>
</form>
</CenterColumn>
<Spacer h={64} />
</>
)}
</AppContainer>
);
}
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>
);
}
Loading

0 comments on commit 8062504

Please sign in to comment.