taler-typescript-core

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

commit d2192da12ca9a34ec78458420f3d35fd0d39e875
parent 2bbf1822cfd725d9360161d233e8cdf105ed9940
Author: Florian Dold <dold@taler.net>
Date:   Fri, 11 Sep 2026 14:54:33 +0200

merchant-webui: reveal machine access tokens after MFA

Keep newly issued machine access tokens in application memory across
second-factor verification and return to the copy dialog. Remove the
token from the redirect URL and clear it when the creation flow or
initiating session ends.

Diffstat:
Mpackages/taler-merchant-webui/src/App.tsx | 30++++++++++++++++++++++++++++++
Mpackages/taler-merchant-webui/src/routes/CreateAccessRoute.tsx | 10++++++++--
Apackages/taler-merchant-webui/src/routes/createAccess.test.tsx | 303+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-merchant-webui/src/screens/CreateAccessScreen.tsx | 12++++++++++--
4 files changed, 351 insertions(+), 4 deletions(-)

diff --git a/packages/taler-merchant-webui/src/App.tsx b/packages/taler-merchant-webui/src/App.tsx @@ -183,6 +183,7 @@ import { lastMerchantAccountStore, customBackendUrl, type LoginTokenInfo, + type SessionIdentity, captureSessionIdentity, isCurrentSessionIdentity, } from "./stores/session.js"; @@ -291,6 +292,24 @@ export function AppContent(): VNode { setIssuedPairingCredential(null); }, [pairingAccount, pairingBackend]); + // Hand the one-time machine token across the MFA route in memory only. + // Bind it to the initiating session, including while issuance is in flight. + const creationIdentity = captureSessionIdentity(); + const [issuedAccessToken, setIssuedAccessToken] = useState<{ + token: string; + identity: SessionIdentity; + } | null>(null); + const accessTokenIsCurrent = + issuedAccessToken !== null && + isCurrentSessionIdentity(issuedAccessToken.identity); + const inAccessCreationFlow = + location === "/access/new" || location === "/money/payout-accounts/mfa"; + useEffect(() => { + if (!accessTokenIsCurrent || !inAccessCreationFlow) { + setIssuedAccessToken(null); + } + }, [accessTokenIsCurrent, inAccessCreationFlow]); + const handleSignIn = (data: { account: string; token: string; @@ -698,6 +717,17 @@ export function AppContent(): VNode { </Route> <Route path="/access/new"> <CreateAccessRoute + issuedToken={accessTokenIsCurrent ? issuedAccessToken.token : null} + onIssuedTokenChange={(token) => { + if (token === null) { + setIssuedAccessToken(null); + } else if ( + creationIdentity && + isCurrentSessionIdentity(creationIdentity) + ) { + setIssuedAccessToken({ token, identity: creationIdentity }); + } + }} onMfaRequired={(pending) => { beginPendingMfa(pending); setLocation("/money/payout-accounts/mfa"); diff --git a/packages/taler-merchant-webui/src/routes/CreateAccessRoute.tsx b/packages/taler-merchant-webui/src/routes/CreateAccessRoute.tsx @@ -26,10 +26,14 @@ import type { PendingProtectedAction } from "../api/protectedAction.js"; import { runProtectedMutation } from "../api/contracts.js"; export interface CreateAccessRouteProps { + issuedToken: string | null; + onIssuedTokenChange: (token: string | null) => void; onMfaRequired: (pending: PendingProtectedAction) => void; } export function CreateAccessRoute({ + issuedToken, + onIssuedTokenChange, onMfaRequired, }: CreateAccessRouteProps): VNode { const { t } = useTranslation(); @@ -38,6 +42,8 @@ export function CreateAccessRoute({ return ( <CreateAccessScreen + generatedToken={issuedToken} + onGeneratedTokenChange={onIssuedTokenChange} onSave={async (data) => { if (hasToken && createToken) { const scope = loginTokenScope(data.canDo, data.isRefreshable); @@ -79,8 +85,8 @@ export function CreateAccessRoute({ t`The backend did not return a machine access token.`, ); } - const redirectTo = `/access?created_token=${encodeURIComponent(continued.token)}`; - return { redirectTo }; + onIssuedTokenChange(continued.token); + return { redirectTo: "/access/new" }; }), }); return { mfaRequired: true }; diff --git a/packages/taler-merchant-webui/src/routes/createAccess.test.tsx b/packages/taler-merchant-webui/src/routes/createAccess.test.tsx @@ -0,0 +1,303 @@ +/* + 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 assert from "node:assert"; +import test from "node:test"; +import { render } from "preact"; +import { act } from "preact/test-utils"; +import { Router } from "wouter-preact"; +import { AppContent } from "../App.js"; +import { useHashLocation } from "../routing/useHashLocation.js"; +import { useHttpLibForTesting } from "../api/client.js"; +import { + FakeHttpLib, + conflict, + noContent, + ok, + type HandlerResult, +} from "../testing/fake-http.js"; +import { customBackendUrlStore, signIn, signOut } from "../stores/session.js"; + +const issuedToken = "secret-token:machine-issued-by-backend"; +const backend = "https://merchant.example.com/"; +const issued = ok({ + access_token: issuedToken, + scope: "order-simple", + expiration: { t_s: 1_800_000_000 }, + refreshable: false, +}); +const challenge = (id = "CH-1"): HandlerResult => ({ + status: 202, + body: { + combi_and: false, + challenges: [ + { challenge_id: id, tan_channel: "email", tan_info: "owner@example.com" }, + ], + }, +}); + +async function flush() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +async function navigate(path: string) { + await act(async () => { + window.location.hash = `#${path}`; + window.dispatchEvent(new HashChangeEvent("hashchange")); + }); + await flush(); +} + +function input(container: HTMLElement, selector: string, value: string) { + const field = container.querySelector<HTMLInputElement>(selector); + assert.ok(field, `Missing input ${selector}`); + act(() => { + field.value = value; + field.dispatchEvent(new Event("input", { bubbles: true })); + }); +} + +async function submit(container: HTMLElement) { + const form = container.querySelector("form"); + assert.ok(form); + await act(async () => { + form.dispatchEvent( + new Event("submit", { bubbles: true, cancelable: true }), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + await flush(); +} + +async function click(container: HTMLElement, label: string) { + const button = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === label, + ); + assert.ok(button, `Missing button ${label}`); + await act(async () => button.click()); + await flush(); +} + +function assertNoPersistedToken() { + const browserState = JSON.stringify({ + url: window.location.href, + history: window.history.state, + local: Array.from({ length: localStorage.length }, (_, i) => { + const key = localStorage.key(i)!; + return [key, localStorage.getItem(key)]; + }), + session: Array.from({ length: sessionStorage.length }, (_, i) => { + const key = sessionStorage.key(i)!; + return [key, sessionStorage.getItem(key)]; + }), + }); + assert.ok(!browserState.includes(issuedToken)); + assert.ok(!browserState.includes(encodeURIComponent(issuedToken))); + assert.ok(!browserState.includes("created_token")); +} + +async function openCreation(responses: HandlerResult[]) { + const previousBackend = customBackendUrlStore.get(); + const previousUrl = window.location.href; + let creations = 0; + const http = new FakeHttpLib().handle((request) => { + const path = new URL(request.url).pathname; + if (request.method === "POST" && path.endsWith("/private/token")) { + const response = responses[creations++]; + assert.ok(response, "Unexpected extra token creation request"); + return response; + } + if (request.method === "POST" && path.includes("/challenge/")) { + return path.endsWith("/confirm") + ? noContent() + : ok({ + solve_expiration: { t_s: Math.floor(Date.now() / 1000) + 300 }, + earliest_retransmission: { t_s: "never" }, + }); + } + return undefined; + }); + const restoreHttp = useHttpLibForTesting(http); + signIn("cafe", "secret-token:portal", backend); + window.history.replaceState(null, "", "#/access/new"); + const container = document.createElement("div"); + document.body.appendChild(container); + const mount = () => + render( + <Router hook={useHashLocation}> + <AppContent /> + </Router>, + container, + ); + await act(async () => mount()); + await flush(); + + return { + container, + http, + mount, + create: async () => { + input(container, "#acc_used_for", "Counter till"); + input(container, "#acc_password", "master secret"); + await submit(container); + }, + solve: async () => { + assert.strictEqual(window.location.hash, "#/money/payout-accounts/mfa"); + // The original creation screen must be unmounted during verification. + assert.strictEqual(container.querySelector("#acc_used_for"), null); + input(container, "#signin-2fa-code", "123456"); + await submit(container); + }, + close: () => { + render(null, container); + container.remove(); + restoreHttp(); + signOut(); + customBackendUrlStore.set(previousBackend); + window.history.replaceState(null, "", previousUrl); + }, + }; +} + +test("machine access reveals and copies the MFA-issued token after returning, without a URL credential", async () => { + const view = await openCreation([challenge(), issued]); + try { + await view.create(); + assertNoPersistedToken(); + await view.solve(); + + assert.strictEqual(window.location.hash, "#/access/new"); + assert.match(view.container.textContent ?? "", /Machine Access Created/); + assert.strictEqual( + view.container.querySelector<HTMLInputElement>("input[readonly]")?.value, + issuedToken, + ); + assertNoPersistedToken(); + await click(view.container, "Copy"); + assert.strictEqual(await navigator.clipboard.readText(), issuedToken); + + const creations = view.http.requests.filter((request) => + request.url.endsWith("/private/token"), + ); + assert.strictEqual(creations.length, 2); + assert.deepStrictEqual(creations[0]!.body, { + description: "Counter till", + scope: "order-simple", + duration: { d_us: 2_592_000_000_000 }, + }); + assert.deepStrictEqual(creations[1]!.body, creations[0]!.body); + assert.strictEqual( + creations[0]!.headers?.["Taler-Challenge-Ids"], + undefined, + ); + assert.strictEqual(creations[1]!.headers?.["Taler-Challenge-Ids"], "CH-1"); + assert.strictEqual( + atob(creations[1]!.headers!.Authorization!.replace(/^Basic /, "")), + "cafe:master secret", + ); + + await click(view.container, "I have saved it → Done"); + assert.strictEqual(window.location.hash, "#/access"); + await navigate("/access/new"); + assert.strictEqual(view.container.querySelector("input[readonly]"), null); + assertNoPersistedToken(); + assert.strictEqual( + view.http.requests.filter((r) => r.url.endsWith("/private/token")).length, + 2, + ); + } finally { + view.close(); + } +}); + +test("machine access still reveals the backend token without MFA", async () => { + const view = await openCreation([issued]); + try { + await view.create(); + assert.strictEqual( + view.container.querySelector<HTMLInputElement>("input[readonly]")?.value, + issuedToken, + ); + assert.strictEqual(window.location.hash, "#/access/new"); + assertNoPersistedToken(); + } finally { + view.close(); + } +}); + +test("machine access waits for additional challenges and does not reveal a token on failure", async () => { + const view = await openCreation([challenge(), challenge("CH-2"), conflict()]); + try { + await view.create(); + await view.solve(); + assert.strictEqual(view.container.querySelector("input[readonly]"), null); + await view.solve(); + assert.match( + view.container.textContent ?? "", + /Your code was accepted, but the action did not finish/, + ); + assert.strictEqual(view.container.querySelector("input[readonly]"), null); + assertNoPersistedToken(); + await click(view.container, "Return"); + await navigate("/access/new"); + assert.strictEqual(view.container.querySelector("input[readonly]"), null); + } finally { + view.close(); + } +}); + +for (const reason of [ + "navigation", + "sign-out", + "account", + "backend", + "reload", +]) { + test(`machine access discards its one-time token on ${reason}`, async () => { + const view = await openCreation([issued]); + try { + await view.create(); + assert.ok(view.container.querySelector("input[readonly]")); + if (reason === "navigation") { + await navigate("/access"); + } else if (reason === "reload") { + render(null, view.container); + await act(async () => view.mount()); + } else { + await act(async () => { + if (reason === "sign-out") signOut(); + if (reason === "account") + signIn("other", "secret-token:other", backend); + if (reason === "backend") + customBackendUrlStore.set("https://other.example.com/"); + }); + await flush(); + assert.strictEqual( + view.container.querySelector("input[readonly]"), + null, + ); + await act(async () => signIn("cafe", "secret-token:portal", backend)); + } + await navigate("/access/new"); + assert.strictEqual(view.container.querySelector("input[readonly]"), null); + assertNoPersistedToken(); + } finally { + view.close(); + } + }); +} diff --git a/packages/taler-merchant-webui/src/screens/CreateAccessScreen.tsx b/packages/taler-merchant-webui/src/screens/CreateAccessScreen.tsx @@ -26,6 +26,8 @@ import { PasswordInput } from "../ui/PasswordInput.js"; import { useClipboard } from "../utils/useClipboard.js"; export interface CreateAccessScreenProps { + generatedToken?: string | null; + onGeneratedTokenChange?: (token: string | null) => void; onSave?: (data: { usedFor: string; canDo: string; @@ -106,7 +108,11 @@ function permissionChoices( ]; } -export function CreateAccessScreen({ onSave }: CreateAccessScreenProps): VNode { +export function CreateAccessScreen({ + onSave, + generatedToken: issuedToken, + onGeneratedTokenChange, +}: CreateAccessScreenProps): VNode { const { t } = useTranslation(); const [, setLocation] = useLocation(); @@ -118,7 +124,9 @@ export function CreateAccessScreen({ onSave }: CreateAccessScreenProps): VNode { const [isRefreshable, setIsRefreshable] = useState<boolean>(false); const [showAdvanced, setShowAdvanced] = useState<boolean>(false); - const [generatedToken, setGeneratedToken] = useState<string | null>(null); + const [localToken, setLocalToken] = useState<string | null>(null); + const generatedToken = issuedToken === undefined ? localToken : issuedToken; + const setGeneratedToken = onGeneratedTokenChange ?? setLocalToken; const { copied: tokenCopied, error: copyError, copy } = useClipboard(); const [isSubmitting, setIsSubmitting] = useState<boolean>(false); const [errorMsg, setErrorMsg] = useState<string>("");