commit 7b491fb2da4304c5d3335df93d10e8cef000cb46 parent e9a0a5339a292f130909329b1caaf5c4a1a2c706 Author: Florian Dold <dold@taler.net> Date: Thu, 10 Sep 2026 21:53:30 +0200 merchant web UI: configure login token lifetime, default to forever Accept duration strings and protocol durations in webui-config.json. Use the configured lifetime for sign-in, account creation, and renewal, while retaining local developer overrides and finite renewal scheduling. Encode unlimited self-provisioning validity as unsigned milliseconds. Diffstat:
16 files changed, 445 insertions(+), 69 deletions(-)
diff --git a/packages/taler-merchant-webui/README.md b/packages/taler-merchant-webui/README.md @@ -20,7 +20,8 @@ The application reads `webui-config.json` beside its static assets: { "experimental": false, "merchant_base_url": "https://merchant.example.com/", - "merchant_base_url_configurable": true + "merchant_base_url_configurable": true, + "login_token_lifetime": "forever" } ``` @@ -29,6 +30,19 @@ The application reads `webui-config.json` beside its static assets: - `merchant_base_url_configurable` controls whether sign-in/sign-up may change that URL. +- `login_token_lifetime` requests the lifetime of WebUI login tokens at sign-in, + account creation, and renewal. It defaults to `"forever"`. Accepts a duration + string such as `"30m"` or `"2d"`, or a `TalerProtocolDuration` object such as + `{ "d_us": 1800000000 }` or `{ "d_us": "forever" }`. Finite durations must be + positive safe integers in microseconds. The alias `loginTokenLifetime` is also + accepted; the snake-case field takes precedence. + +The backend may grant a shorter lifetime; its returned expiration is authoritative. +Existing sessions use the configured lifetime at their next renewal or sign-in. +The Developer Settings token lifetime override takes precedence locally; selecting +“Deployment default” clears it. This setting does not change separately created +API access tokens. + Developer Settings at `/#/dev` can override these values locally for testing. ## Structure diff --git a/packages/taler-merchant-webui/src/api/tokenRefresh.test.ts b/packages/taler-merchant-webui/src/api/tokenRefresh.test.ts @@ -46,11 +46,12 @@ import { signOut, unauthorizedError, } from "../stores/session.js"; +import { updateDevSettings } from "../stores/devSettings.js"; import { - updateDevSettings, - DEFAULT_LOGIN_TOKEN_LIFETIME_MINUTES, -} from "../stores/devSettings.js"; -import { webUiConfig } from "../stores/webuiConfig.js"; + FALLBACK_TOKEN_REFRESH_LIFETIME_MINUTES, + loginTokenLifetime, +} from "../stores/loginTokenLifetime.js"; +import { webUiConfig, parseWebUiConfig } from "../stores/webuiConfig.js"; const DAY_MINUTES = 24 * 60; @@ -132,13 +133,15 @@ test("the check interval tracks the lifetime, within bounds", () => { assert.ok(refreshCheckIntervalMs(5) >= 5_000); // A two-day token is not polled every few seconds. assert.equal( - refreshCheckIntervalMs(DEFAULT_LOGIN_TOKEN_LIFETIME_MINUTES), + refreshCheckIntervalMs(FALLBACK_TOKEN_REFRESH_LIFETIME_MINUTES), 30_000, ); }); -test("the default lifetime is 48 hours", () => { - assert.equal(DEFAULT_LOGIN_TOKEN_LIFETIME_MINUTES, 48 * 60); +test("the default lifetime is forever with a finite refresh fallback", () => { + reset(); + assert.deepEqual(loginTokenLifetime.value, { d_us: "forever" }); + assert.equal(FALLBACK_TOKEN_REFRESH_LIFETIME_MINUTES, 48 * 60); }); test("a due token is renewed and the new one replaces it", async () => { @@ -419,3 +422,53 @@ test("the requested lifetime follows the dev setting", async () => { reset(); } }); + +for (const [configured, expectedUs] of [ + [undefined, "forever"], + ["30m", 1_800_000_000], + [{ d_us: 3_600_000_000 }, 3_600_000_000], + [{ d_us: "forever" }, "forever"], +] as const) { + test(`renewal requests configured lifetime ${JSON.stringify(configured)}`, async () => { + reset(); + webUiConfig.value = parseWebUiConfig({ login_token_lifetime: configured }); + const http = new FakeHttpLib().on( + "POST", + "/private/token", + ok({ + access_token: "secret-token:forever", + token: "secret-token:forever", + scope: "spa:refreshable", + refreshable: true, + expiration: { t_s: "never" }, + }), + ); + const restore = useHttpLibForTesting(http); + try { + signIn( + "sandbox", + "secret-token:old" as AccessToken, + "https://backend.example.test/", + { + expiresS: Math.floor(Date.now() / 1000) + 60, + refreshable: true, + scope: "spa:refreshable", + }, + ); + assert.equal(await maybeRefreshLoginToken(), "refreshed"); + assert.deepEqual( + (http.lastRequest!.body as { duration: unknown }).duration, + { d_us: expectedUs }, + ); + resetRecoveryBudget(); + assert.equal(await maybeRefreshLoginToken(), "skipped"); + assert.equal(http.requests.length, 1); + assert.equal(getLoginTokenStatus().expired, false); + assert.equal(await maybeRefreshLoginToken({ force: true }), "refreshed"); + assert.equal(http.requests.length, 2); + } finally { + restore(); + reset(); + } + }); +} diff --git a/packages/taler-merchant-webui/src/api/tokenRefresh.ts b/packages/taler-merchant-webui/src/api/tokenRefresh.ts @@ -40,9 +40,10 @@ import { updateLoginToken, } from "../stores/session.js"; import { - loginTokenLifetimeMinutes, - tokenRefreshEnabled, -} from "../stores/devSettings.js"; + loginTokenLifetime, + tokenRefreshLifetimeMinutes, +} from "../stores/loginTokenLifetime.js"; +import { tokenRefreshEnabled } from "../stores/devSettings.js"; /** * Renew once half the lifetime is gone. @@ -127,7 +128,7 @@ export async function maybeRefreshLoginToken( // 401 handling sign the merchant out with an explanation. if (!status.refreshable) return "skipped"; - const lifetime = loginTokenLifetimeMinutes.value; + const lifetime = tokenRefreshLifetimeMinutes.value; if (!options.force && !isRefreshDue(status.secondsRemaining, lifetime)) { return "skipped"; } @@ -162,7 +163,7 @@ export async function maybeRefreshLoginToken( "", { scope: LoginTokenScope.Spa_Refreshable, - duration: { d_us: lifetime * 60 * 1_000_000 }, + duration: loginTokenLifetime.value, description: options.t ? options.t`Merchant portal session renewal` : "Merchant portal session renewal", @@ -327,9 +328,10 @@ export function useLoginTokenRefresh(): void { const hasToken = session.value.token !== undefined; const refreshable = session.value.tokenRefreshable === true; const enabled = tokenRefreshEnabled.value; - const lifetime = loginTokenLifetimeMinutes.value; + const expiresS = session.value.tokenExpiresS; + const lifetime = tokenRefreshLifetimeMinutes.value; useEffect(() => { - if (!hasToken || !refreshable || !enabled) return; + if (!hasToken || !refreshable || !enabled || expiresS === undefined) return; let stopped = false; const tick = (): void => { @@ -355,5 +357,5 @@ export function useLoginTokenRefresh(): void { clearInterval(handle); document.removeEventListener("visibilitychange", onVisible); }; - }, [hasToken, refreshable, enabled, lifetime, t]); + }, [hasToken, refreshable, enabled, expiresS, lifetime, t]); } diff --git a/packages/taler-merchant-webui/src/reviewFixes.test.ts b/packages/taler-merchant-webui/src/reviewFixes.test.ts @@ -34,6 +34,7 @@ test("configuration booleans are decoded strictly", () => { parseWebUiConfig({ merchant_base_url_configurable: false }), { experimental: undefined, + login_token_lifetime: undefined, merchant_base_url: undefined, merchant_base_url_configurable: false, currency: undefined, diff --git a/packages/taler-merchant-webui/src/routes/SelfProvisionRoute.tsx b/packages/taler-merchant-webui/src/routes/SelfProvisionRoute.tsx @@ -20,7 +20,7 @@ import { Duration, TalerMerchantApi, } from "@gnu-taler/taler-util"; -import { loginTokenLifetimeMinutes } from "../stores/devSettings.js"; +import { loginTokenLifetime } from "../stores/loginTokenLifetime.js"; import { merchantManagementClient, sendTanChallenge, @@ -150,18 +150,12 @@ export async function provision( address: {}, jurisdiction: {}, }, - req.challengeIds && req.challengeIds.length > 0 - ? { - challengeIds: req.challengeIds, - tokenValidity: Duration.fromMilliseconds( - loginTokenLifetimeMinutes.value * 60 * 1000, - ), - } - : { - tokenValidity: Duration.fromMilliseconds( - loginTokenLifetimeMinutes.value * 60 * 1000, - ), - }, + { + challengeIds: req.challengeIds?.length ? req.challengeIds : undefined, + tokenValidity: Duration.fromTalerProtocolDuration( + loginTokenLifetime.value, + ), + }, ); if (resp.type === "fail") { diff --git a/packages/taler-merchant-webui/src/routes/SignInRoute.tsx b/packages/taler-merchant-webui/src/routes/SignInRoute.tsx @@ -22,7 +22,7 @@ import { } from "@gnu-taler/taler-util"; import { merchantClient } from "../api/client.js"; import { normalizeToken } from "../stores/session.js"; -import { loginTokenLifetimeMinutes } from "../stores/devSettings.js"; +import { loginTokenLifetime } from "../stores/loginTokenLifetime.js"; import { handleRouteSendChallenge, handleRouteSolveChallenge, @@ -131,7 +131,7 @@ export async function authenticate( // "spa" is the scope for this portal; refreshable so the session can be // extended without asking for the password again. scope: LoginTokenScope.All_Refreshable, - duration: { d_us: loginTokenLifetimeMinutes.value * 60 * 1_000_000 }, + duration: loginTokenLifetime.value, description: t ? t`Merchant portal sign-in` : "Merchant portal sign-in", }, challengeIds && challengeIds.length > 0 ? { challengeIds } : {}, diff --git a/packages/taler-merchant-webui/src/routes/selfProvision.test.ts b/packages/taler-merchant-webui/src/routes/selfProvision.test.ts @@ -33,6 +33,9 @@ import { useHttpLibForTesting } from "../api/client.js"; import { provision, signupPolicy } from "./SelfProvisionRoute.js"; import { isValidInstanceId } from "../screens/SelfProvisionScreen.js"; +import { webUiConfig, parseWebUiConfig } from "../stores/webuiConfig.js"; +import { resetDevSettings } from "../stores/devSettings.js"; + const BACKEND = "https://backend.example.test/"; /** A 200 from self-provisioning carries a login token, like /private/token. */ @@ -347,3 +350,44 @@ test("a token refused for any other reason still reports the account as created" restore(); } }); + +for (const [configured, expectedUs] of [ + [undefined, "forever"], + ["forever", "forever"], + ["30m", 1_800_000_000], + [{ d_us: 3_600_000_000 }, 3_600_000_000], + [{ d_us: "forever" }, "forever"], +] as const) { + for (const challengeIds of [undefined, ["CH-EMAIL"]]) { + test(`provision uses configured lifetime ${JSON.stringify(configured)} with challenges ${!!challengeIds}`, async () => { + resetDevSettings(); + webUiConfig.value = parseWebUiConfig({ + login_token_lifetime: configured, + }); + const http = new FakeHttpLib().on( + "POST", + "/instances", + ok(issuedToken()), + ); + const restore = useHttpLibForTesting(http); + try { + const res = await provision({ ...REQ, challengeIds }); + assert.equal(res.type, "ok", JSON.stringify(res)); + assert.equal( + new URL(http.lastRequest!.url).searchParams.get("token_validity_ms"), + expectedUs === "forever" + ? "18446744073709551615" + : String(expectedUs / 1000), + ); + assert.equal( + http.lastRequest!.headers?.["Taler-Challenge-Ids"], + challengeIds?.join(", "), + ); + } finally { + restore(); + resetDevSettings(); + webUiConfig.value = {}; + } + }); + } +} diff --git a/packages/taler-merchant-webui/src/routes/signIn.test.ts b/packages/taler-merchant-webui/src/routes/signIn.test.ts @@ -37,6 +37,9 @@ import { import { useHttpLibForTesting } from "../api/client.js"; import { authenticate } from "./SignInRoute.js"; +import { webUiConfig, parseWebUiConfig } from "../stores/webuiConfig.js"; +import { resetDevSettings } from "../stores/devSettings.js"; + const BACKEND = "https://backend.example.test/"; const PASSWORD = "correct horse battery staple"; @@ -301,3 +304,45 @@ test("a malformed backend URL fails instead of throwing", async () => { }); assert.equal(res.type, "fail"); }); + +for (const [configured, expectedUs] of [ + [undefined, "forever"], + ["forever", "forever"], + ["30m", 1_800_000_000], + [{ d_us: 3_600_000_000 }, 3_600_000_000], + [{ d_us: "forever" }, "forever"], +] as const) { + for (const challengeIds of [undefined, ["CH-EMAIL"]]) { + test(`authenticate uses configured lifetime ${JSON.stringify(configured)} with challenges ${!!challengeIds}`, async () => { + resetDevSettings(); + webUiConfig.value = parseWebUiConfig({ + login_token_lifetime: configured, + }); + const http = new FakeHttpLib().on( + "POST", + "/private/token", + ok(tokenResponse()), + ); + const restore = useHttpLibForTesting(http); + try { + const res = await authenticate({ + ...{ account: "sandbox", secret: PASSWORD, backendUrl: BACKEND }, + challengeIds, + }); + assert.equal(res.type, "ok", JSON.stringify(res)); + assert.deepEqual( + (http.lastRequest!.body as { duration: unknown }).duration, + { d_us: expectedUs }, + ); + assert.equal( + http.lastRequest!.headers?.["Taler-Challenge-Ids"], + challengeIds?.join(", "), + ); + } finally { + restore(); + resetDevSettings(); + webUiConfig.value = {}; + } + }); + } +} diff --git a/packages/taler-merchant-webui/src/screens/DevSettingsScreen.tsx b/packages/taler-merchant-webui/src/screens/DevSettingsScreen.tsx @@ -21,7 +21,6 @@ import { hasActiveDevOverrides, resetDevSettings, updateDevSettings, - loginTokenLifetimeMinutes, tokenRefreshEnabled, } from "../stores/devSettings.js"; import { @@ -36,6 +35,11 @@ import { maybeRefreshLoginToken, REFRESH_AT_FRACTION_REMAINING, } from "../api/tokenRefresh.js"; +import { Duration } from "@gnu-taler/taler-util"; +import { + deploymentLoginTokenLifetime, + tokenRefreshLifetimeMinutes, +} from "../stores/loginTokenLifetime.js"; import { webUiConfig } from "../stores/webuiConfig.js"; import { TalerLogo } from "../ui/TalerLogo.js"; import { useTranslation, type TranslateFn } from "../context/translation.js"; @@ -253,14 +257,18 @@ function LoginTokenPanel(): VNode { const status = getLoginTokenStatus(); const token = session.value.token; - const lifetime = loginTokenLifetimeMinutes.value; + const lifetime = tokenRefreshLifetimeMinutes.value; + const override = devSettings.value.loginTokenLifetimeMinutes; + const deploymentLifetime = Duration.formatShort( + deploymentLoginTokenLifetime.value, + ); const refreshOn = tokenRefreshEnabled.value; const lifetimeChoices = [ { minutes: 5, label: t`5 minutes (for testing expiry)` }, { minutes: 60, label: t`1 hour` }, { minutes: 12 * 60, label: t`12 hours` }, { minutes: 24 * 60, label: t`24 hours` }, - { minutes: 48 * 60, label: t`48 hours (default)` }, + { minutes: 48 * 60, label: t`48 hours` }, { minutes: 7 * 24 * 60, label: t`7 days` }, ]; @@ -314,7 +322,7 @@ function LoginTokenPanel(): VNode { <dt class="text-gray-500">{t`Expires`}</dt> <dd class="font-mono text-gray-900"> {status.expiresS === undefined - ? t`unknown (a pasted credential)` + ? t`Never or unknown (a pasted credential)` : new Date(status.expiresS * 1000).toLocaleString()} </dd> @@ -403,17 +411,23 @@ function LoginTokenPanel(): VNode { </label> <select id="dev-token-lifetime" - value={String(lifetime)} + value={override === undefined ? "" : String(override)} onChange={(e) => updateDevSettings((s) => ({ ...s, - loginTokenLifetimeMinutes: Number( - (e.target as HTMLSelectElement).value, - ), + loginTokenLifetimeMinutes: + e.currentTarget.value === "" + ? undefined + : Number(e.currentTarget.value), })) } class="w-full px-3 py-2 border border-gray-300 rounded-md text-xs bg-white text-gray-900 focus:ring-2 focus:ring-blue-500 focus:outline-none" > + <option value="">{t`Deployment default (${deploymentLifetime})`}</option> + {override !== undefined && + !lifetimeChoices.some((c) => c.minutes === override) && ( + <option value={String(override)}>{String(override)}</option> + )} {lifetimeChoices.map((c) => ( <option key={c.minutes} value={String(c.minutes)}> {c.label} diff --git a/packages/taler-merchant-webui/src/stores/devSettings.ts b/packages/taler-merchant-webui/src/stores/devSettings.ts @@ -24,15 +24,6 @@ import { import { computed, type Signal } from "@preact/signals"; import { persistedSignal } from "./persisted.js"; -/** - * How long a login token is asked to live. - * - * Two days covers a shift plus the next morning, so the ordinary merchant is - * never asked to sign in again mid-task. The backend may impose its own upper - * bound and answers with what it actually granted. - */ -export const DEFAULT_LOGIN_TOKEN_LIFETIME_MINUTES = 48 * 60; - export interface DevSettings { showExperimentalFeatures?: boolean; verboseLogging?: boolean; @@ -68,29 +59,10 @@ export const hasActiveDevOverrides = computed(() => { v.verboseLogging || v.disablePasswordLengthCheck || v.disableTokenRefresh || - (v.loginTokenLifetimeMinutes !== undefined && - v.loginTokenLifetimeMinutes !== DEFAULT_LOGIN_TOKEN_LIFETIME_MINUTES) + v.loginTokenLifetimeMinutes !== undefined ); }); -/** - * The lifetime to ask for, in minutes. - * - * A stored value of zero or less would ask for a token that is already dead, so - * it falls back to the default rather than locking the merchant out. - */ -export const loginTokenLifetimeMinutes = computed(() => { - const configured = devSettings.value.loginTokenLifetimeMinutes; - if ( - configured === undefined || - !Number.isFinite(configured) || - configured <= 0 - ) { - return DEFAULT_LOGIN_TOKEN_LIFETIME_MINUTES; - } - return configured; -}); - /** Whether the portal renews the login token by itself. */ export const tokenRefreshEnabled = computed( () => !devSettings.value.disableTokenRefresh, diff --git a/packages/taler-merchant-webui/src/stores/loginTokenLifetime.test.ts b/packages/taler-merchant-webui/src/stores/loginTokenLifetime.test.ts @@ -0,0 +1,69 @@ +/* + 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 test from "node:test"; +import assert from "node:assert/strict"; +import { + devSettings, + hasActiveDevOverrides, + resetDevSettings, + updateDevSettings, +} from "./devSettings.js"; +import { webUiConfig } from "./webuiConfig.js"; +import { + loginTokenLifetime, + tokenRefreshLifetimeMinutes, +} from "./loginTokenLifetime.js"; + +test("login lifetime follows deployment config and local override changes", () => { + resetDevSettings(); + webUiConfig.value = {}; + try { + assert.deepEqual(loginTokenLifetime.value, { d_us: "forever" }); + assert.equal(tokenRefreshLifetimeMinutes.value, 48 * 60); + webUiConfig.value = { login_token_lifetime: "2d" }; + assert.deepEqual(loginTokenLifetime.value, { d_us: 172_800_000_000 }); + updateDevSettings((s) => ({ ...s, loginTokenLifetimeMinutes: 5 })); + assert.equal(hasActiveDevOverrides.value, true); + assert.deepEqual(loginTokenLifetime.value, { d_us: 300_000_000 }); + assert.equal(tokenRefreshLifetimeMinutes.value, 5); + updateDevSettings((s) => ({ ...s, loginTokenLifetimeMinutes: undefined })); + assert.deepEqual(loginTokenLifetime.value, { d_us: 172_800_000_000 }); + assert.equal(hasActiveDevOverrides.value, false); + webUiConfig.value = { login_token_lifetime: { d_us: 1001 } }; + assert.deepEqual(loginTokenLifetime.value, { d_us: 1001 }); + updateDevSettings((s) => ({ ...s, loginTokenLifetimeMinutes: 48 * 60 })); + assert.equal(hasActiveDevOverrides.value, true); + resetDevSettings(); + assert.deepEqual(loginTokenLifetime.value, { d_us: 1001 }); + } finally { + resetDevSettings(); + webUiConfig.value = {}; + } +}); + +test("invalid persisted overrides fall back to deployment config", () => { + webUiConfig.value = { login_token_lifetime: "1h" }; + try { + for (const minutes of [0, -1, NaN, Infinity, Number.MAX_SAFE_INTEGER]) { + devSettings.value = { loginTokenLifetimeMinutes: minutes }; + assert.deepEqual(loginTokenLifetime.value, { d_us: 3_600_000_000 }); + } + } finally { + resetDevSettings(); + webUiConfig.value = {}; + } +}); diff --git a/packages/taler-merchant-webui/src/stores/loginTokenLifetime.ts b/packages/taler-merchant-webui/src/stores/loginTokenLifetime.ts @@ -0,0 +1,48 @@ +/* + 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 { computed } from "@preact/signals"; +import { type TalerProtocolDuration } from "@gnu-taler/taler-util"; +import { devSettings } from "./devSettings.js"; +import { parseLoginTokenLifetime, webUiConfig } from "./webuiConfig.js"; + +export const deploymentLoginTokenLifetime = computed<TalerProtocolDuration>( + () => + parseLoginTokenLifetime( + webUiConfig.value.login_token_lifetime ?? "forever", + ), +); + +/** A local testing override takes precedence over the deployment default. */ +export const loginTokenLifetime = computed<TalerProtocolDuration>(() => { + const minutes = devSettings.value.loginTokenLifetimeMinutes; + if (minutes !== undefined) { + const microseconds = minutes * 60 * 1_000_000; + if (Number.isSafeInteger(microseconds) && microseconds > 0) { + return { d_us: microseconds }; + } + } + return deploymentLoginTokenLifetime.value; +}); + +/** Retain the finite renewal schedule if the backend caps an unlimited request. */ +export const FALLBACK_TOKEN_REFRESH_LIFETIME_MINUTES = 48 * 60; +export const tokenRefreshLifetimeMinutes = computed(() => { + const duration = loginTokenLifetime.value; + return duration.d_us === "forever" + ? FALLBACK_TOKEN_REFRESH_LIFETIME_MINUTES + : duration.d_us / (60 * 1_000_000); +}); diff --git a/packages/taler-merchant-webui/src/stores/webuiConfig.test.ts b/packages/taler-merchant-webui/src/stores/webuiConfig.test.ts @@ -79,3 +79,68 @@ test("a fixed deployment cannot be overridden by developer settings", () => { allowCustomBackendUrl.value = false; devSettings.value = {}; }); + +for (const [input, expected] of [ + ["forever", { d_us: "forever" }], + ["30m", { d_us: 1_800_000_000 }], + ["2d", { d_us: 172_800_000_000 }], + ["1us", { d_us: 1 }], + [{ d_us: "forever" }, { d_us: "forever" }], + [{ d_us: 1001 }, { d_us: 1001 }], +] as const) { + test(`login lifetime config accepts ${JSON.stringify(input)}`, () => { + assert.deepEqual( + parseWebUiConfig({ login_token_lifetime: input }).login_token_lifetime, + expected, + ); + assert.deepEqual( + parseWebUiConfig({ loginTokenLifetime: input }).login_token_lifetime, + expected, + ); + }); +} + +test("login lifetime snake-case config takes precedence", () => { + assert.deepEqual( + parseWebUiConfig({ + login_token_lifetime: "2d", + loginTokenLifetime: "invalid", + }).login_token_lifetime, + { d_us: 172_800_000_000 }, + ); + assert.throws( + () => + parseWebUiConfig({ + login_token_lifetime: null, + loginTokenLifetime: "forever", + }), + /login_token_lifetime/, + ); +}); + +for (const input of [ + null, + true, + 10, + [], + {}, + "", + "nope", + "0s", + "-1h", + "9007199254740992us", + { d_us: 0 }, + { d_us: -1 }, + { d_us: 0.5 }, + { d_us: Infinity }, + { d_us: NaN }, + { d_us: Number.MAX_SAFE_INTEGER + 1 }, + { d_us: "never" }, +]) { + test(`login lifetime rejects ${JSON.stringify(input)}`, () => { + assert.throws( + () => parseWebUiConfig({ login_token_lifetime: input }), + /login_token_lifetime/, + ); + }); +} diff --git a/packages/taler-merchant-webui/src/stores/webuiConfig.ts b/packages/taler-merchant-webui/src/stores/webuiConfig.ts @@ -17,6 +17,9 @@ import { signal, computed } from "@preact/signals"; import { codecForBoolean, + codecForDuration, + Duration, + type TalerProtocolDuration, codecForTalerMerchantConfigResponse, } from "@gnu-taler/taler-util"; import { devSettings } from "./devSettings.js"; @@ -24,6 +27,7 @@ import { persistedSignal } from "./persisted.js"; export interface WebUiConfig { experimental?: boolean; + login_token_lifetime?: string | TalerProtocolDuration; merchant_base_url?: string; merchant_base_url_configurable?: boolean; currency?: string; @@ -54,6 +58,27 @@ export function setAllowCustomBackendUrl(allow?: boolean): void { allowCustomBackendUrlStore.set(next); } +/** Normalize both supported config formats to the duration sent to the backend. */ +export function parseLoginTokenLifetime(data: unknown): TalerProtocolDuration { + try { + const duration = + typeof data === "string" + ? Duration.toTalerProtocolDuration(Duration.fromPrettyString(data)) + : codecForDuration.decode(data); + if ( + duration.d_us !== "forever" && + (!Number.isSafeInteger(duration.d_us) || duration.d_us <= 0) + ) { + throw new Error("expected a positive duration"); + } + return duration; + } catch { + throw new Error( + "login_token_lifetime must be a positive duration string or TalerProtocolDuration, or forever", + ); + } +} + export function parseWebUiConfig(data: unknown): WebUiConfig { if (!data || typeof data !== "object" || Array.isArray(data)) { throw new Error("webui-config.json must contain an object"); @@ -75,7 +100,13 @@ export function parseWebUiConfig(data: unknown): WebUiConfig { if (typeof value !== "string") throw new Error(`${snake} must be a string`); return value; }; + const lifetime = + raw.login_token_lifetime !== undefined + ? raw.login_token_lifetime + : raw.loginTokenLifetime; return { + login_token_lifetime: + lifetime === undefined ? undefined : parseLoginTokenLifetime(lifetime), experimental: optionalBoolean("experimental", "experimental"), merchant_base_url: optionalString("merchant_base_url", "merchantBaseUrl"), merchant_base_url_configurable: optionalBoolean( diff --git a/packages/taler-util/src/http-client/merchant-management.test.ts b/packages/taler-util/src/http-client/merchant-management.test.ts @@ -263,3 +263,23 @@ test("management KYC accepts an empty no-content response", async () => { assert.strictEqual(kyc.type, "ok"); assert.strictEqual(kyc.body, undefined); }); + +test("self-provisioning encodes finite and unlimited token validity as integer milliseconds", async () => { + const http = new FakeHttpLib().on("POST", "/instances", noContent()); + const client = new TalerMerchantManagementHttpClient(baseUrl, http); + for (const [d_ms, expected] of [ + ["forever", "18446744073709551615"], + [300_000, "300000"], + [1.001, "2"], + [0.001, "1"], + ] as const) { + const result = await client.createInstanceSelfProvision(configuration, { + tokenValidity: { d_ms }, + }); + assert.equal(result.type, "ok"); + assert.equal( + new URL(http.lastRequest!.url).searchParams.get("token_validity_ms"), + expected, + ); + } +}); diff --git a/packages/taler-util/src/http-client/merchant.ts b/packages/taler-util/src/http-client/merchant.ts @@ -3972,7 +3972,11 @@ export class TalerMerchantManagementHttpClient extends TalerMerchantInstanceHttp if (params.tokenValidity) { url.searchParams.append( "token_validity_ms", - String(params.tokenValidity.d_ms), + // The backend parses unsigned integer milliseconds, with saturation + // to GNUNET_TIME_UNIT_FOREVER_REL for the maximum uint64 value. + params.tokenValidity.d_ms === "forever" + ? "18446744073709551615" + : String(Math.ceil(params.tokenValidity.d_ms)), ); } const resp = await this.httpLib.fetch(url.href, {