commit ba837abbed323aac4e35930caae38f2af2840ea0
parent 5c394478b2806aeaf109c0b0903e9fd3ba7c68fa
Author: Florian Dold <dold@taler.net>
Date: Wed, 23 Sep 2026 14:49:46 +0200
challenger web UI: send the user back to the client once verification failed
The client only learns about the failure through the error redirect
challenger hands out, so follow it instead of stranding the user.
Issue: https://bugs.taler.net/n/11740
Diffstat:
5 files changed, 189 insertions(+), 2 deletions(-)
diff --git a/packages/challenger-webui/src/challenger-flow.test.ts b/packages/challenger-webui/src/challenger-flow.test.ts
@@ -1,8 +1,9 @@
import assert from "node:assert/strict";
import test from "node:test";
-import { TalerFormAttributes } from "@gnu-taler/taler-util";
+import { HttpStatusCode, TalerFormAttributes } from "@gnu-taler/taler-util";
import { getSession, safeToURL } from "./Routing.js";
import { getChallengeNextPage } from "./components/CheckChallengeIsUpToDate.js";
+import { getValidationFailedReturnURL } from "./components/ValidationFailed.js";
import { getSessionStateKey } from "./hooks/session.js";
import {
canSubmitDestination,
@@ -355,3 +356,39 @@ test("successful verification cancels its pending return", () => {
assert.deepEqual(cancelledTimers, [9]);
assert.equal(redirected, false);
});
+
+test("a failed validation sends the user back through the error redirect", () => {
+ const redirect =
+ "https://exchange.example/kyc-proof/p?error=access_denied&error_description=x&state=s";
+ assert.equal(
+ getValidationFailedReturnURL({
+ case: HttpStatusCode.Gone,
+ body: { code: 9772, redirect_url: redirect },
+ }),
+ redirect,
+ );
+ // Only a web URL is followed.
+ assert.equal(
+ getValidationFailedReturnURL({
+ case: HttpStatusCode.Gone,
+ body: { code: 9772, redirect_url: "javascript:alert(1)" },
+ }),
+ undefined,
+ );
+ // Without a redirect_url there is nobody to report to.
+ assert.equal(
+ getValidationFailedReturnURL({
+ case: HttpStatusCode.Gone,
+ body: { code: 9772 },
+ }),
+ undefined,
+ );
+ // Running out of one kind of attempt only is not a failed validation.
+ assert.equal(
+ getValidationFailedReturnURL({
+ case: HttpStatusCode.TooManyRequests,
+ body: { code: 9766, redirect_url: redirect },
+ }),
+ undefined,
+ );
+});
diff --git a/packages/challenger-webui/src/components/CheckChallengeIsUpToDate.tsx b/packages/challenger-webui/src/components/CheckChallengeIsUpToDate.tsx
@@ -17,6 +17,11 @@ import { useEffect, useRef } from "preact/hooks";
import { useChallengeSession } from "../hooks/challenge.js";
import { SessionId } from "../hooks/session.js";
import {
+ getValidationFailedReturnURL,
+ isValidationFailed,
+ ValidationFailed,
+} from "./ValidationFailed.js";
+import {
ActionButton,
formatErrorDetails,
formatHttpErrorDetails,
@@ -124,6 +129,11 @@ export function CheckChallengeIsUpToDate({
}
if (result.type === "fail") {
+ if (isValidationFailed(result)) {
+ return (
+ <ValidationFailed returnURL={getValidationFailedReturnURL(result)} />
+ );
+ }
const retryable = result.case === HttpStatusCode.InternalServerError;
// Translators: Error heading for a verification session that has expired or
// was already removed by Challenger.
diff --git a/packages/challenger-webui/src/components/ValidationFailed.tsx b/packages/challenger-webui/src/components/ValidationFailed.tsx
@@ -0,0 +1,100 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+*/
+import { ChallengerApi, HttpStatusCode } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { VNode, h } from "preact";
+import { useEffect, useState } from "preact/hooks";
+import {
+ safeRedirectURL,
+ scheduleAutomaticReturn,
+} from "../pages/CallengeCompleted.js";
+import {
+ InlineNotice,
+ PrimaryActionLink,
+ VerificationCard,
+} from "./VerificationUi.js";
+
+/**
+ * Challenger answers 410 once the user has used up every address change,
+ * code delivery and code entry attempt. The verification can then never
+ * succeed, and the application that requested it only learns about the
+ * failure when the user is sent back to it through the error redirect the
+ * response carries.
+ *
+ * @returns the URL to send the user to if @a failure is such an answer
+ */
+export function getValidationFailedReturnURL(failure: {
+ case: HttpStatusCode;
+ body?: unknown;
+}): string | undefined {
+ if (failure.case !== HttpStatusCode.Gone) return undefined;
+ const body = failure.body as
+ | ChallengerApi.ValidationFailedResponse
+ | undefined;
+ return safeRedirectURL(body?.redirect_url);
+}
+
+export function isValidationFailed(failure: { case: HttpStatusCode }): boolean {
+ return failure.case === HttpStatusCode.Gone;
+}
+
+export function ValidationFailed({
+ returnURL,
+}: {
+ returnURL: string | undefined;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const [secondsRemaining, setSecondsRemaining] = useState<number>();
+ const destination = returnURL ? new URL(returnURL).host : undefined;
+ // Translators: Page title after the user used up every attempt to verify
+ // their email address, phone number or postal address.
+ const failedTitle = i18n.str`Verification failed`;
+ // Translators: Heading of the notice explaining that the verification can
+ // no longer succeed.
+ const noAttemptsTitle = i18n.str`No attempts remain`;
+ // Translators: Explanation after the user used up every attempt; the
+ // application that requested the verification decides what happens next.
+ const failedDescription = i18n.str`All attempts to verify this address have been used. Return to the application to continue.`;
+ // Translators: %1$s is the host name of the application that requested
+ // verification.
+ const returnLabel = i18n.str`Return to ${destination ?? ""}`;
+ // Translators: %1$s is the live number of seconds before the browser returns
+ // to the application that requested verification.
+ const redirectCountdown = i18n.str`Redirecting in ${secondsRemaining ?? ""} seconds…`;
+
+ useEffect(() => {
+ if (!returnURL) return;
+ return scheduleAutomaticReturn(
+ returnURL,
+ (url) => window.location.replace(url),
+ setSecondsRemaining,
+ );
+ }, [returnURL]);
+
+ return (
+ <VerificationCard title={failedTitle}>
+ <InlineNotice tone="error" title={noAttemptsTitle}>
+ {failedDescription}
+ </InlineNotice>
+ {returnURL ? (
+ <div class="mt-5 space-y-3">
+ <PrimaryActionLink href={returnURL}>{returnLabel}</PrimaryActionLink>
+ {secondsRemaining !== undefined ? (
+ <p
+ class="text-sm text-secondary dark:text-darkSecondary"
+ aria-live="polite"
+ >
+ {redirectCountdown}
+ </p>
+ ) : undefined}
+ </div>
+ ) : undefined}
+ </VerificationCard>
+ );
+}
diff --git a/packages/challenger-webui/src/pages/AnswerChallenge.tsx b/packages/challenger-webui/src/pages/AnswerChallenge.tsx
@@ -42,6 +42,11 @@ import {
VerificationCard,
} from "../components/VerificationUi.js";
import { challengeFailureMessage } from "./AskChallenge.js";
+import {
+ getValidationFailedReturnURL,
+ isValidationFailed,
+ ValidationFailed,
+} from "../components/ValidationFailed.js";
type Props = {
focus?: boolean;
@@ -169,6 +174,11 @@ export function AnswerChallenge({
return;
}
setNewCodeSent(false);
+ if (isValidationFailed(operationResult)) {
+ // The reloaded session reports the failure, too, and shows it.
+ void revalidateChallengeSession();
+ return;
+ }
setDeliveryError(challengeFailureMessage(i18n, operationResult));
setDeliveryErrorDetails(formatHttpErrorDetails(operationResult));
},
@@ -210,6 +220,13 @@ export function AnswerChallenge({
void revalidateChallengeSession();
return;
}
+ if (isValidationFailed(operationResult)) {
+ // That was the last chance. The reloaded session reports the
+ // failure, too, and shows it.
+ setPin("");
+ void revalidateChallengeSession();
+ return;
+ }
setCodeError(solveFailureMessage(i18n, operationResult.case));
setCodeErrorDetails(formatHttpErrorDetails(operationResult));
},
@@ -259,6 +276,11 @@ export function AnswerChallenge({
);
}
if (result.type === "fail") {
+ if (isValidationFailed(result)) {
+ return (
+ <ValidationFailed returnURL={getValidationFailedReturnURL(result)} />
+ );
+ }
const technicalDetails = formatHttpErrorDetails(result);
return (
<VerificationCard title={i18n.str`Could not load verification details`}>
diff --git a/packages/challenger-webui/src/pages/AskChallenge.tsx b/packages/challenger-webui/src/pages/AskChallenge.tsx
@@ -46,7 +46,15 @@ import {
TechnicalDetails,
VerificationCard,
} from "../components/VerificationUi.js";
-import { useChallengeSession } from "../hooks/challenge.js";
+import {
+ revalidateChallengeSession,
+ useChallengeSession,
+} from "../hooks/challenge.js";
+import {
+ getValidationFailedReturnURL,
+ isValidationFailed,
+ ValidationFailed,
+} from "../components/ValidationFailed.js";
import { SessionId, useSessionState } from "../hooks/session.js";
import { getAddressDescriptionFromAddrType } from "./AnswerChallenge.js";
@@ -181,6 +189,11 @@ export function AskChallenge(props: Props): VNode {
);
}
if (result.type === "fail") {
+ if (isValidationFailed(result)) {
+ return (
+ <ValidationFailed returnURL={getValidationFailedReturnURL(result)} />
+ );
+ }
// Translators: Page title when Challenger rejects the request to load the
// address-entry step of an existing verification.
const detailsFailureTitle = i18n.str`Could not load verification details`;
@@ -313,6 +326,11 @@ function AskChallengeInternal({
}
return;
}
+ if (isValidationFailed(result)) {
+ // The reloaded session reports the failure, too, and shows it.
+ void revalidateChallengeSession();
+ return;
+ }
setActionError(challengeFailureMessage(i18n, result));
setActionErrorDetails(formatHttpErrorDetails(result));
},