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:
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)) {