diff --git a/web/apps/accounts/src/pages/passkeys/verify.tsx b/web/apps/accounts/src/pages/passkeys/verify.tsx index 29b20b2384..c9ea3289d9 100644 --- a/web/apps/accounts/src/pages/passkeys/verify.tsx +++ b/web/apps/accounts/src/pages/passkeys/verify.tsx @@ -16,6 +16,7 @@ import { isWebAuthnSupported, isWhitelistedRedirect, passkeyAuthenticationSuccessRedirectURL, + passkeySessionAlreadyClaimedErrorMessage, redirectToPasskeyRecoverPage, signChallenge, type BeginPasskeyAuthenticationResponse, @@ -31,6 +32,7 @@ const Page = () => { | "loading" /* Can happen multiple times in the flow */ | "webAuthnNotSupported" /* Unrecoverable error */ | "unknownRedirect" /* Unrecoverable error */ + | "sessionAlreadyClaimed" /* Unrecoverable error */ | "unrecoverableFailure" /* Unrecoverable error - generic */ | "failedDuringSignChallenge" /* Recoverable error in signChallenge */ | "failed" /* Recoverable error otherwise */ @@ -117,7 +119,12 @@ const Page = () => { beginResponse = await beginPasskeyAuthentication(passkeySessionID); } catch (e) { log.error("Failed to begin passkey authentication", e); - setStatus("failed"); + setStatus( + e instanceof Error && + e.message == passkeySessionAlreadyClaimedErrorMessage + ? "sessionAlreadyClaimed" + : "failed", + ); return; } @@ -231,6 +238,7 @@ const Page = () => { loading: , unknownRedirect: , webAuthnNotSupported: , + sessionAlreadyClaimed: , unrecoverableFailure: , failedDuringSignChallenge: ( { return ; }; +const SessionAlreadyClaimed: React.FC = () => { + return ( + + + + + {t("passkey_login_already_claimed_session")} + + + + ); +}; + +const SessionAlreadyClaimed_ = styled("div")` + display: flex; + flex-direction: column; + align-items: center; + gap: 2rem; +`; + const UnrecoverableFailure: React.FC = () => { return ; }; diff --git a/web/apps/accounts/src/services/passkey.ts b/web/apps/accounts/src/services/passkey.ts index 0fda0c1e00..23dd6a44a6 100644 --- a/web/apps/accounts/src/services/passkey.ts +++ b/web/apps/accounts/src/services/passkey.ts @@ -368,6 +368,13 @@ export interface BeginPasskeyAuthenticationResponse { }; } +/** + * The passkey session which we are trying to start an authentication ceremony + * for has already finished elsewhere. + */ +export const passkeySessionAlreadyClaimedErrorMessage = + "Passkey session already claimed"; + /** * Create a authentication ceremony session and return a challenge and a list of * public key credentials that can be used to attest that challenge. @@ -379,6 +386,9 @@ export interface BeginPasskeyAuthenticationResponse { * * @param passkeySessionID A session created by the requesting app that can be * used to initiate a passkey authentication ceremony on the accounts app. + * + * @throws In addition to arbitrary errors, it throws errors with the message + * {@link passkeySessionAlreadyClaimedErrorMessage}. */ export const beginPasskeyAuthentication = async ( passkeySessionID: string, @@ -388,7 +398,11 @@ export const beginPasskeyAuthentication = async ( method: "POST", body: JSON.stringify({ sessionID: passkeySessionID }), }); - if (!res.ok) throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`); + if (!res.ok) { + if (res.status == 409) + throw new Error(passkeySessionAlreadyClaimedErrorMessage); + throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`); + } // See: [Note: Converting binary data in WebAuthn API payloads] diff --git a/web/packages/accounts/services/passkey.ts b/web/packages/accounts/services/passkey.ts index 70cd3ae0fa..f16ceeaae9 100644 --- a/web/packages/accounts/services/passkey.ts +++ b/web/packages/accounts/services/passkey.ts @@ -209,7 +209,7 @@ const getAccountsToken = async () => { * The passkey session whose status we are trying to check has already expired. * The user should attempt to login again. */ -export const passKeySessionExpiredErrorMessage = "Passkey session has expired"; +export const passkeySessionExpiredErrorMessage = "Passkey session has expired"; /** * Check if the user has already authenticated using their passkey for the given @@ -229,7 +229,7 @@ export const passKeySessionExpiredErrorMessage = "Passkey session has expired"; * authentication has completed, and `undefined` otherwise. * * @throws In addition to arbitrary errors, it throws errors with the message - * {@link passKeySessionExpiredErrorMessage}. + * {@link passkeySessionExpiredErrorMessage}. */ export const checkPasskeyVerificationStatus = async ( sessionID: string, @@ -241,7 +241,7 @@ export const checkPasskeyVerificationStatus = async ( }); if (!res.ok) { if (res.status == 404 || res.status == 410) - throw new Error(passKeySessionExpiredErrorMessage); + throw new Error(passkeySessionExpiredErrorMessage); if (res.status == 400) return undefined; /* verification pending */ throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`); } diff --git a/web/packages/next/locales/en-US/translation.json b/web/packages/next/locales/en-US/translation.json index 51b63abb2b..c5508e2424 100644 --- a/web/packages/next/locales/en-US/translation.json +++ b/web/packages/next/locales/en-US/translation.json @@ -622,6 +622,7 @@ "passkey_add_failed": "Could not add passkey", "passkey_login_failed": "Passkey login failed", "passkey_login_invalid_url": "The login URL is invalid.", + "passkey_login_already_claimed_session": "This session has already been verified.", "passkey_login_generic_error": "An error occurred while logging in with passkey.", "passkey_login_credential_hint": "If your passkeys are on a different device, you can open this page on that device to verify.", "passkeys_not_supported": "Passkeys are not supported in this browser", diff --git a/web/packages/shared/components/LoginComponents.tsx b/web/packages/shared/components/LoginComponents.tsx index 950c6dc4cf..afe12a690d 100644 --- a/web/packages/shared/components/LoginComponents.tsx +++ b/web/packages/shared/components/LoginComponents.tsx @@ -3,7 +3,7 @@ import log from "@/next/log"; import type { BaseAppContextT } from "@/next/types/app"; import { checkPasskeyVerificationStatus, - passKeySessionExpiredErrorMessage, + passkeySessionExpiredErrorMessage, saveCredentialsAndNavigateTo, } from "@ente/accounts/services/passkey"; import EnteButton from "@ente/shared/components/EnteButton"; @@ -108,7 +108,7 @@ export const VerifyingPasskey: React.FC = ({ log.error("Passkey verification status check failed", e); setDialogBoxAttributesV2( e instanceof Error && - e.message == passKeySessionExpiredErrorMessage + e.message == passkeySessionExpiredErrorMessage ? sessionExpiredDialogAttributes(logout) : genericErrorAttributes(), );