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(),
);