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:
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>("");