diff --git a/web/apps/accounts/README.md b/web/apps/accounts/README.md index 51edb152e9..82f087f2d4 100644 --- a/web/apps/accounts/README.md +++ b/web/apps/accounts/README.md @@ -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). diff --git a/web/apps/accounts/src/pages/passkeys/verify.tsx b/web/apps/accounts/src/pages/passkeys/verify.tsx index 013eaceb05..8cf1990082 100644 --- a/web/apps/accounts/src/pages/passkeys/verify.tsx +++ b/web/apps/accounts/src/pages/passkeys/verify.tsx @@ -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("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 = { loading: , unknownRedirect: , @@ -142,6 +160,8 @@ const Page = () => { ), waitingForUser: , + redirectingWeb: , + redirectingApp: , }; 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 ( @@ -286,3 +314,57 @@ const WaitingImgContainer = styled("div")` justify-content: center; margin-block-start: 1rem; `; + +const RedirectingWeb: React.FC = () => { + return ( + + + {t("passkey_verified")} + + {t("redirecting_back_to_app")} + + + ); +}; + +interface RedirectingAppProps { + /** Called when the user presses the button to redirect again */ + onRetry: () => void; +} + +const RedirectingApp: React.FC = ({ onRetry }) => { + const handleClose = window.close; + + return ( + + + {t("passkey_verified")} + + {t("redirecting_back_to_app")} + + + {t("redirect_close_instructions")} + + + + {t("CLOSE")} + + + {t("redirect_again")} + + + + ); +}; diff --git a/web/apps/accounts/src/services/passkey.ts b/web/apps/accounts/src/services/passkey.ts index 03cbd1eff8..600f5a4be1 100644 --- a/web/apps/accounts/src/services/passkey.ts +++ b/web/apps/accounts/src/services/passkey.ts @@ -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; }; /** diff --git a/web/packages/next/locales/en-US/translation.json b/web/packages/next/locales/en-US/translation.json index d218c94ec4..f668e8c01b 100644 --- a/web/packages/next/locales/en-US/translation.json +++ b/web/packages/next/locales/en-US/translation.json @@ -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" }