commit 4a7596b72735efef4b745018e51100a523dcf22b
parent a4f5256e98d70be2fa635ee5037750a1ce256598
Author: Florian Dold <dold@taler.net>
Date: Sat, 29 Aug 2026 13:15:49 +0200
challenger: add local validation tester
Diffstat:
11 files changed, 1785 insertions(+), 47 deletions(-)
diff --git a/Makefile b/Makefile
@@ -107,6 +107,7 @@ install:
$(MAKE) -C packages/libeufin-bank-webui install-nodeps
$(MAKE) -C packages/taler-merchant-webui install-nodeps
$(MAKE) -C packages/taler-auditor-webui install-nodeps
+ $(MAKE) -C packages/challenger-webui install-nodeps
$(MAKE) -C packages/taler-exchange-kyc-webui install-nodeps
$(MAKE) -C packages/taler-exchange-aml-webui install-nodeps
diff --git a/packages/challenger-webui/src/app.tsx b/packages/challenger-webui/src/app.tsx
@@ -61,6 +61,14 @@ const evictBankSwrCache: CacheEvictor<ChallengerCacheEviction> = {
};
export function App(): VNode {
+ return (
+ <TranslationProvider source={strings}>
+ <ConfiguredApp />
+ </TranslationProvider>
+ );
+}
+
+function ConfiguredApp(): VNode {
const [settings, setSettings] = useState<ChallengerUiSettings>();
useEffect(() => {
fetchSettings(setSettings);
@@ -70,51 +78,49 @@ export function App(): VNode {
const baseUrl = getInitialBackendBaseURL(settings.backendBaseURL);
return (
<SettingsProvider value={settings}>
- <TranslationProvider source={strings}>
- <NotificationProvider>
- <ChallengerApiProvider
- baseUrl={new URL(baseUrl)}
- frameOnError={Frame}
- evictors={{
- challenger: evictBankSwrCache,
+ <NotificationProvider>
+ <ChallengerApiProvider
+ baseUrl={new URL(baseUrl)}
+ frameOnError={Frame}
+ evictors={{
+ challenger: evictBankSwrCache,
+ }}
+ >
+ <SWRConfig
+ value={{
+ provider: WITH_LOCAL_STORAGE_CACHE
+ ? localStorageProvider
+ : undefined,
+ // normally, do not revalidate
+ revalidateOnFocus: false,
+ revalidateOnReconnect: true,
+ revalidateIfStale: false,
+ revalidateOnMount: undefined,
+ focusThrottleInterval: undefined,
+
+ // normally, do not refresh
+ refreshInterval: undefined,
+ dedupingInterval: 2000,
+ refreshWhenHidden: false,
+ refreshWhenOffline: false,
+
+ // ignore errors
+ shouldRetryOnError: false,
+ errorRetryCount: 0,
+ errorRetryInterval: undefined,
+
+ // do not go to loading again if already has data
+ keepPreviousData: false,
}}
>
- <SWRConfig
- value={{
- provider: WITH_LOCAL_STORAGE_CACHE
- ? localStorageProvider
- : undefined,
- // normally, do not revalidate
- revalidateOnFocus: false,
- revalidateOnReconnect: true,
- revalidateIfStale: false,
- revalidateOnMount: undefined,
- focusThrottleInterval: undefined,
-
- // normally, do not refresh
- refreshInterval: undefined,
- dedupingInterval: 2000,
- refreshWhenHidden: false,
- refreshWhenOffline: false,
-
- // ignore errors
- shouldRetryOnError: false,
- errorRetryCount: 0,
- errorRetryInterval: undefined,
-
- // do not go to loading again if already has data
- keepPreviousData: false,
- }}
- >
- <TalerWalletIntegrationBrowserProvider>
- <BrowserHashNavigationProvider>
- <Routing />
- </BrowserHashNavigationProvider>
- </TalerWalletIntegrationBrowserProvider>
- </SWRConfig>
- </ChallengerApiProvider>
- </NotificationProvider>
- </TranslationProvider>
+ <TalerWalletIntegrationBrowserProvider>
+ <BrowserHashNavigationProvider>
+ <Routing />
+ </BrowserHashNavigationProvider>
+ </TalerWalletIntegrationBrowserProvider>
+ </SWRConfig>
+ </ChallengerApiProvider>
+ </NotificationProvider>
</SettingsProvider>
);
}
diff --git a/packages/taler-harness/README-challenger-tester.md b/packages/taler-harness/README-challenger-tester.md
@@ -0,0 +1,140 @@
+# Testing a local Challenger service
+
+`taler-harness challenger-tester` runs a small, in-memory Web application for
+manually testing a configured Challenger service. It can start validations,
+follow their OAuth redirects, show their status, and receive challenge messages
+through `taler-harness challenger-tester-helper`.
+
+This setup is intended for local development. The tester does not authenticate
+requests to its challenge-message endpoint, so bind it only to a trusted
+interface and do not expose it to the Internet.
+
+## Prerequisites
+
+The following commands must be installed and available in `PATH`:
+
+- `taler-harness`, both for starting the tester and for Challenger's delivery
+ helper
+- `challenger-admin`, which the tester uses to register its temporary OAuth
+ client
+
+The Challenger database must be initialized, and the configured Challenger
+HTTP service must be running. The user running the tester also needs access to
+the Challenger configuration and database used by `challenger-admin`.
+
+## Configure Challenger
+
+For the default tester address, add the following to the `[challenger]` section
+of the Challenger configuration:
+
+```ini
+[challenger]
+AUTH_COMMAND = taler-harness challenger-tester-helper http://127.0.0.1:8080/
+```
+
+Restart Challenger after changing its configuration.
+
+Challenger appends the address as compact JSON to this command and provides the
+rendered challenge message on standard input. Do not add an address argument to
+`AUTH_COMMAND` yourself. If `taler-harness` is not in the service's `PATH`, use
+its absolute path instead.
+
+The tester URL must be reachable from both the Challenger helper process and
+the browser. `127.0.0.1` works when Challenger, the tester, and the browser all
+run on the same machine. For a container or remote Challenger, use a concrete
+hostname or IP address that all three can reach, for example:
+
+```ini
+AUTH_COMMAND = taler-harness challenger-tester-helper http://dev.example:9080/
+```
+
+The command configuration supports space-separated arguments but no quoting or
+escaping, so neither the executable path nor the tester URL may contain spaces.
+
+## Start the tester
+
+Run the tester with the same configuration file used by Challenger:
+
+```console
+$ taler-harness challenger-tester -c /path/to/challenger.conf
+http://127.0.0.1:8080/
+```
+
+The tester reads `[challenger]/BASE_URL`, fetches the Challenger configuration,
+and temporarily registers itself as an OAuth client using `challenger-admin`.
+It removes that registration when stopped normally.
+
+Open the printed URL in a browser. The page also shows the exact
+`AUTH_COMMAND` line corresponding to its listen address.
+
+To use a different address or port, pass the same values to the tester and to
+`AUTH_COMMAND`:
+
+```console
+$ taler-harness challenger-tester \
+ -c /path/to/challenger.conf \
+ --host dev.example \
+ --port 9080
+```
+
+`--host` must be a concrete hostname or IP address, not `0.0.0.0` or `::`,
+because it is also used in the OAuth callback URL.
+
+## Run a validation
+
+1. Open the tester URL in a browser.
+2. Optionally enter an address object to pass to Challenger's `/setup`
+ endpoint. Its fields must match the configured address type and
+ restrictions. For example:
+
+ ```json
+ {
+ "CONTACT_EMAIL": "alice@example.com",
+ "read_only": true
+ }
+ ```
+
+ Leave the field empty to enter the address in the Challenger Web UI.
+
+3. Select the result that the delivery helper should report. The dropdown
+ includes every defined Challenger helper exit code. The default, exit code
+ `0`, reports confirmed delivery. A non-zero result exercises Challenger's
+ address, provider, or helper error handling. The selection applies only to
+ the next delivered challenge and then resets to successful delivery.
+4. Select **Start address validation**. The flow opens in a new tab and
+ redirects that tab to Challenger, leaving the tester dashboard open.
+5. Submit or confirm the address in Challenger. Its delivery command invokes
+ `challenger-tester-helper`, which POSTs the address and message to the
+ tester and exits with the selected status. The tester records the message
+ even when the simulated delivery fails.
+6. For a successful delivery, return to the tester tab and copy the code from
+ **Delivered challenges**. The table also shows the full message, address,
+ and helper result. It recognizes plain eight-digit codes, `1234-5678`, and
+ `T-1234-5678`; if a custom template uses another format, copy the code from
+ the displayed message.
+7. Submit the code in Challenger. The callback exchanges the authorization
+ code, calls `/info`, and shows a compact success or failure page with a link
+ back to the tester dashboard. The dashboard separately updates the
+ validation status.
+
+All validations and delivered messages are kept only in memory and disappear
+when the tester exits. Stop it with `Ctrl-C` so it can unregister its temporary
+client cleanly.
+
+## Troubleshooting
+
+- **Challenger reports that the helper failed:** Verify that the Challenger
+ service can execute `taler-harness` and reach the tester URL. Running the
+ helper's URL from your login shell is not sufficient if Challenger runs in a
+ container or under a restricted service account.
+- **No challenge appears in the tester:** Check that Challenger was restarted
+ after changing `AUTH_COMMAND`, and that its configuration uses the same host
+ and port printed by the tester.
+- **The tester fails during startup:** Run `challenger-admin` with the same
+ `-c` file and check its database access. Also verify that
+ `[challenger]/BASE_URL` points to the running Challenger service.
+- **The OAuth flow cannot return to the tester:** The callback uses the value
+ supplied with `--host`. It must resolve to the tester from the browser and
+ must not be blocked by a firewall.
+- **Port 8080 is already in use:** Choose another fixed port with `--port` and
+ update `AUTH_COMMAND` to match it.
diff --git a/packages/taler-harness/README.md b/packages/taler-harness/README.md
@@ -4,6 +4,12 @@ This package implements the `taler-harness` CLI tool. It contains integration
tests for GNU Taler and GNU anastasis, as well as various helpers for managing
deployments of GNU Taler.
+## Challenger tester
+
+See [Testing a local Challenger service](README-challenger-tester.md) for how
+to configure Challenger, receive its challenge codes in the tester, and run a
+complete validation flow.
+
## Quickly expiring payments
Create a payment that expires after one minute with:
diff --git a/packages/taler-harness/package.json b/packages/taler-harness/package.json
@@ -19,7 +19,7 @@
"build": "tsc && ./build.mjs",
"build:with-deps": "pnpm --filter \"{.}...\" run build",
"check": "tsc",
- "test": "tsc && node --test 'lib/**/*.test.js'",
+ "test": "tsc && node --test 'lib/*.test.js' 'lib/**/*.test.js'",
"typedoc": "pnpm dlx typedoc --out dist/typedoc ./src/",
"clean": "rm -rf lib dist tsconfig.tsbuildinfo",
"pretty": "prettier --write src"
@@ -27,6 +27,7 @@
"files": [
"AUTHORS",
"README",
+ "README-challenger-tester.md",
"COPYING",
"bin/",
"dist/node",
@@ -42,6 +43,7 @@
"@gnu-taler/taler-util": "workspace:*",
"@gnu-taler/taler-wallet-core": "workspace:*",
"@types/selenium-webdriver": "4.35.5",
+ "h3": "^1.15.0",
"playwright-core": "^1.62.0",
"postgres": "^3.4.5",
"selenium-webdriver": "4.40.0",
diff --git a/packages/taler-harness/src/challenger-tester.test.ts b/packages/taler-harness/src/challenger-tester.test.ts
@@ -0,0 +1,510 @@
+/*
+ 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.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import assert from "node:assert";
+import {
+ createServer,
+ type IncomingMessage,
+ type ServerResponse,
+} from "node:http";
+import { test } from "node:test";
+import { URLSearchParams as TalerUrlSearchParams } from "@gnu-taler/taler-util";
+import { createPlatformHttpLib } from "@gnu-taler/taler-util/http";
+import {
+ createChallengerAdminRegistrar,
+ formatChallengerTesterCommandError,
+ runChallengerTesterHelper,
+ startChallengerTester,
+ type ChallengerClientRegistrar,
+} from "./challenger-tester.js";
+
+const CHALLENGER_ACCESS_TOKEN = "0".repeat(52);
+const DEFINED_HELPER_EXIT_CODES = [
+ 0, 201, 202, 10, 11, 12, 13, 14, 15, 20, 21, 22, 30, 31, 32, 33, 40, 41, 42,
+ 50,
+];
+
+function readRequestBody(request: IncomingMessage): Promise<string> {
+ return new Promise((resolve, reject) => {
+ const chunks: Buffer[] = [];
+ request.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
+ request.on("error", reject);
+ request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
+ });
+}
+
+function respondJson(response: ServerResponse, status: number, body: unknown) {
+ response.writeHead(status, { "Content-Type": "application/json" });
+ response.end(JSON.stringify(body));
+}
+
+function formBody(entries: Record<string, string>) {
+ const form = new TalerUrlSearchParams();
+ for (const [key, value] of Object.entries(entries)) {
+ form.set(key, value);
+ }
+ return form;
+}
+
+test("Challenger admin registration keeps the secret out of argv", async () => {
+ const calls: {
+ executable: string;
+ args: string[];
+ env: NodeJS.ProcessEnv;
+ }[] = [];
+ const registrar = createChallengerAdminRegistrar(
+ "challenger.conf",
+ async (executable, args, env) => {
+ calls.push({ executable, args, env });
+ return { stdout: calls.length === 1 ? "17\n" : "", stderr: "" };
+ },
+ );
+
+ const registration = await registrar.register({
+ callbackUrl: "http://127.0.0.1:8080/callback/random",
+ clientSecret: "secret-token:private-value",
+ });
+ assert.equal(registration.clientId, "17");
+ assert.deepEqual(calls[0].args, [
+ "-c",
+ "challenger.conf",
+ "-q",
+ "http://127.0.0.1:8080/callback/random",
+ ]);
+ assert.equal(
+ calls[0].env.CHALLENGER_CLIENT_SECRET,
+ "secret-token:private-value",
+ );
+ assert.equal(calls[0].args.includes("secret-token:private-value"), false);
+
+ await registration.unregister();
+ assert.deepEqual(calls[1].args, [
+ "-c",
+ "challenger.conf",
+ "-q",
+ "--delete",
+ "http://127.0.0.1:8080/callback/random",
+ ]);
+ assert.equal(calls[1].env.CHALLENGER_CLIENT_SECRET, undefined);
+});
+
+test("Challenger tester reports an unavailable server without a stack trace", async () => {
+ const challengerBaseUrl = "http://127.0.0.1:65534/";
+ await assert.rejects(
+ startChallengerTester({
+ challengerBaseUrl,
+ registrar: {
+ async register() {
+ throw new Error("registration must not be attempted");
+ },
+ },
+ httpClient: {
+ async fetch() {
+ throw new Error("connect ECONNREFUSED 127.0.0.1:65534");
+ },
+ },
+ }),
+ (error: unknown) => {
+ const message = formatChallengerTesterCommandError(error);
+ assert.equal(
+ message,
+ "Unable to start Challenger tester: Could not load Challenger configuration from http://127.0.0.1:65534/config. Check that Challenger is running and [challenger]/BASE_URL is correct. Details: connect ECONNREFUSED 127.0.0.1:65534",
+ );
+ assert.doesNotMatch(message, /\n\s+at /);
+ return true;
+ },
+ );
+});
+
+test("Challenger tester completes and reports address validations", async (t) => {
+ const httpClient = createPlatformHttpLib();
+ const challengerRequests: {
+ method: string;
+ path: string;
+ authorization?: string;
+ body: string;
+ }[] = [];
+ let tokenRequests = 0;
+ const challengerServer = createServer(async (request, response) => {
+ const requestUrl = new URL(request.url!, "http://challenger.invalid");
+ const body = await readRequestBody(request);
+ challengerRequests.push({
+ method: request.method ?? "",
+ path: requestUrl.pathname,
+ authorization: request.headers.authorization,
+ body,
+ });
+
+ if (request.method === "GET" && requestUrl.pathname === "/config") {
+ respondJson(response, 200, {
+ name: "challenger",
+ version: "9:0:7",
+ build_version: "1.4.2",
+ address_type: "email",
+ address_hint: "alice@example.com",
+ restrictions: {},
+ });
+ return;
+ }
+ if (request.method === "POST" && requestUrl.pathname === "/setup/42") {
+ if (JSON.parse(body || "{}")?.fail === true) {
+ respondJson(response, 500, { code: 1, hint: "setup failed" });
+ } else {
+ respondJson(response, 200, {
+ nonce: "test-nonce",
+ expires: { t_s: 2_000_000_000 },
+ });
+ }
+ return;
+ }
+ if (request.method === "POST" && requestUrl.pathname === "/token") {
+ tokenRequests++;
+ const form = new URLSearchParams(body);
+ if (form.get("code") === "bad-code") {
+ respondJson(response, 401, { code: 1, hint: "bad code" });
+ } else {
+ respondJson(response, 200, {
+ access_token: CHALLENGER_ACCESS_TOKEN,
+ token_type: "Bearer",
+ expires_in: 3600,
+ });
+ }
+ return;
+ }
+ if (request.method === "GET" && requestUrl.pathname === "/info") {
+ respondJson(response, 200, {
+ id: 23,
+ address: { CONTACT_EMAIL: "alice@example.com" },
+ address_type: "email",
+ expires: { t_s: 2_000_000_000 },
+ });
+ return;
+ }
+ respondJson(response, 404, { code: 1 });
+ });
+ await new Promise<void>((resolve) =>
+ challengerServer.listen(0, "127.0.0.1", resolve),
+ );
+ t.after(
+ () =>
+ new Promise<void>((resolve, reject) => {
+ challengerServer.close((error) => (error ? reject(error) : resolve()));
+ }),
+ );
+ const challengerAddress = challengerServer.address();
+ assert(challengerAddress && typeof challengerAddress !== "string");
+ const challengerBaseUrl = `http://127.0.0.1:${challengerAddress.port}/`;
+
+ let callbackUrl: string | undefined;
+ let clientSecret: string | undefined;
+ let unregistered = false;
+ const registrar: ChallengerClientRegistrar = {
+ async register(args) {
+ callbackUrl = args.callbackUrl;
+ clientSecret = args.clientSecret;
+ return {
+ clientId: "42",
+ async unregister() {
+ unregistered = true;
+ },
+ };
+ },
+ };
+ let tokenNumber = 0;
+ const tester = await startChallengerTester({
+ challengerBaseUrl,
+ host: "127.0.0.1",
+ port: 0,
+ registrar,
+ randomToken: () => `random-token-${++tokenNumber}`,
+ });
+ t.after(() => tester.close());
+ assert.equal(tester.callbackUrl, callbackUrl);
+ assert.match(clientSecret!, /^secret-token:random-token-/);
+
+ const homeResponse = await httpClient.fetch(tester.baseUrl);
+ assert.equal(homeResponse.status, 200);
+ const homeCsp = homeResponse.headers.get("content-security-policy");
+ assert.match(homeCsp!, /form-action 'self'/);
+ assert.equal(homeCsp!.includes(new URL(tester.baseUrl).origin), true);
+ assert.equal(homeCsp!.includes(new URL(challengerBaseUrl).origin), true);
+ const home = await homeResponse.text();
+ assert.match(home, /Challenger tester/);
+ assert.match(home, /email addresses/);
+ assert.match(
+ home,
+ /<form method="post" action="\/validations" target="_blank">/,
+ );
+ assert.match(home, /link\.target = "_blank";/);
+ assert.match(home, /link\.rel = "noopener";/);
+ for (const exitCode of DEFINED_HELPER_EXIT_CODES) {
+ assert.match(home, new RegExp(`<option value="${exitCode}">`));
+ }
+ assert.equal(
+ home.includes(
+ `AUTH_COMMAND = taler-harness challenger-tester-helper ${tester.baseUrl}`,
+ ),
+ true,
+ );
+ const csrfMatch = home.match(/name="csrf_token" value="([^"]+)"/);
+ assert(csrfMatch);
+
+ const deliveredAddress = { CONTACT_EMAIL: "alice@example.com" };
+ const deliveredExitCode = await runChallengerTesterHelper({
+ testerBaseUrl: tester.baseUrl,
+ address: JSON.stringify(deliveredAddress),
+ message: "Your Challenger verification code is T-1234-5678.",
+ httpClient,
+ });
+ assert.equal(deliveredExitCode, 0);
+ const challengesResponse = await httpClient.fetch(
+ new URL("api/challenges", tester.baseUrl).href,
+ );
+ assert.equal(challengesResponse.status, 200);
+ const challenges = (await challengesResponse.json()) as any[];
+ assert.equal(challenges.length, 1);
+ assert.deepEqual(challenges[0].address, deliveredAddress);
+ assert.equal(challenges[0].code, "12345678");
+ assert.equal(challenges[0].helperExitCode, 0);
+ assert.equal(challenges[0].helperOutcome, "Delivery confirmed");
+ assert.equal(
+ challenges[0].message,
+ "Your Challenger verification code is T-1234-5678.",
+ );
+
+ await assert.rejects(
+ runChallengerTesterHelper({
+ testerBaseUrl: tester.baseUrl,
+ address: "[]",
+ message: "message",
+ httpClient,
+ }),
+ /expected an object/,
+ );
+ const malformedChallengeResponse = await httpClient.fetch(
+ new URL("api/challenges", tester.baseUrl).href,
+ {
+ method: "POST",
+ body: { address: [], message: 42 },
+ },
+ );
+ assert.equal(malformedChallengeResponse.status, 400);
+
+ const setupAddress = {
+ CONTACT_EMAIL: "alice@example.com",
+ read_only: true,
+ };
+ const startResponse = await httpClient.fetch(
+ new URL("validations", tester.baseUrl).href,
+ {
+ method: "POST",
+ body: formBody({
+ csrf_token: csrfMatch[1],
+ setup_json: JSON.stringify(setupAddress),
+ }),
+ redirect: "manual",
+ },
+ );
+ assert.equal(startResponse.status, 303, await startResponse.text());
+ const authorizeUrl = new URL(startResponse.headers.get("location")!);
+ assert.equal(
+ authorizeUrl.href.startsWith(`${challengerBaseUrl}authorize/`),
+ true,
+ );
+ assert.equal(authorizeUrl.searchParams.get("client_id"), "42");
+ assert.equal(authorizeUrl.searchParams.get("redirect_uri"), callbackUrl);
+ const state = authorizeUrl.searchParams.get("state");
+ assert(state);
+
+ const setupRequest = challengerRequests.find((x) => x.path === "/setup/42");
+ assert(setupRequest);
+ assert.deepEqual(JSON.parse(setupRequest.body), setupAddress);
+ assert.equal(setupRequest.authorization, `Bearer ${clientSecret}`);
+
+ let statusesResponse = await httpClient.fetch(
+ new URL("api/validations", tester.baseUrl).href,
+ );
+ let statuses = (await statusesResponse.json()) as any[];
+ assert.equal(statuses.length, 1);
+ assert.equal(statuses[0].status, "pending");
+ assert.equal(statuses[0].authorizeUrl, authorizeUrl.href);
+
+ const callbackResponse = await httpClient.fetch(
+ `${callbackUrl}?state=${encodeURIComponent(state)}&code=good-code`,
+ { redirect: "manual" },
+ );
+ assert.equal(callbackResponse.status, 200);
+ const callbackPage = await callbackResponse.text();
+ assert.match(callbackPage, /Address validation completed/);
+ assert.match(callbackPage, /href="\/">Return to Challenger tester<\/a>/);
+ assert.doesNotMatch(callbackPage, /Start address validation/);
+ assert.doesNotMatch(callbackPage, /Delivered challenges/);
+ statusesResponse = await httpClient.fetch(
+ new URL("api/validations", tester.baseUrl).href,
+ );
+ statuses = (await statusesResponse.json()) as any[];
+ assert.equal(statuses[0].status, "completed");
+ assert.deepEqual(statuses[0].result.address, {
+ CONTACT_EMAIL: "alice@example.com",
+ });
+
+ const tokenRequest = challengerRequests.find((x) => x.path === "/token");
+ assert(tokenRequest);
+ const tokenForm = new URLSearchParams(tokenRequest.body);
+ assert.equal(tokenForm.get("redirect_uri"), callbackUrl);
+ assert.equal(tokenForm.get("client_secret"), clientSecret);
+ const infoRequest = challengerRequests.find((x) => x.path === "/info");
+ assert.equal(infoRequest?.authorization, `Bearer ${CHALLENGER_ACCESS_TOKEN}`);
+
+ const duplicateResponse = await httpClient.fetch(
+ `${callbackUrl}?state=${encodeURIComponent(state)}&code=good-code`,
+ { redirect: "manual" },
+ );
+ assert.equal(duplicateResponse.status, 200);
+ assert.match(await duplicateResponse.text(), /Address validation completed/);
+ assert.equal(tokenRequests, 1);
+
+ const secondStartResponse = await httpClient.fetch(
+ new URL("validations", tester.baseUrl).href,
+ {
+ method: "POST",
+ body: formBody({
+ csrf_token: csrfMatch[1],
+ setup_json: "",
+ }),
+ redirect: "manual",
+ },
+ );
+ assert.equal(secondStartResponse.status, 303);
+ const secondAuthorizeUrl = new URL(
+ secondStartResponse.headers.get("location")!,
+ );
+ const secondState = secondAuthorizeUrl.searchParams.get("state");
+ assert(secondState);
+ const failedCallbackResponse = await httpClient.fetch(
+ `${callbackUrl}?state=${encodeURIComponent(secondState)}&code=bad-code`,
+ { redirect: "manual" },
+ );
+ assert.equal(failedCallbackResponse.status, 400);
+ const failedCallbackPage = await failedCallbackResponse.text();
+ assert.match(failedCallbackPage, /Address validation failed/);
+ assert.match(
+ failedCallbackPage,
+ /href="\/">Return to Challenger tester<\/a>/,
+ );
+ statusesResponse = await httpClient.fetch(
+ new URL("api/validations", tester.baseUrl).href,
+ );
+ statuses = (await statusesResponse.json()) as any[];
+ assert.equal(
+ statuses.some((x) => x.status === "failed" && typeof x.error === "string"),
+ true,
+ );
+
+ const missingStateResponse = await httpClient.fetch(tester.callbackUrl);
+ assert.equal(missingStateResponse.status, 400);
+ const unknownStateResponse = await httpClient.fetch(
+ `${callbackUrl}?state=unknown&code=x`,
+ );
+ assert.equal(unknownStateResponse.status, 400);
+ const missingCsrfResponse = await httpClient.fetch(
+ new URL("validations", tester.baseUrl).href,
+ {
+ method: "POST",
+ body: formBody({ setup_json: "{}" }),
+ },
+ );
+ assert.equal(missingCsrfResponse.status, 403);
+ const malformedJsonResponse = await httpClient.fetch(
+ new URL("validations", tester.baseUrl).href,
+ {
+ method: "POST",
+ body: formBody({
+ csrf_token: csrfMatch[1],
+ setup_json: "[]",
+ }),
+ },
+ );
+ assert.equal(malformedJsonResponse.status, 400);
+
+ const failedSetupResponse = await httpClient.fetch(
+ new URL("validations", tester.baseUrl).href,
+ {
+ method: "POST",
+ body: formBody({
+ csrf_token: csrfMatch[1],
+ setup_json: '{"fail":true}',
+ }),
+ redirect: "manual",
+ },
+ );
+ assert.equal(failedSetupResponse.status, 303);
+ statusesResponse = await httpClient.fetch(
+ new URL("api/validations", tester.baseUrl).href,
+ );
+ statuses = (await statusesResponse.json()) as any[];
+ assert.equal(
+ statuses.some((x) => x.status === "failed"),
+ true,
+ );
+
+ const invalidHelperResultResponse = await httpClient.fetch(
+ new URL("validations", tester.baseUrl).href,
+ {
+ method: "POST",
+ body: formBody({
+ csrf_token: csrfMatch[1],
+ setup_json: "",
+ helper_exit_code: "99",
+ }),
+ },
+ );
+ assert.equal(invalidHelperResultResponse.status, 400);
+
+ for (const exitCode of DEFINED_HELPER_EXIT_CODES) {
+ const selectedResultResponse = await httpClient.fetch(
+ new URL("validations", tester.baseUrl).href,
+ {
+ method: "POST",
+ body: formBody({
+ csrf_token: csrfMatch[1],
+ setup_json: "",
+ helper_exit_code: String(exitCode),
+ }),
+ redirect: "manual",
+ },
+ );
+ assert.equal(selectedResultResponse.status, 303);
+ const reportedExitCode = await runChallengerTesterHelper({
+ testerBaseUrl: tester.baseUrl,
+ address: JSON.stringify(deliveredAddress),
+ message: `Simulated helper result ${exitCode}`,
+ httpClient,
+ });
+ assert.equal(reportedExitCode, exitCode);
+ }
+
+ const resetExitCode = await runChallengerTesterHelper({
+ testerBaseUrl: tester.baseUrl,
+ address: JSON.stringify(deliveredAddress),
+ message: "The helper result resets after one delivery",
+ httpClient,
+ });
+ assert.equal(resetExitCode, 0);
+
+ await tester.close();
+ assert.equal(unregistered, true);
+});
diff --git a/packages/taler-harness/src/challenger-tester.ts b/packages/taler-harness/src/challenger-tester.ts
@@ -0,0 +1,980 @@
+/*
+ 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.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ ChallengerApi,
+ ChallengerHttpClient,
+ Configuration,
+ type ConfigSource,
+ Logger,
+ createClientSecretAccessToken,
+ createRFC8959AccessTokenPlain,
+ succeedOrThrow,
+} from "@gnu-taler/taler-util";
+import {
+ createPlatformHttpLib,
+ type HttpRequestLibrary,
+} from "@gnu-taler/taler-util/http";
+import {
+ createApp,
+ createRouter,
+ defineEventHandler,
+ getQuery,
+ readBody,
+ sendRedirect,
+ setResponseHeader,
+ setResponseStatus,
+ toNodeListener,
+} from "h3";
+import { execFile } from "node:child_process";
+import { randomBytes } from "node:crypto";
+import * as http from "node:http";
+
+const logger = new Logger("challenger-tester.ts");
+
+const challengerConfigSource: ConfigSource = {
+ projectName: "challenger",
+ componentName: "challenger",
+ installPathBinary: "challenger-config",
+ baseConfigVarname: "CHALLENGER_BASE_CONFIG",
+ prefixVarname: "CHALLENGER_PREFIX",
+};
+
+type ValidationStatus =
+ | "starting"
+ | "pending"
+ | "processing"
+ | "completed"
+ | "failed";
+
+interface ValidationRecord {
+ id: string;
+ state: string;
+ status: ValidationStatus;
+ createdAt: string;
+ updatedAt: string;
+ setupAddress?: Record<string, unknown>;
+ nonce?: string;
+ authorizeUrl?: string;
+ result?: ChallengerApi.ChallengerInfoResponse;
+ error?: string;
+ completion?: Promise<void>;
+}
+
+interface ChallengeDelivery {
+ id: string;
+ receivedAt: string;
+ address: Record<string, unknown>;
+ message: string;
+ code?: string;
+ helperExitCode: number;
+ helperOutcome: string;
+}
+
+interface ChallengerHelperOutcome {
+ exitCode: number;
+ description: string;
+}
+
+const challengerHelperOutcomes: readonly ChallengerHelperOutcome[] = [
+ { exitCode: 0, description: "Delivery confirmed" },
+ { exitCode: 201, description: "Accepted for delivery" },
+ { exitCode: 202, description: "Duplicate delivery suppressed" },
+ { exitCode: 10, description: "Address malformed or missing" },
+ { exitCode: 11, description: "Phone number too short" },
+ { exitCode: 12, description: "Phone number too long" },
+ { exitCode: 13, description: "Not a mobile subscription" },
+ { exitCode: 14, description: "Subscriber unknown or unallocated" },
+ { exitCode: 15, description: "Recipient blocked or barred" },
+ { exitCode: 20, description: "Handset unavailable or out of coverage" },
+ { exitCode: 21, description: "Message expired" },
+ { exitCode: 22, description: "Carrier or intermediate failure" },
+ { exitCode: 30, description: "Provider unavailable or internal error" },
+ { exitCode: 31, description: "Provider rejected the request" },
+ { exitCode: 32, description: "Provider rate limit exceeded" },
+ { exitCode: 33, description: "Submission outcome unknown" },
+ { exitCode: 40, description: "Local configuration or invocation error" },
+ { exitCode: 41, description: "Provider credentials rejected" },
+ { exitCode: 42, description: "Provider account balance insufficient" },
+ { exitCode: 50, description: "Unclassified transmission failure" },
+];
+
+const challengerHelperOutcomesByExitCode = new Map(
+ challengerHelperOutcomes.map((outcome) => [outcome.exitCode, outcome]),
+);
+
+export interface ChallengerClientRegistration {
+ clientId: string;
+ unregister(): Promise<void>;
+}
+
+export interface ChallengerClientRegistrar {
+ register(args: {
+ callbackUrl: string;
+ clientSecret: string;
+ }): Promise<ChallengerClientRegistration>;
+}
+
+interface CommandResult {
+ stdout: string;
+ stderr: string;
+}
+
+export type ChallengerAdminRunner = (
+ executable: string,
+ args: string[],
+ env: NodeJS.ProcessEnv,
+) => Promise<CommandResult>;
+
+function runCommand(
+ executable: string,
+ args: string[],
+ env: NodeJS.ProcessEnv,
+): Promise<CommandResult> {
+ return new Promise((resolve, reject) => {
+ execFile(
+ executable,
+ args,
+ {
+ encoding: "utf8",
+ env,
+ },
+ (error, stdout, stderr) => {
+ if (error) {
+ reject(
+ new Error(
+ `${executable} failed: ${stderr.trim() || error.message}`,
+ ),
+ );
+ return;
+ }
+ resolve({ stdout, stderr });
+ },
+ );
+ });
+}
+
+export function createChallengerAdminRegistrar(
+ configFile?: string,
+ runner: ChallengerAdminRunner = runCommand,
+): ChallengerClientRegistrar {
+ const configArgs = configFile ? ["-c", configFile] : [];
+ return {
+ async register({ callbackUrl, clientSecret }) {
+ const unregister = async () => {
+ const env = { ...process.env };
+ delete env.CHALLENGER_CLIENT_SECRET;
+ await runner(
+ "challenger-admin",
+ [...configArgs, "-q", "--delete", callbackUrl],
+ env,
+ );
+ };
+ let addResult: CommandResult;
+ try {
+ addResult = await runner(
+ "challenger-admin",
+ [...configArgs, "-q", callbackUrl],
+ {
+ ...process.env,
+ CHALLENGER_CLIENT_SECRET: clientSecret,
+ },
+ );
+ } catch (error) {
+ await unregister().catch(() => undefined);
+ throw error;
+ }
+ const clientId = addResult.stdout.trim();
+ if (!/^[1-9][0-9]*$/.test(clientId)) {
+ await unregister().catch(() => undefined);
+ throw new Error(
+ `challenger-admin returned an invalid client ID: ${JSON.stringify(clientId)}`,
+ );
+ }
+ return {
+ clientId,
+ unregister,
+ };
+ },
+ };
+}
+
+function randomToken(bytes = 24): string {
+ return randomBytes(bytes).toString("base64url");
+}
+
+function normalizeChallengerBaseUrl(rawUrl: string): string {
+ const url = new URL(rawUrl);
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
+ throw new Error("[challenger]/BASE_URL must use HTTP or HTTPS");
+ }
+ if (url.search || url.hash) {
+ throw new Error(
+ "[challenger]/BASE_URL must not contain a query or fragment",
+ );
+ }
+ if (!url.pathname.endsWith("/")) {
+ url.pathname += "/";
+ }
+ return url.href;
+}
+
+function normalizeTesterBaseUrl(rawUrl: string): string {
+ const url = new URL(rawUrl);
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
+ throw new Error("the Challenger tester URL must use HTTP or HTTPS");
+ }
+ if (url.username || url.password || url.search || url.hash) {
+ throw new Error(
+ "the Challenger tester URL must not contain credentials, a query, or a fragment",
+ );
+ }
+ if (!url.pathname.endsWith("/")) {
+ url.pathname += "/";
+ }
+ return url.href;
+}
+
+function parseAddressJson(rawAddress: string): Record<string, unknown> {
+ let address: unknown;
+ try {
+ address = JSON.parse(rawAddress);
+ } catch (error) {
+ throw new Error(`invalid address JSON: ${errorMessage(error)}`);
+ }
+ if (!address || typeof address !== "object" || Array.isArray(address)) {
+ throw new Error("invalid address JSON: expected an object");
+ }
+ return address as Record<string, unknown>;
+}
+
+function extractChallengeCode(message: string): string | undefined {
+ const match = /(?:T-)?([0-9]{8}|[0-9]{4}-[0-9]{4})/.exec(message);
+ return match?.[1].replace("-", "");
+}
+
+function normalizeListenHost(host: string): string {
+ const unwrapped =
+ host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
+ if (!unwrapped || unwrapped.includes("/") || unwrapped.includes("://")) {
+ throw new Error("--host must be a hostname or IP address");
+ }
+ if (unwrapped === "0.0.0.0" || unwrapped === "::") {
+ throw new Error("--host must be concrete, not a wildcard address");
+ }
+ return unwrapped;
+}
+
+function makeHttpBaseUrl(host: string, port: number): string {
+ const urlHost = host.includes(":") ? `[${host}]` : host;
+ return `http://${urlHost}:${port}/`;
+}
+
+function errorMessage(error: unknown): string {
+ if (error instanceof Error) {
+ return error.message;
+ }
+ return String(error);
+}
+
+export function formatChallengerTesterCommandError(error: unknown): string {
+ return `Unable to start Challenger tester: ${errorMessage(error)}`;
+}
+
+function getSingleQueryValue(value: unknown): string | undefined {
+ return typeof value === "string" ? value : undefined;
+}
+
+function escapeHtml(value: string): string {
+ return value.replace(
+ /[&<>"']/g,
+ (character) =>
+ ({
+ "&": "&",
+ "<": "<",
+ ">": ">",
+ '"': """,
+ "'": "'",
+ })[character]!,
+ );
+}
+
+function setHtmlHeaders(
+ event: Parameters<typeof setResponseHeader>[0],
+ formActionUrls: string[] = [],
+): void {
+ const formActionSources = [
+ "'self'",
+ ...formActionUrls.map((url) => new URL(url).origin),
+ ];
+ setResponseHeader(event, "Content-Type", "text/html; charset=utf-8");
+ setResponseHeader(event, "Cache-Control", "no-store");
+ setResponseHeader(event, "X-Content-Type-Options", "nosniff");
+ setResponseHeader(event, "Referrer-Policy", "no-referrer");
+ setResponseHeader(
+ event,
+ "Content-Security-Policy",
+ `default-src 'none'; connect-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; form-action ${Array.from(new Set(formActionSources)).join(" ")}; base-uri 'none'; frame-ancestors 'none'`,
+ );
+}
+
+function renderErrorPage(title: string, detail: string): string {
+ return `<!doctype html>
+<html lang="en">
+ <head><meta charset="utf-8"><title>${escapeHtml(title)}</title></head>
+ <body>
+ <h1>${escapeHtml(title)}</h1>
+ <p>${escapeHtml(detail)}</p>
+ <p><a href="/">Return to Challenger tester</a></p>
+ </body>
+</html>`;
+}
+
+function renderValidationCompletedPage(): string {
+ return `<!doctype html>
+<html lang="en">
+ <head><meta charset="utf-8"><title>Address validation completed</title></head>
+ <body>
+ <h1>Address validation completed</h1>
+ <p>The address was successfully validated.</p>
+ <p><a href="/">Return to Challenger tester</a></p>
+ </body>
+</html>`;
+}
+
+function renderHelperOutcomeOptions(): string {
+ return challengerHelperOutcomes
+ .map(
+ (outcome) =>
+ `<option value="${outcome.exitCode}">${outcome.exitCode} — ${escapeHtml(outcome.description)}</option>`,
+ )
+ .join("\n");
+}
+
+function renderHomePage(args: {
+ addressType: string;
+ baseUrl: string;
+ challengerBaseUrl: string;
+ csrfToken: string;
+}): string {
+ return `<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>Challenger tester</title>
+ <style>
+ :root { color-scheme: light dark; font-family: system-ui, sans-serif; }
+ body { max-width: 78rem; margin: 2rem auto; padding: 0 1rem; }
+ form, table { width: 100%; }
+ textarea { box-sizing: border-box; min-height: 8rem; width: 100%; font-family: monospace; }
+ button { margin-top: .75rem; padding: .5rem 1rem; }
+ select { display: block; margin-top: .35rem; max-width: 100%; padding: .4rem; }
+ table { border-collapse: collapse; margin-top: 1rem; }
+ th, td { border-bottom: 1px solid #8886; padding: .6rem; text-align: left; vertical-align: top; }
+ pre { margin: 0; max-width: 34rem; overflow-wrap: anywhere; white-space: pre-wrap; }
+ .muted { opacity: .7; }
+ .failed { color: #c33; }
+ .completed { color: #298b3b; }
+ </style>
+ </head>
+ <body>
+ <h1>Challenger tester</h1>
+ <p>Testing <a href="${escapeHtml(args.challengerBaseUrl)}">${escapeHtml(args.challengerBaseUrl)}</a> (${escapeHtml(args.addressType)} addresses).</p>
+ <p>Configure Challenger to deliver its challenge messages here:</p>
+ <pre>AUTH_COMMAND = taler-harness challenger-tester-helper ${escapeHtml(args.baseUrl)}</pre>
+ <form method="post" action="/validations" target="_blank">
+ <input type="hidden" name="csrf_token" value="${escapeHtml(args.csrfToken)}">
+ <label for="setup-json">Optional JSON object passed to <code>/setup</code></label>
+ <textarea id="setup-json" name="setup_json" placeholder='{"CONTACT_EMAIL":"alice@example.com","read_only":true}'></textarea>
+ <label for="helper-exit-code">Result for the next challenge delivery</label>
+ <select id="helper-exit-code" name="helper_exit_code">
+ ${renderHelperOutcomeOptions()}
+ </select>
+ <p class="muted">The selected result is used once, then reset to successful delivery.</p>
+ <div><button type="submit">Start address validation</button></div>
+ </form>
+ <h2>Validations</h2>
+ <table>
+ <thead><tr><th>Started</th><th>Status</th><th>Setup address</th><th>Result or error</th><th>Action</th></tr></thead>
+ <tbody id="validations"><tr><td colspan="5" class="muted">No validations started.</td></tr></tbody>
+ </table>
+ <h2>Delivered challenges</h2>
+ <table>
+ <thead><tr><th>Received</th><th>Code</th><th>Helper result</th><th>Address</th><th>Message</th></tr></thead>
+ <tbody id="challenges"><tr><td colspan="5" class="muted">No challenge messages received.</td></tr></tbody>
+ </table>
+ <script>
+ const tbody = document.getElementById("validations");
+ const challengesBody = document.getElementById("challenges");
+ let lastValidationsJson;
+ let lastChallengesJson;
+ const cell = (row, text) => {
+ const td = document.createElement("td");
+ const pre = document.createElement("pre");
+ pre.textContent = text;
+ td.append(pre);
+ row.append(td);
+ return td;
+ };
+ async function refresh() {
+ try {
+ const [validationsResponse, challengesResponse] = await Promise.all([
+ fetch("/api/validations", { cache: "no-store" }),
+ fetch("/api/challenges", { cache: "no-store" }),
+ ]);
+ if (!validationsResponse.ok || !challengesResponse.ok) return;
+ const validations = await validationsResponse.json();
+ const challenges = await challengesResponse.json();
+ const validationsJson = JSON.stringify(validations);
+ if (validationsJson !== lastValidationsJson) {
+ lastValidationsJson = validationsJson;
+ tbody.replaceChildren();
+ if (!validations.length) {
+ const row = document.createElement("tr");
+ const td = document.createElement("td");
+ td.colSpan = 5;
+ td.className = "muted";
+ td.textContent = "No validations started.";
+ row.append(td);
+ tbody.append(row);
+ } else {
+ for (const validation of validations) {
+ const row = document.createElement("tr");
+ cell(row, new Date(validation.createdAt).toLocaleString());
+ const status = cell(row, validation.status);
+ status.className = validation.status;
+ cell(row, validation.setupAddress ? JSON.stringify(validation.setupAddress, null, 2) : "—");
+ cell(row, validation.result ? JSON.stringify(validation.result, null, 2) : (validation.error || "—"));
+ const action = document.createElement("td");
+ if (validation.authorizeUrl && validation.status === "pending") {
+ const link = document.createElement("a");
+ link.href = validation.authorizeUrl;
+ link.textContent = "Continue";
+ link.target = "_blank";
+ link.rel = "noopener";
+ action.append(link);
+ } else {
+ action.textContent = "—";
+ }
+ row.append(action);
+ tbody.append(row);
+ }
+ }
+ }
+ const challengesJson = JSON.stringify(challenges);
+ if (challengesJson !== lastChallengesJson) {
+ lastChallengesJson = challengesJson;
+ challengesBody.replaceChildren();
+ if (!challenges.length) {
+ const row = document.createElement("tr");
+ const td = document.createElement("td");
+ td.colSpan = 5;
+ td.className = "muted";
+ td.textContent = "No challenge messages received.";
+ row.append(td);
+ challengesBody.append(row);
+ } else {
+ for (const challenge of challenges) {
+ const row = document.createElement("tr");
+ cell(row, new Date(challenge.receivedAt).toLocaleString());
+ cell(row, challenge.code || "Not detected");
+ cell(row, challenge.helperExitCode + " — " + challenge.helperOutcome);
+ cell(row, JSON.stringify(challenge.address, null, 2));
+ cell(row, challenge.message);
+ challengesBody.append(row);
+ }
+ }
+ }
+ } catch (_) {
+ // A later poll will retry while the tester is running.
+ }
+ }
+ refresh();
+ setInterval(refresh, 1000);
+ </script>
+ </body>
+</html>`;
+}
+
+function listen(
+ server: http.Server,
+ port: number,
+ host: string,
+): Promise<void> {
+ return new Promise((resolve, reject) => {
+ const onError = (error: Error) => {
+ server.off("listening", onListening);
+ reject(error);
+ };
+ const onListening = () => {
+ server.off("error", onError);
+ resolve();
+ };
+ server.once("error", onError);
+ server.once("listening", onListening);
+ server.listen(port, host);
+ });
+}
+
+function closeServer(server: http.Server): Promise<void> {
+ if (!server.listening) {
+ return Promise.resolve();
+ }
+ return new Promise((resolve, reject) => {
+ server.close((error) => (error ? reject(error) : resolve()));
+ });
+}
+
+export interface ChallengerTesterHandle {
+ baseUrl: string;
+ callbackUrl: string;
+ close(): Promise<void>;
+ closed: Promise<void>;
+}
+
+export async function runChallengerTesterHelper(args: {
+ testerBaseUrl: string;
+ address: string;
+ message: string;
+ httpClient?: HttpRequestLibrary;
+}): Promise<number> {
+ const testerBaseUrl = normalizeTesterBaseUrl(args.testerBaseUrl);
+ const address = parseAddressJson(args.address);
+ const endpoint = new URL("api/challenges", testerBaseUrl).href;
+ const httpClient = args.httpClient ?? createPlatformHttpLib();
+ const response = await httpClient.fetch(endpoint, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ address, message: args.message }),
+ });
+ if (response.status < 200 || response.status >= 300) {
+ const detail = (await response.text()).trim();
+ throw new Error(
+ `Challenger tester rejected the challenge message with HTTP ${response.status}${detail ? `: ${detail}` : ""}`,
+ );
+ }
+ let responseBody: unknown;
+ try {
+ responseBody = await response.json();
+ } catch (error) {
+ throw new Error(
+ `Challenger tester returned an invalid response: ${errorMessage(error)}`,
+ );
+ }
+ const helperExitCode =
+ responseBody && typeof responseBody === "object"
+ ? (responseBody as Record<string, unknown>).helperExitCode
+ : undefined;
+ if (
+ !Number.isInteger(helperExitCode) ||
+ (helperExitCode as number) < 0 ||
+ (helperExitCode as number) > 255
+ ) {
+ throw new Error("Challenger tester returned an invalid helper exit code");
+ }
+ return helperExitCode as number;
+}
+
+export async function startChallengerTester(args: {
+ challengerBaseUrl: string;
+ host?: string;
+ port?: number;
+ registrar: ChallengerClientRegistrar;
+ randomToken?: (bytes?: number) => string;
+ httpClient?: HttpRequestLibrary;
+}): Promise<ChallengerTesterHandle> {
+ const challengerBaseUrl = normalizeChallengerBaseUrl(args.challengerBaseUrl);
+ const host = normalizeListenHost(args.host ?? "127.0.0.1");
+ const port = args.port ?? 8080;
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
+ throw new Error("--port must be an integer between 0 and 65535");
+ }
+ const makeToken = args.randomToken ?? randomToken;
+ const challenger = new ChallengerHttpClient(
+ challengerBaseUrl,
+ args.httpClient,
+ );
+ let challengerConfig: ChallengerApi.ChallengerTermsOfServiceResponse;
+ try {
+ challengerConfig = succeedOrThrow(await challenger.getConfig());
+ } catch (error) {
+ const configUrl = new URL("config", challengerBaseUrl).href;
+ throw new Error(
+ `Could not load Challenger configuration from ${configUrl}. Check that Challenger is running and [challenger]/BASE_URL is correct. Details: ${errorMessage(error)}`,
+ );
+ }
+ const csrfToken = makeToken(24);
+ const callbackPath = `/callback/${makeToken(18)}`;
+ const validations = new Map<string, ValidationRecord>();
+ const challenges: ChallengeDelivery[] = [];
+ let nextHelperExitCode = 0;
+ let registration: ChallengerClientRegistration | undefined;
+ let callbackUrl = "";
+ let baseUrl = "";
+
+ const app = createApp();
+ const router = createRouter();
+
+ router.get(
+ "/",
+ defineEventHandler((event) => {
+ setHtmlHeaders(event, [baseUrl, challengerBaseUrl]);
+ return renderHomePage({
+ addressType: challengerConfig.address_type,
+ baseUrl,
+ challengerBaseUrl,
+ csrfToken,
+ });
+ }),
+ );
+
+ router.get(
+ "/api/challenges",
+ defineEventHandler((event) => {
+ setResponseHeader(event, "Cache-Control", "no-store");
+ return challenges.slice().reverse();
+ }),
+ );
+
+ router.post(
+ "/api/challenges",
+ defineEventHandler(async (event) => {
+ const body = await readBody<unknown>(event);
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
+ setResponseStatus(event, 400);
+ return { error: "request body must be a JSON object" };
+ }
+ const address = (body as Record<string, unknown>).address;
+ const message = (body as Record<string, unknown>).message;
+ if (!address || typeof address !== "object" || Array.isArray(address)) {
+ setResponseStatus(event, 400);
+ return { error: "address must be a JSON object" };
+ }
+ if (typeof message !== "string") {
+ setResponseStatus(event, 400);
+ return { error: "message must be a string" };
+ }
+ const helperOutcome =
+ challengerHelperOutcomesByExitCode.get(nextHelperExitCode)!;
+ nextHelperExitCode = 0;
+ const delivery: ChallengeDelivery = {
+ id: makeToken(9),
+ receivedAt: new Date().toISOString(),
+ address: address as Record<string, unknown>,
+ message,
+ code: extractChallengeCode(message),
+ helperExitCode: helperOutcome.exitCode,
+ helperOutcome: helperOutcome.description,
+ };
+ challenges.push(delivery);
+ setResponseStatus(event, 201);
+ return delivery;
+ }),
+ );
+
+ router.get(
+ "/api/validations",
+ defineEventHandler((event) => {
+ setResponseHeader(event, "Cache-Control", "no-store");
+ return Array.from(validations.values())
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
+ .map((validation) => ({
+ id: validation.id,
+ status: validation.status,
+ createdAt: validation.createdAt,
+ updatedAt: validation.updatedAt,
+ setupAddress: validation.setupAddress,
+ authorizeUrl: validation.authorizeUrl,
+ result: validation.result,
+ error: validation.error,
+ }));
+ }),
+ );
+
+ router.post(
+ "/validations",
+ defineEventHandler(async (event) => {
+ const body = await readBody<Record<string, unknown>>(event);
+ if (body?.csrf_token !== csrfToken) {
+ setHtmlHeaders(event);
+ setResponseStatus(event, 403);
+ return renderErrorPage("Request rejected", "Invalid CSRF token.");
+ }
+ const setupJson = body.setup_json;
+ if (typeof setupJson !== "string") {
+ setHtmlHeaders(event);
+ setResponseStatus(event, 400);
+ return renderErrorPage(
+ "Invalid setup address",
+ "The setup JSON field must be a string.",
+ );
+ }
+ const rawHelperExitCode = body.helper_exit_code;
+ const helperExitCode =
+ rawHelperExitCode === undefined ||
+ (typeof rawHelperExitCode === "string" &&
+ /^(0|[1-9][0-9]*)$/.test(rawHelperExitCode))
+ ? Number(rawHelperExitCode ?? 0)
+ : Number.NaN;
+ if (!challengerHelperOutcomesByExitCode.has(helperExitCode)) {
+ setHtmlHeaders(event);
+ setResponseStatus(event, 400);
+ return renderErrorPage(
+ "Invalid helper result",
+ "Select one of the defined Challenger helper exit codes.",
+ );
+ }
+ let setupAddress: Record<string, unknown> | undefined;
+ if (setupJson.trim()) {
+ try {
+ const parsed: unknown = JSON.parse(setupJson);
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
+ throw new Error("the value is not a JSON object");
+ }
+ setupAddress = parsed as Record<string, unknown>;
+ } catch (error) {
+ setHtmlHeaders(event);
+ setResponseStatus(event, 400);
+ return renderErrorPage("Invalid setup address", errorMessage(error));
+ }
+ }
+ if (!registration) {
+ setHtmlHeaders(event);
+ setResponseStatus(event, 503);
+ return renderErrorPage(
+ "Tester not ready",
+ "The Challenger client has not been registered.",
+ );
+ }
+
+ const now = new Date().toISOString();
+ const state = makeToken(24);
+ const validation: ValidationRecord = {
+ id: makeToken(9),
+ state,
+ status: "starting",
+ createdAt: now,
+ updatedAt: now,
+ setupAddress,
+ };
+ validations.set(state, validation);
+ try {
+ const setup = succeedOrThrow(
+ await challenger.setup(
+ registration.clientId,
+ createRFC8959AccessTokenPlain(clientSecret),
+ setupAddress,
+ ),
+ );
+ const authorizeUrl = new URL(
+ `authorize/${encodeURIComponent(setup.nonce)}`,
+ challengerBaseUrl,
+ );
+ authorizeUrl.searchParams.set("response_type", "code");
+ authorizeUrl.searchParams.set("client_id", registration.clientId);
+ authorizeUrl.searchParams.set("redirect_uri", callbackUrl);
+ authorizeUrl.searchParams.set("state", state);
+ validation.nonce = setup.nonce;
+ validation.authorizeUrl = authorizeUrl.href;
+ validation.status = "pending";
+ validation.updatedAt = new Date().toISOString();
+ nextHelperExitCode = helperExitCode;
+ return sendRedirect(event, authorizeUrl.href, 303);
+ } catch (error) {
+ validation.status = "failed";
+ validation.error = errorMessage(error);
+ validation.updatedAt = new Date().toISOString();
+ return sendRedirect(event, "/", 303);
+ }
+ }),
+ );
+
+ router.get(
+ callbackPath,
+ defineEventHandler(async (event) => {
+ const query = getQuery(event);
+ const state = getSingleQueryValue(query.state);
+ if (!state) {
+ setHtmlHeaders(event);
+ setResponseStatus(event, 400);
+ return renderErrorPage("Invalid callback", "Missing OAuth state.");
+ }
+ const validation = validations.get(state);
+ if (!validation) {
+ setHtmlHeaders(event);
+ setResponseStatus(event, 400);
+ return renderErrorPage("Invalid callback", "Unknown OAuth state.");
+ }
+ const renderValidationResult = (): string => {
+ setHtmlHeaders(event);
+ if (validation.status === "completed") {
+ return renderValidationCompletedPage();
+ }
+ setResponseStatus(event, 400);
+ return renderErrorPage(
+ "Address validation failed",
+ validation.error ?? "Challenger did not complete the validation.",
+ );
+ };
+ if (validation.status === "completed") {
+ return renderValidationResult();
+ }
+ if (validation.status === "failed") {
+ return renderValidationResult();
+ }
+ if (!registration) {
+ validation.status = "failed";
+ validation.error = "The Challenger client is no longer registered.";
+ validation.updatedAt = new Date().toISOString();
+ return renderValidationResult();
+ }
+
+ if (!validation.completion) {
+ validation.completion = (async () => {
+ validation.status = "processing";
+ validation.updatedAt = new Date().toISOString();
+ const oauthError = getSingleQueryValue(query.error);
+ const code = getSingleQueryValue(query.code);
+ try {
+ if (oauthError) {
+ const description =
+ getSingleQueryValue(query.error_description) ?? oauthError;
+ throw new Error(
+ `Challenger authorization failed: ${description}`,
+ );
+ }
+ if (!code) {
+ throw new Error("Challenger callback did not contain a code.");
+ }
+ const token = succeedOrThrow(
+ await challenger.token(
+ registration!.clientId,
+ callbackUrl,
+ createRFC8959AccessTokenPlain(clientSecret),
+ code,
+ ),
+ );
+ const info = succeedOrThrow(
+ await challenger.info(
+ createClientSecretAccessToken(token.access_token),
+ ),
+ );
+ validation.result = info;
+ validation.status = "completed";
+ } catch (error) {
+ validation.error = errorMessage(error);
+ validation.status = "failed";
+ } finally {
+ validation.updatedAt = new Date().toISOString();
+ }
+ })();
+ }
+ await validation.completion;
+ return renderValidationResult();
+ }),
+ );
+
+ app.use(router);
+ const server = http.createServer(toNodeListener(app));
+ await listen(server, port, host);
+ const address = server.address();
+ if (!address || typeof address === "string") {
+ await closeServer(server);
+ throw new Error("unable to determine the Challenger tester listen port");
+ }
+ baseUrl = makeHttpBaseUrl(host, address.port);
+ callbackUrl = new URL(callbackPath, baseUrl).href;
+ const clientSecret = `secret-token:${makeToken(32)}`;
+
+ try {
+ registration = await args.registrar.register({
+ callbackUrl,
+ clientSecret,
+ });
+ } catch (error) {
+ await closeServer(server);
+ throw error;
+ }
+
+ let resolveClosed!: () => void;
+ const closed = new Promise<void>((resolve) => {
+ resolveClosed = resolve;
+ });
+ server.once("close", resolveClosed);
+ let closePromise: Promise<void> | undefined;
+ return {
+ baseUrl,
+ callbackUrl,
+ closed,
+ close() {
+ if (!closePromise) {
+ closePromise = (async () => {
+ await closeServer(server);
+ const activeRegistration = registration;
+ registration = undefined;
+ await activeRegistration?.unregister();
+ })();
+ }
+ return closePromise;
+ },
+ };
+}
+
+export async function runChallengerTester(args: {
+ configFile?: string;
+ host?: string;
+ port?: number;
+}): Promise<void> {
+ const config = Configuration.load(args.configFile, challengerConfigSource);
+ const challengerBaseUrl = config
+ .getString("challenger", "BASE_URL")
+ .required();
+ const handle = await startChallengerTester({
+ challengerBaseUrl,
+ host: args.host,
+ port: args.port,
+ registrar: createChallengerAdminRegistrar(args.configFile),
+ });
+ logger.info(`Challenger tester is available at ${handle.baseUrl}`);
+ console.log(handle.baseUrl);
+
+ let shuttingDown = false;
+ const shutdown = () => {
+ if (shuttingDown) {
+ return;
+ }
+ shuttingDown = true;
+ void handle.close().catch((error) => {
+ logger.warn(
+ `Failed to cleanly stop Challenger tester: ${errorMessage(error)}`,
+ );
+ });
+ };
+ process.once("SIGINT", shutdown);
+ process.once("SIGTERM", shutdown);
+ try {
+ await handle.closed;
+ } finally {
+ process.off("SIGINT", shutdown);
+ process.off("SIGTERM", shutdown);
+ await handle.close().catch((error) => {
+ logger.warn(
+ `Failed to clean up Challenger client: ${errorMessage(error)}`,
+ );
+ });
+ }
+}
diff --git a/packages/taler-harness/src/index.ts b/packages/taler-harness/src/index.ts
@@ -91,6 +91,11 @@ import { runBench1 } from "./bench1.js";
import { runBenchWalletDb } from "./benchWalletDb.js";
import { runBench2 } from "./bench2.js";
import { runBench3 } from "./bench3.js";
+import {
+ formatChallengerTesterCommandError,
+ runChallengerTester,
+ runChallengerTesterHelper,
+} from "./challenger-tester.js";
import { runEnvFull } from "./env-full.js";
import { runEnv1 } from "./env1.js";
import {
@@ -159,6 +164,50 @@ const testingCli = talerHarnessCli.subcommand("testingArgs", "testing", {
help: "Subcommands for advanced operations (only use if you know what you're doing!).",
});
+talerHarnessCli
+ .subcommand("challengerTester", "challenger-tester", {
+ help: "Run a local Web site for testing a configured Challenger service.",
+ })
+ .maybeOption("configFile", ["-c", "--config"], clk.STRING, {
+ help: "Challenger configuration file to use.",
+ })
+ .maybeOption("host", ["--host"], clk.STRING, {
+ help: "Concrete host to listen on and use in the callback URL (default: 127.0.0.1).",
+ })
+ .maybeOption("port", ["--port"], clk.INT, {
+ help: "HTTP port to listen on (default: 8080).",
+ })
+ .action(async (args) => {
+ try {
+ await runChallengerTester({
+ configFile: args.challengerTester.configFile,
+ host: args.challengerTester.host,
+ port: args.challengerTester.port,
+ });
+ } catch (error) {
+ console.error(formatChallengerTesterCommandError(error));
+ process.exitCode = 1;
+ }
+ });
+
+talerHarnessCli
+ .subcommand("challengerTesterHelper", "challenger-tester-helper", {
+ help: "Deliver a Challenger challenge message to challenger-tester.",
+ })
+ .requiredArgument("testerBaseUrl", clk.STRING, {
+ help: "Base URL printed by challenger-tester.",
+ })
+ .requiredArgument("address", clk.STRING, {
+ help: "Address JSON appended by Challenger.",
+ })
+ .action(async (args) => {
+ process.exitCode = await runChallengerTesterHelper({
+ testerBaseUrl: args.challengerTesterHelper.testerBaseUrl,
+ address: args.challengerTesterHelper.address,
+ message: await read(process.stdin),
+ });
+ });
+
testingCli
.subcommand("scenarioCoinAcceptor", "scenario-coin-acceptor", {
help: "Test scenario for coin acceptor.",
diff --git a/packages/taler-util/src/http-client/challenger.test.ts b/packages/taler-util/src/http-client/challenger.test.ts
@@ -24,6 +24,7 @@ import {
} from "../http-common.js";
import { HttpStatusCode } from "../http-status-codes.js";
import { isOperationFail, isOperationOk } from "../operation.js";
+import { codecForChallengerTermsOfServiceResponse } from "../types-taler-challenger.js";
import { ChallengerHttpClient } from "./challenger.js";
function fixedLib(
@@ -62,9 +63,27 @@ function fixedLib(
const REDIRECT = "https://client.example.com/cb?code=XYZ";
+test("Challenger config decodes build version and address hint", () => {
+ const config = codecForChallengerTermsOfServiceResponse().decode({
+ name: "challenger",
+ version: "2:0:0",
+ build_version: "1.4.2-17-g0123456",
+ implementation: "urn:gnu:taler:challenger:c",
+ restrictions: {},
+ address_type: "email",
+ address_hint: "alice@example.com",
+ });
+
+ assert.strictEqual(config.build_version, "1.4.2-17-g0123456");
+ assert.strictEqual(config.address_hint, "alice@example.com");
+});
+
test("solve returns the completed redirect on a 302", async (t) => {
const { lib } = fixedLib(HttpStatusCode.Found, { location: REDIRECT });
- const client = new ChallengerHttpClient("https://challenger.example.com/", lib);
+ const client = new ChallengerHttpClient(
+ "https://challenger.example.com/",
+ lib,
+ );
const res = await client.solve("nonce", { pin: "1234" });
assert.ok(isOperationOk(res), "302 must be a success");
assert.strictEqual((res.body as any).type, "completed");
@@ -73,8 +92,16 @@ test("solve returns the completed redirect on a 302", async (t) => {
test("token reports 401 for a bad client secret", async (t) => {
const { lib } = fixedLib(HttpStatusCode.Unauthorized);
- const client = new ChallengerHttpClient("https://challenger.example.com/", lib);
- const res = await client.token("clientId", "https://cb/", "secret" as any, "code");
+ const client = new ChallengerHttpClient(
+ "https://challenger.example.com/",
+ lib,
+ );
+ const res = await client.token(
+ "clientId",
+ "https://cb/",
+ "secret" as any,
+ "code",
+ );
assert.ok(isOperationFail(res), "401 must be a known failure");
assert.strictEqual(res.case, HttpStatusCode.Unauthorized);
});
diff --git a/packages/taler-util/src/types-taler-challenger.ts b/packages/taler-util/src/types-taler-challenger.ts
@@ -45,6 +45,11 @@ export interface ChallengerTermsOfServiceResponse {
// The format is "current:revision:age".
version: string;
+ // Release version of the source code.
+ // The format is MAJOR.MINOR.MICRO[-GITDATA].
+ // Since v7.
+ build_version: string;
+
// URN of the implementation (needed to interpret 'revision' in version).
// @since v0, may become mandatory in the future.
implementation?: string;
@@ -61,11 +66,17 @@ export interface ChallengerTermsOfServiceResponse {
// @since v2.
address_type: "email" | "phone" | "postal" | "postal-ch";
+
+ // Example address shown to the user.
+ address_hint: string;
}
export interface ChallengeSetupResponse {
// Nonce to use when constructing /authorize endpoint.
nonce: string;
+
+ // Time when the setup nonce expires.
+ expires: Timestamp;
}
export interface Restriction {
@@ -209,6 +220,7 @@ export const codecForChallengerTermsOfServiceResponse =
buildCodecForObject<ChallengerTermsOfServiceResponse>()
.property("name", codecForConstString("challenger"))
.property("version", codecForString())
+ .property("build_version", codecForString())
.property("implementation", codecOptional(codecForString()))
.property("restrictions", codecOptional(codecForMap(codecForAny())))
.property(
@@ -220,12 +232,14 @@ export const codecForChallengerTermsOfServiceResponse =
codecForConstString("postal-ch"),
),
)
+ .property("address_hint", codecForString())
.build("ChallengerApi.ChallengerTermsOfServiceResponse");
export const codecForChallengeSetupResponse =
(): Codec<ChallengeSetupResponse> =>
buildCodecForObject<ChallengeSetupResponse>()
.property("nonce", codecForString())
+ .property("expires", codecForTimestamp)
.build("ChallengerApi.ChallengeSetupResponse");
export const codecForChallengeStatus = (): Codec<ChallengeStatus> =>
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
@@ -478,6 +478,9 @@ importers:
'@types/selenium-webdriver':
specifier: 4.35.5
version: 4.35.5
+ h3:
+ specifier: ^1.15.0
+ version: 1.15.11
playwright-core:
specifier: ^1.62.0
version: 1.62.0