taler-typescript-core

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

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:
Mpackages/taler-util/src/http-client/merchant.ts | 4+++-
Mpackages/taler-util/src/taleruri.test.ts | 6+++---
Mpackages/taler-util/src/taleruri.ts | 16++++++++++++----
Mpackages/taler-util/src/taleruris.test.ts | 6+++---
Mpackages/taler-wallet-core/src/pay-merchant.ts | 11+++++++++--
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, });