taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

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:
MMakefile | 1+
Mpackages/challenger-webui/src/app.tsx | 92++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Apackages/taler-harness/README-challenger-tester.md | 140+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/README.md | 6++++++
Mpackages/taler-harness/package.json | 4+++-
Apackages/taler-harness/src/challenger-tester.test.ts | 510+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-harness/src/challenger-tester.ts | 980+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/index.ts | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-util/src/http-client/challenger.test.ts | 33++++++++++++++++++++++++++++++---
Mpackages/taler-util/src/types-taler-challenger.ts | 14++++++++++++++
Mpnpm-lock.yaml | 3+++
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) => + ({ + "&": "&amp;", + "<": "&lt;", + ">": "&gt;", + '"': "&quot;", + "'": "&#39;", + })[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