commit b73573be3af299a89e801ddd0d6c6d6c7b7f020a
parent 5b7c49b9b95926032e26ae557fb5e99c6f2018bf
Author: Florian Dold <florian@dold.me>
Date: Tue, 4 Aug 2026 23:09:35 +0200
fix handling of empty vs missing session IDs
Diffstat:
5 files changed, 30 insertions(+), 13 deletions(-)
diff --git a/packages/taler-util/src/http-client/merchant.ts b/packages/taler-util/src/http-client/merchant.ts
@@ -529,7 +529,9 @@ export class TalerMerchantInstanceHttpClient {
if (params.refund !== undefined) {
url.searchParams.set("refund", params.refund);
}
- if (params.sessionId !== undefined) {
+ if (params.sessionId) {
+ // An empty session ID is not a session, and "?session_id=" would
+ // ask the merchant for an order that is bound to no session.
url.searchParams.set("session_id", params.sessionId);
}
if (params.timeout !== undefined) {
diff --git a/packages/taler-util/src/taleruri.test.ts b/packages/taler-util/src/taleruri.test.ts
@@ -110,7 +110,7 @@ import { AmountString } from "./types-taler-common.js";
r1.merchantBaseUrl,
"https://example.com/" as HostPortPath,
);
- assert.strictEqual(r1.sessionId, "");
+ assert.strictEqual(r1.sessionId, undefined);
const url2 = "taler://pay/example.com/myorder/mysession";
const r2 = Result.orUndefined(
@@ -192,7 +192,7 @@ import { AmountString } from "./types-taler-common.js";
type: TalerUriAction.Pay,
merchantBaseUrl: "http://localhost:123/" as HostPortPath,
orderId: "foo",
- sessionId: "",
+ sessionId: undefined,
});
assert.deepStrictEqual(url1, "taler+http://pay/localhost:123/foo/");
@@ -210,7 +210,7 @@ import { AmountString } from "./types-taler-common.js";
type: TalerUriAction.Pay,
merchantBaseUrl: "https://localhost:123/" as HostPortPath,
orderId: "foo",
- sessionId: "",
+ sessionId: undefined,
});
assert.deepStrictEqual(url1, "taler://pay/localhost:123/foo/");
diff --git a/packages/taler-util/src/taleruri.ts b/packages/taler-util/src/taleruri.ts
@@ -342,9 +342,11 @@ export namespace TalerUris {
p.withdrawalOperationId,
)}`;
case TalerUriAction.Pay:
+ // The session ID is the last path component of the URI and thus
+ // cannot be omitted; an empty component means "no session".
return `/${asHost(p.merchantBaseUrl)}${encodeUriSegment(
p.orderId,
- )}/${encodeUriSegment(p.sessionId)}`;
+ )}/${encodeUriSegment(p.sessionId ?? "")}`;
case TalerUriAction.Refund:
// refund should end with a /
return `/${asHost(p.merchantBaseUrl)}${encodeUriSegment(p.orderId)}/`;
@@ -648,8 +650,9 @@ function parsePay(
// get order
const orderId = decodeUriSegment(cs[cs.length - 2]);
- // get session
- const sessionId = decodeUriSegment(cs[cs.length - 1]);
+ // get session; the merchant emits an empty last component for orders
+ // that are not bound to a session, which is not a session ID
+ const sessionId = decodeUriSegment(cs[cs.length - 1]) || undefined;
let nfc: boolean | undefined;
if ("nfc" in params) {
@@ -1107,7 +1110,12 @@ export interface TalerPayUriResult {
type: TalerUriAction.Pay;
merchantBaseUrl: HostPortPath;
orderId: string;
- sessionId: string;
+ /**
+ * Session the payment is bound to. Undefined if the payment is not
+ * bound to any session, which the URI encodes as an empty last path
+ * component.
+ */
+ sessionId?: string;
claimToken?: string;
/**
* Nonce priv, only present in the
diff --git a/packages/taler-util/src/taleruris.test.ts b/packages/taler-util/src/taleruris.test.ts
@@ -105,7 +105,7 @@ test("taler-new pay url parsing: defaults", (t) => {
r1.merchantBaseUrl,
"https://example.com/" as HostPortPath,
);
- assert.strictEqual(r1.sessionId, "");
+ assert.strictEqual(r1.sessionId, undefined);
const url2 = "taler://pay/example.com/myorder/mysession";
const r2 = Result.unpack(TalerUris.parse(url2));
@@ -170,7 +170,7 @@ test("taler-new pay URI (stringify)", (t) => {
type: TalerUriAction.Pay,
merchantBaseUrl: "http://localhost:123/" as HostPortPath,
orderId: "foo",
- sessionId: "",
+ sessionId: undefined,
});
assert.deepStrictEqual(url1, "taler+http://pay/localhost:123/foo/");
@@ -188,7 +188,7 @@ test("taler-new pay URI (stringify with https)", (t) => {
type: TalerUriAction.Pay,
merchantBaseUrl: "https://localhost:123/" as HostPortPath,
orderId: "foo",
- sessionId: "",
+ sessionId: undefined,
});
assert.deepStrictEqual(url1, "taler://pay/localhost:123/foo/");
diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts
@@ -3369,11 +3369,18 @@ async function processPurchasePay(
}
await storeFirstPaySuccess(wex, proposalId, sessionId, merchantResp);
+ } else if (!sessionId) {
+ // There is no session to bind the payment to, and /paid only makes
+ // sense for an actual session (the merchant rejects an empty session
+ // ID). The payment itself is already done, so there is nothing to
+ // demonstrate to the merchant here.
+ logger.trace(`skipping /paid, payment is not bound to a session`);
+ await storePayReplaySuccess(wex, proposalId, undefined);
} else {
const reqBody = {
sig: purchase.merchantPaySig,
h_contract: download.contractTermsHash,
- session_id: sessionId ?? "",
+ session_id: sessionId,
};
logger.trace(`/paid request body: ${j2s(reqBody)}`);
const merchantClient = walletMerchantClient(
@@ -3942,7 +3949,7 @@ export async function sharePayment(
type: TalerUriAction.Pay,
merchantBaseUrl: merchantBaseUrl as HostPortPath, // FIXME: change function argument
orderId,
- sessionId: result.session ?? "",
+ sessionId: result.session,
noncePriv: result.nonce,
claimToken: result.token,
});