taler-typescript-core

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

commit 94ca0630d53f32cdd3da4464091e6becf769c5ed
parent 5661413726424d507c3dc8413d81ec35426cefc5
Author: Florian Dold <dold@taler.net>
Date:   Sat, 10 Oct 2026 21:41:53 +0200

always use /token authorization, support strict v19 mode of the merchant

Diffstat:
Mpackages/taler-harness/src/harness/harness.ts | 3+++
Mpackages/taler-harness/src/index.ts | 40++++++++++++++++++++++------------------
Mpackages/taler-harness/src/integrationtests/test-merchant-instances.ts | 5++---
Mpackages/taler-harness/src/stagefright/merchant.ts | 96++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
4 files changed, 117 insertions(+), 27 deletions(-)

diff --git a/packages/taler-harness/src/harness/harness.ts b/packages/taler-harness/src/harness/harness.ts @@ -2999,6 +2999,9 @@ export class MerchantService implements MerchantServiceInterface { config.setString("merchant", "currency", mc.currency ?? "TESTKUDOS"); config.setString("merchant", "serve", "tcp"); config.setString("merchant", "port", `${mc.httpPort}`); + // Reject deprecated protocol features (password as bearer token, + // external auth), so tests only exercise the current protocol. + config.setString("merchant", "strict_protocol_v19", "yes"); config.setString( "merchant", "keyfile", diff --git a/packages/taler-harness/src/index.ts b/packages/taler-harness/src/index.ts @@ -1398,24 +1398,21 @@ deploymentCli } { - const resp = await merchantInstance.addTemplate( - createRFC8959AccessTokenEncoded(password), - { - template_id: "default", - template_description: "First template", - template_contract: { - template_type: TemplateType.FIXED_ORDER, - pay_duration: Duration.toTalerProtocolDuration( - Duration.fromSpec({ hours: 1 }), - ), - currency, - summary: "Pay me!", - }, - editable_defaults: { - amount: currency, - }, + const resp = await merchantInstance.addTemplate(access_token, { + template_id: "default", + template_description: "First template", + template_contract: { + template_type: TemplateType.FIXED_ORDER, + pay_duration: Duration.toTalerProtocolDuration( + Duration.fromSpec({ hours: 1 }), + ), + currency, + summary: "Pay me!", }, - ); + editable_defaults: { + amount: currency, + }, + }); if (resp.type === "fail") { console.error( `unable to create template for insntaince ${id}, status ${resp.case}`, @@ -1521,9 +1518,16 @@ deploymentCli } } + // Log in again with the new password. + const { access_token: newAccessToken } = succeedOrThrow( + await merchantInstance.createAccessToken(id, randomPassword, { + scope: LoginTokenScope.All, + }), + ); + { const resp = await merchantInstance.updateBankAccount( - createRFC8959AccessTokenEncoded(randomPassword), + newAccessToken, wireAccount, { credit_facade_url: bank.getRevenueAPI(id).href, diff --git a/packages/taler-harness/src/integrationtests/test-merchant-instances.ts b/packages/taler-harness/src/integrationtests/test-merchant-instances.ts @@ -144,6 +144,7 @@ export async function runMerchantInstancesTest(t: GlobalTestState) { level: "WARNING", messages: [ ["Access denied: password does not match", 2], + ["Bearer authentication failed: 41", 2], [ "Client used deprecated '/instances/admin/' path. Redirecting to modern path", 3, @@ -166,15 +167,13 @@ export async function runMerchantInstancesTest(t: GlobalTestState) { ["Endpoint `/private/instances/myinst/config' not known", 1], ["Endpoint `/private/instances/myinst/orders' not known", 1], [ - "External protocol violation detected at ../src/backend/taler-merchant-httpd_auth.c:724.", + /^External protocol violation detected at \.\.\/src\/backend\/taler-merchant-httpd_auth\.c:\d+\.$/, 2, ], ["Failed to find required permissions for scope 0", 4], ["Instance for `/instances/config' not known", 1], ["Instance for `/instances/foo/config' not known", 1], - ["Login failed", 2], ["Permission check failed: scope 0 not understood", 4], - ["Trying deprecated secret-token:password API authN", 2], ], justification: "the test's route/authentication matrix deliberately probes deprecated, malformed, unknown, unauthenticated, and wrongly authenticated instance URLs", diff --git a/packages/taler-harness/src/stagefright/merchant.ts b/packages/taler-harness/src/stagefright/merchant.ts @@ -27,6 +27,13 @@ import { TransactionMinorState, paytoFromTransferSubject, AmountString, + AccessToken, + Duration, + HttpStatusCode, + LoginTokenRequest, + LoginTokenScope, + succeedOrThrow, + TalerMerchantInstanceHttpClient, } from "@gnu-taler/taler-util"; import { WalletClient, tryUnixConnect, delayMs } from "../harness/harness.js"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; @@ -44,6 +51,7 @@ import { registerMerchant, signInMerchant, solveMfaChallenges, + type MerchantAccount, } from "./merchant-auth.js"; import { resolveMerchantOptions, @@ -119,6 +127,65 @@ function normalizeBaseUrl(url: string): string { return url.endsWith("/") ? url : `${url}/`; } +/** + * Get a login token for the instance's API. + * + * The backend no longer takes the password itself as a bearer token, so log + * in through POST /private/token. On an instance with MFA that answers 202 + * with TAN challenges first, which are solved through the mock 2FA mailboxes + * before the request is repeated with their IDs. + */ +async function loginMerchantApi( + baseUrl: string, + account: MerchantAccount, + mock2fa: Mock2faReader, + duration: Duration, +): Promise<AccessToken> { + const { instanceId, password, email, phone, timeoutMs } = account; + const api = new TalerMerchantInstanceHttpClient( + `${baseUrl}instances/${instanceId}/`, + ); + const request: LoginTokenRequest = { + scope: LoginTokenScope.All, + duration: Duration.toTalerProtocolDuration(duration), + description: "stagefright API checks", + }; + const first = await api.createAccessToken(instanceId, password, request); + if (first.type === "ok") { + return first.body.access_token; + } + if (first.case !== HttpStatusCode.Accepted) { + throw Error( + `logging in to the API of '${instanceId}' failed with HTTP status ${first.case}`, + ); + } + // Whatever is in the mailboxes now was meant for the portal, not for us. + await mock2fa.markCurrent([email, phone]); + const { challenges, combi_and } = first.body; + const toSolve = combi_and ? challenges : challenges.slice(0, 1); + for (const challenge of toSolve) { + succeedOrThrow(await api.sendChallenge(challenge.challenge_id)); + const message = await mock2fa.waitForNewCode([email, phone], { + login: instanceId, + timeoutMs, + }); + succeedOrThrow( + await api.confirmChallenge(challenge.challenge_id, { + tan: message.code, + }), + ); + } + logger.info( + `solved ${toSolve.length} MFA challenge(s) for the API login token`, + ); + const { access_token } = succeedOrThrow( + await api.createAccessToken(instanceId, password, request, { + challengeIds: toSolve.map((c) => c.challenge_id), + }), + ); + return access_token; +} + export async function runStagefrightMerchant( input: MerchantOptions = {}, ): Promise<MerchantResult> { @@ -194,6 +261,23 @@ export async function runStagefrightMerchant( await registerMerchant(stage, account, mock2fa); } if (coverage === "onboarding") return; + // The API checks below share one login token, obtained on first use and + // valid for as long as the scenario may run. + let apiAuthorization: string | undefined; + const getApiAuthorization = async (): Promise<string> => { + if (apiAuthorization === undefined) { + const token = await loginMerchantApi( + baseUrl, + account, + mock2fa, + Duration.fromMilliseconds( + options.overallTimeoutMs ?? DEFAULT_SCENARIO_TIMEOUT_MS, + ), + ); + apiAuthorization = `Bearer ${token}`; + } + return apiAuthorization; + }; let createdOrderId = ""; let paidOrderId = ""; let otpDeviceName = ""; @@ -1214,7 +1298,7 @@ export async function runStagefrightMerchant( // an order is paid. Note that a claimed order is *not* a paid one. const orderStatus = await fetch( `${baseUrl}instances/${instanceId}/private/orders/${createdOrderId}`, - { headers: { Authorization: `Bearer secret-token:${password}` } }, + { headers: { Authorization: await getApiAuthorization() } }, ).then((r: any) => r.json()); if (orderStatus.order_status !== "paid") { throw Error( @@ -1283,7 +1367,7 @@ export async function runStagefrightMerchant( // Create product with categories via backend API const prodId = `sf_prod_${Date.now()}`; - const tokenHeader = `Bearer secret-token:${password}`; + const tokenHeader = await getApiAuthorization(); await fetch(`${baseUrl}instances/${instanceId}/private/products`, { method: "POST", headers: { @@ -1721,7 +1805,7 @@ export async function runStagefrightMerchant( async (page) => { const orderToRefund = paidOrderId || createdOrderId; // Ensure backend order status is paid - const tokenHeader = `Bearer secret-token:${password}`; + const tokenHeader = await getApiAuthorization(); for (let i = 0; i < 10; i++) { const detailRes: any = await fetch( `${baseUrl}instances/${instanceId}/private/orders/${orderToRefund}`, @@ -1849,7 +1933,7 @@ export async function runStagefrightMerchant( const listed: any = await fetch( `${baseUrl}instances/${instanceId}/private/tokens`, { - headers: { Authorization: `Bearer secret-token:${password}` }, + headers: { Authorization: await getApiAuthorization() }, }, ); if (listed.status === 200) { @@ -2028,7 +2112,7 @@ export async function runStagefrightMerchant( } const created: any = await fetch( `${baseUrl}instances/${instanceId}/private/orders/${choiceOrderId}`, - { headers: { Authorization: `Bearer secret-token:${password}` } }, + { headers: { Authorization: await getApiAuthorization() } }, ).then((r: any) => r.json()); const createdSummary = created.contract_terms?.summary ?? created.summary ?? ""; @@ -2077,7 +2161,7 @@ export async function runStagefrightMerchant( // ...and gone from the backend, which is what actually matters. const listed: any = await fetch( `${baseUrl}instances/${instanceId}/private/tokenfamilies`, - { headers: { Authorization: `Bearer secret-token:${password}` } }, + { headers: { Authorization: await getApiAuthorization() } }, ).then((r: any) => r.json()); const families: any[] = listed.token_families ?? []; if (families.some((f) => f.name === doomed)) {