[web] Passkey - Show a redirect again option on passkeys (#2113)

This commit is contained in:
Manav Rathi
2024-06-12 16:54:27 +05:30
committed by GitHub
4 changed files with 108 additions and 19 deletions

View File

@@ -2,11 +2,15 @@
Code that runs on `accounts.ente.io`.
Primarily, this serves a common domain where our clients (mobile and web / auth
and photos) can create and authenticate using shared passkeys tied to the user's
Ente account. Passkeys can be shared by multiple domains, so we didn't strictly
need a separate web origin for sharing passkeys across our web clients, but we
do need a web origin to handle the passkey flow for the mobile clients.
Primarily, this serves a common domain where our clients can create and
authenticate using shared passkeys tied to the user's Ente account.
> [!NOTE]
>
> Passkeys can be shared by multiple subdomains, so we didn't strictly need a
> separate web origin for sharing passkeys between our (photos and auth) web
> clients, but we do need a web origin to handle the passkey flow for the
> desktop and mobile clients.
For more details about the Passkey flows,
[docs/webauthn-passkeys.md](../../docs/webauthn-passkeys.md).

View File

@@ -1,5 +1,6 @@
import log from "@/next/log";
import type { TwoFactorAuthorizationResponse } from "@/next/types/credentials";
import { ensure } from "@/utils/ensure";
import { nullToUndefined } from "@/utils/transform";
import { VerticallyCentered } from "@ente/shared/components/Container";
import EnteButton from "@ente/shared/components/EnteButton";
@@ -13,8 +14,8 @@ import {
finishPasskeyAuthentication,
isWebAuthnSupported,
isWhitelistedRedirect,
redirectAfterPasskeyAuthentication,
redirectToPasskeyRecoverPage,
redirectURLWithPasskeyAuthentication,
signChallenge,
} from "services/passkey";
@@ -30,10 +31,19 @@ const Page = () => {
| "unknownRedirect" /* Unrecoverable error */
| "unrecoverableFailure" /* Unrocevorable error - generic */
| "failed" /* Recoverable error */
| "waitingForUser"; /* ...to authenticate with their passkey */
| "waitingForUser" /* ...to authenticate with their passkey */
| "redirectingWeb" /* Redirect back to the requesting app (HTTP) */
| "redirectingApp"; /* Other redirects (mobile / desktop redirect) */
const [status, setStatus] = useState<Status>("loading");
// The URL we're redirecting to on success.
//
// This will only be set when status is "redirecting*".
const [redirectURLWithData, setRedirectURLWithData] = useState<
URL | undefined
>();
/** (re)start the authentication flow */
const authenticate = async () => {
if (!isWebAuthnSupported()) {
@@ -107,12 +117,13 @@ const Page = () => {
return;
}
// Conceptually we can `setStatus("done")` at this point, but we'll
// leave this page anyway, so no need to tickle React.
setStatus(isHTTP(redirectURL) ? "redirectingWeb" : "redirectingApp");
await redirectAfterPasskeyAuthentication(
redirectURL,
authorizationResponse,
setRedirectURLWithData(
await redirectURLWithPasskeyAuthentication(
redirectURL,
authorizationResponse,
),
);
};
@@ -120,6 +131,10 @@ const Page = () => {
void authenticate();
}, []);
useEffect(() => {
if (redirectURLWithData) redirectToURL(redirectURLWithData);
}, [redirectURLWithData]);
const handleRetry = () => void authenticate();
const handleRecover = () => {
@@ -133,6 +148,9 @@ const Page = () => {
redirectToPasskeyRecoverPage(new URL(recover));
};
const handleRedirectAgain = () =>
redirectToURL(ensure(redirectURLWithData));
const components: Record<Status, React.ReactNode> = {
loading: <Loading />,
unknownRedirect: <UnknownRedirect />,
@@ -142,6 +160,8 @@ const Page = () => {
<RetriableFailed onRetry={handleRetry} onRecover={handleRecover} />
),
waitingForUser: <WaitingForUser />,
redirectingWeb: <RedirectingWeb />,
redirectingApp: <RedirectingApp onRetry={handleRedirectAgain} />,
};
return components[status];
@@ -149,6 +169,14 @@ const Page = () => {
export default Page;
// Not 100% accurate, but good enough for our purposes.
const isHTTP = (url: URL) => url.protocol.startsWith("http");
const redirectToURL = (url: URL) => {
log.info(`Redirecting to ${url.href}`);
window.location.href = url.href;
};
const Loading: React.FC = () => {
return (
<VerticallyCentered>
@@ -286,3 +314,57 @@ const WaitingImgContainer = styled("div")`
justify-content: center;
margin-block-start: 1rem;
`;
const RedirectingWeb: React.FC = () => {
return (
<Content>
<InfoIcon color="accent" fontSize="large" />
<Typography variant="h3">{t("passkey_verified")}</Typography>
<Typography color="text.muted">
{t("redirecting_back_to_app")}
</Typography>
</Content>
);
};
interface RedirectingAppProps {
/** Called when the user presses the button to redirect again */
onRetry: () => void;
}
const RedirectingApp: React.FC<RedirectingAppProps> = ({ onRetry }) => {
const handleClose = window.close;
return (
<Content>
<InfoIcon color="accent" fontSize="large" />
<Typography variant="h3">{t("passkey_verified")}</Typography>
<Typography color="text.muted">
{t("redirecting_back_to_app")}
</Typography>
<Typography color="text.muted">
{t("redirect_close_instructions")}
</Typography>
<ButtonStack>
<EnteButton
onClick={handleClose}
fullWidth
color="secondary"
type="button"
variant="contained"
>
{t("CLOSE")}
</EnteButton>
<EnteButton
onClick={onRetry}
fullWidth
color="primary"
type="button"
variant="text"
>
{t("redirect_again")}
</EnteButton>
</ButtonStack>
</Content>
);
};

View File

@@ -521,25 +521,24 @@ const authenticatorAssertionResponse = (credential: Credential) => {
};
/**
* Redirect back to the calling app that initiated the passkey authentication
* flow with the result of the authentication.
* Create a redirection URL to get back to the calling app that initiated the
* passkey authentication flow with the result of the authentication.
*
* @param redirectURL The URL to redirect to. Provided by the calling app that
* initiated the passkey authentication.
* @param redirectURL The base URL to redirect to. Provided by the calling app
* that initiated the passkey authentication.
*
* @param twoFactorAuthorizationResponse The result of
* {@link finishPasskeyAuthentication} returned by the backend.
*/
export const redirectAfterPasskeyAuthentication = async (
export const redirectURLWithPasskeyAuthentication = async (
redirectURL: URL,
twoFactorAuthorizationResponse: TwoFactorAuthorizationResponse,
) => {
const encodedResponse = await toB64URLSafeNoPaddingString(
JSON.stringify(twoFactorAuthorizationResponse),
);
redirectURL.searchParams.set("response", encodedResponse);
window.location.href = redirectURL.href;
return redirectURL;
};
/**

View File

@@ -627,6 +627,10 @@
"TRY_AGAIN": "Try again",
"passkey_login_instructions": "Follow the steps from your browser to continue logging in.",
"passkey_login": "Login with passkey",
"passkey_verified": "Passkey verified",
"redirecting_back_to_app": "Redirecting you back to the app...",
"redirect_close_instructions": "You can close this window after the app opens.",
"redirect_again": "Redirect again",
"autogenerated_first_album_name": "My First Album",
"autogenerated_default_album_name": "New Album"
}