commit 5c394478b2806aeaf109c0b0903e9fd3ba7c68fa
parent df872794aaee66742b5c5a526de16078a316b7f6
Author: Florian Dold <dold@taler.net>
Date: Wed, 23 Sep 2026 14:49:38 +0200
util: handle challenger's 410 for a permanently failed validation
Challenger v10 reports a validation the user can no longer pass as 410
with the error redirect to the client, which used to be an unknown
failure.
Issue: https://bugs.taler.net/n/11740
Diffstat:
3 files changed, 68 insertions(+), 0 deletions(-)
diff --git a/packages/taler-util/src/http-client/challenger.test.ts b/packages/taler-util/src/http-client/challenger.test.ts
@@ -152,3 +152,29 @@ test("solve decodes recoverable 409 and 429 responses", async () => {
assert.deepStrictEqual(result.body, { ...pending, ec: undefined });
}
});
+
+test("a permanently failed validation is a known 410 failure everywhere", async () => {
+ const failed = {
+ code: 9772,
+ hint: "The validation failed permanently.",
+ redirect_url:
+ "https://client.example.com/cb?error=access_denied&error_description=x&state=s",
+ };
+ const calls: Array<(c: ChallengerHttpClient) => Promise<any>> = [
+ (c) => c.login("nonce", "1", "https://client.example.com/cb", "s"),
+ (c) => c.challenge("nonce", { CONTACT_EMAIL: "alice@example.com" }),
+ (c) => c.solve("nonce", { pin: "1234" }),
+ ];
+ for (const call of calls) {
+ const { lib } = fixedLib(HttpStatusCode.Gone, {}, failed);
+ const client = new ChallengerHttpClient(
+ "https://challenger.example.com/",
+ lib,
+ );
+ const res = await call(client);
+ assert.ok(isOperationFail(res), "410 must be a known failure");
+ assert.strictEqual(res.case, HttpStatusCode.Gone);
+ assert.strictEqual((res as any).body.code, 9772);
+ assert.strictEqual((res as any).body.redirect_url, failed.redirect_url);
+ }
+});
diff --git a/packages/taler-util/src/http-client/challenger.ts b/packages/taler-util/src/http-client/challenger.ts
@@ -39,6 +39,7 @@ import {
codecForChallengeSetupResponse,
codecForChallengeSolveResponse,
codecForChallengeStatus,
+ codecForChallengeValidationFailedResponse,
codecForChallengerAuthResponse,
codecForChallengerInfoResponse,
codecForChallengerTermsOfServiceResponse,
@@ -156,6 +157,12 @@ export class ChallengerHttpClient {
return opKnownHttpFailure(resp.status, resp);
case HttpStatusCode.NotAcceptable:
return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Gone:
+ return opKnownAlternativeHttpFailure(
+ resp,
+ HttpStatusCode.Gone,
+ codecForChallengeValidationFailedResponse(),
+ );
case HttpStatusCode.TooManyRequests:
return opKnownHttpFailure(resp.status, resp);
case HttpStatusCode.InternalServerError:
@@ -193,6 +200,12 @@ export class ChallengerHttpClient {
return opKnownHttpFailure(resp.status, resp);
case HttpStatusCode.NotAcceptable:
return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Gone:
+ return opKnownAlternativeHttpFailure(
+ resp,
+ HttpStatusCode.Gone,
+ codecForChallengeValidationFailedResponse(),
+ );
case HttpStatusCode.TooManyRequests:
return opKnownHttpFailure(resp.status, resp);
case HttpStatusCode.PayloadTooLarge:
@@ -262,6 +275,12 @@ export class ChallengerHttpClient {
return opKnownHttpFailure(resp.status, resp);
case HttpStatusCode.NotAcceptable:
return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.Gone:
+ return opKnownAlternativeHttpFailure(
+ resp,
+ HttpStatusCode.Gone,
+ codecForChallengeValidationFailedResponse(),
+ );
case HttpStatusCode.TooManyRequests:
return opKnownAlternativeHttpFailure(
resp,
diff --git a/packages/taler-util/src/types-taler-challenger.ts b/packages/taler-util/src/types-taler-challenger.ts
@@ -186,6 +186,21 @@ export interface InvalidPinResponse {
no_challenge: boolean;
}
+// Returned with 410 Gone once the validation failed permanently: the user
+// exhausted every address change, TAN transmission and TAN attempt.
+export interface ValidationFailedResponse {
+ // TALER_EC_CHALLENGER_VALIDATION_FAILED
+ code: number;
+
+ // human-readable description of the error
+ hint?: string;
+
+ // RFC 6749 "access_denied" error response to the client; the user agent
+ // must send the user there so that the client learns about the failure.
+ // Missing if the client never told us where to redirect to.
+ redirect_url?: string;
+}
+
export interface ChallengerAuthResponse {
// Token used to authenticate access in /info.
access_token: string;
@@ -299,6 +314,14 @@ export const codecForChallengeSolveResponse =
.alternative("pending", codecForChallengeInvalidPinResponse())
.build("ChallengerApi.ChallengeSolveResponse");
+export const codecForChallengeValidationFailedResponse =
+ (): Codec<ValidationFailedResponse> =>
+ buildCodecForObject<ValidationFailedResponse>()
+ .property("code", codecForNumber())
+ .property("hint", codecOptional(codecForString()))
+ .property("redirect_url", codecOptional(codecForString()))
+ .build("ChallengerApi.ValidationFailedResponse");
+
export const codecForChallengerAuthResponse =
(): Codec<ChallengerAuthResponse> =>
buildCodecForObject<ChallengerAuthResponse>()