From 6f4bb6bf95268cd619731bdc097a652d58fc8557 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 12 Jun 2024 15:37:38 +0530 Subject: [PATCH 1/5] Update README --- web/apps/accounts/README.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) 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). From 6aa810b50083671d755c7af309d47795496d163b Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 12 Jun 2024 16:25:07 +0530 Subject: [PATCH 2/5] [web] Redirect again button --- .../accounts/src/pages/passkeys/verify.tsx | 79 +++++++++++++++++-- web/apps/accounts/src/services/passkey.ts | 13 ++- 2 files changed, 78 insertions(+), 14 deletions(-) diff --git a/web/apps/accounts/src/pages/passkeys/verify.tsx b/web/apps/accounts/src/pages/passkeys/verify.tsx index 013eaceb05..010c3bbfdf 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,18 @@ const Page = () => { | "unknownRedirect" /* Unrecoverable error */ | "unrecoverableFailure" /* Unrocevorable error - generic */ | "failed" /* Recoverable error */ - | "waitingForUser"; /* ...to authenticate with their passkey */ + | "waitingForUser" /* ...to authenticate with their passkey */ + | "redirecting"; /* The user back to the calling app */ 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 +116,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("redirecting"); - await redirectAfterPasskeyAuthentication( - redirectURL, - authorizationResponse, + setRedirectURLWithData( + await redirectURLWithPasskeyAuthentication( + redirectURL, + authorizationResponse, + ), ); }; @@ -120,6 +130,10 @@ const Page = () => { void authenticate(); }, []); + useEffect(() => { + if (redirectURLWithData) redirectToURL(redirectURLWithData); + }, [redirectURLWithData]); + const handleRetry = () => void authenticate(); const handleRecover = () => { @@ -133,6 +147,9 @@ const Page = () => { redirectToPasskeyRecoverPage(new URL(recover)); }; + const handleRedirectAgain = () => + redirectToURL(ensure(redirectURLWithData)); + const components: Record = { loading: , unknownRedirect: , @@ -142,6 +159,7 @@ const Page = () => { ), waitingForUser: , + redirecting: , }; return components[status]; @@ -149,6 +167,11 @@ const Page = () => { export default Page; +const redirectToURL = (url: URL) => { + log.info(`Redirecting to ${url.href}`); + window.location.href = url.href; +}; + const Loading: React.FC = () => { return ( @@ -286,3 +309,45 @@ const WaitingImgContainer = styled("div")` justify-content: center; margin-block-start: 1rem; `; + +interface RedirectingProps { + /** Called when the user presses the button to redirect again */ + onRetry: () => void; +} + +const Redirecting: React.FC = ({ onRetry }) => { + const handleClose = window.close; + + return ( + + + Passkey verified + + Redirecting you back to the app... + + + You can close this window after the app opens. + + + + {t("CLOSE")} + + + 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; }; /** From 0bde1ab22dc385cb7a6ef1a17dbb9fff2aa2f662 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 12 Jun 2024 16:35:55 +0530 Subject: [PATCH 3/5] L11n keys --- web/apps/accounts/src/pages/passkeys/verify.tsx | 8 ++++---- web/packages/next/locales/en-US/translation.json | 4 ++++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/web/apps/accounts/src/pages/passkeys/verify.tsx b/web/apps/accounts/src/pages/passkeys/verify.tsx index 010c3bbfdf..c97a92e069 100644 --- a/web/apps/accounts/src/pages/passkeys/verify.tsx +++ b/web/apps/accounts/src/pages/passkeys/verify.tsx @@ -321,12 +321,12 @@ const Redirecting: React.FC = ({ onRetry }) => { return ( - Passkey verified + {t("passkey_verified")} - Redirecting you back to the app... + {t("redirecting_back_to_app")} - You can close this window after the app opens. + {t("redirect_close_instructions")} = ({ onRetry }) => { type="button" variant="text" > - Redirect again + {t("redirect_again")} 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" } From 523317eb71cb3399a55871d9d288ae4f812c6b51 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 12 Jun 2024 16:43:49 +0530 Subject: [PATCH 4/5] Separate handling for web / app --- .../accounts/src/pages/passkeys/verify.tsx | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/web/apps/accounts/src/pages/passkeys/verify.tsx b/web/apps/accounts/src/pages/passkeys/verify.tsx index c97a92e069..249a3051bb 100644 --- a/web/apps/accounts/src/pages/passkeys/verify.tsx +++ b/web/apps/accounts/src/pages/passkeys/verify.tsx @@ -32,13 +32,14 @@ const Page = () => { | "unrecoverableFailure" /* Unrocevorable error - generic */ | "failed" /* Recoverable error */ | "waitingForUser" /* ...to authenticate with their passkey */ - | "redirecting"; /* The user back to the calling app */ + | "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". + // This will only be set when status is "redirecting*". const [redirectURLWithData, setRedirectURLWithData] = useState< URL | undefined >(); @@ -116,7 +117,7 @@ const Page = () => { return; } - setStatus("redirecting"); + setStatus(isHTTP(redirectURL) ? "redirectingWeb" : "redirectingApp"); setRedirectURLWithData( await redirectURLWithPasskeyAuthentication( @@ -159,7 +160,8 @@ const Page = () => { ), waitingForUser: , - redirecting: , + redirectingWeb: , + redirectingApp: , }; return components[status]; @@ -167,6 +169,8 @@ const Page = () => { export default Page; +const isHTTP = (url: URL) => url.protocol == "http" || url.protocol == "https"; + const redirectToURL = (url: URL) => { log.info(`Redirecting to ${url.href}`); window.location.href = url.href; @@ -310,12 +314,24 @@ const WaitingImgContainer = styled("div")` margin-block-start: 1rem; `; -interface RedirectingProps { +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 Redirecting: React.FC = ({ onRetry }) => { +const RedirectingApp: React.FC = ({ onRetry }) => { const handleClose = window.close; return ( From 622c4e7258f9c70ece522954e7f5c1a6cfe6feb8 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Wed, 12 Jun 2024 16:50:45 +0530 Subject: [PATCH 5/5] Fix the check (protocol includes colon) --- web/apps/accounts/src/pages/passkeys/verify.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/apps/accounts/src/pages/passkeys/verify.tsx b/web/apps/accounts/src/pages/passkeys/verify.tsx index 249a3051bb..8cf1990082 100644 --- a/web/apps/accounts/src/pages/passkeys/verify.tsx +++ b/web/apps/accounts/src/pages/passkeys/verify.tsx @@ -169,7 +169,8 @@ const Page = () => { export default Page; -const isHTTP = (url: URL) => url.protocol == "http" || url.protocol == "https"; +// 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}`);