taler-typescript-core

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

commit a09dde7fc87e000a341d3255401e12e9d5e0038a
parent c3b580a6f5b84748537d8e4f15c757da7f7912a5
Author: Florian Dold <dold@taler.net>
Date:   Mon, 24 Aug 2026 02:29:07 +0200

bank web UI: bound session refresh and logout handling

Diffstat:
Mpackages/libeufin-bank-webui/src/hooks/session.ts | 128+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mpackages/libeufin-bank-webui/src/pages/BankFrame.tsx | 97++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
2 files changed, 163 insertions(+), 62 deletions(-)

diff --git a/packages/libeufin-bank-webui/src/hooks/session.ts b/packages/libeufin-bank-webui/src/hooks/session.ts @@ -35,7 +35,7 @@ import { import { mutate } from "swr"; import { SESSION_DURATION } from "../pages/LoginForm.js"; import { createRFC8959AccessTokenEncoded } from "@gnu-taler/taler-util"; -import { useEffect } from "preact/hooks"; +import { useEffect, useRef } from "preact/hooks"; /** * Has the information to reach and @@ -126,21 +126,40 @@ export function useSessionState(): SessionStateHandler { SESSION_STATE_KEY, defaultState, ); + const updateRef = useRef(update); + updateRef.current = update; + const loggedInUsername = + state.status === "loggedIn" ? state.username : undefined; + const loggedInExpiration = + state.status === "loggedIn" ? state.expiration.t_ms : undefined; + const loggedInIsAdmin = + state.status === "loggedIn" ? state.isUserAdministrator : undefined; useEffect(() => { if ( - state.status === "loggedIn" && - AbsoluteTime.isExpired(state.expiration) + loggedInUsername === undefined || + loggedInExpiration === undefined || + loggedInExpiration === "never" ) { - const nextState: SessionState = { - status: "expired", - username: state.username, - expiration: state.expiration, - isUserAdministrator: state.username === "admin", - }; - update(nextState); + return; } - }); + const expiration = AbsoluteTime.fromMilliseconds(loggedInExpiration); + const remaining = Duration.getRemaining(expiration); + if (remaining.d_ms === "forever") return; + const timeout = setTimeout( + () => { + const nextState: SessionState = { + status: "expired", + username: loggedInUsername, + expiration, + isUserAdministrator: loggedInIsAdmin ?? false, + }; + updateRef.current(nextState); + }, + Math.max(remaining.d_ms, 0), + ); + return () => clearTimeout(timeout); + }, [loggedInExpiration, loggedInIsAdmin, loggedInUsername]); return { state, @@ -182,6 +201,8 @@ function cleanAllCache(): void { */ export function useRefreshSessionBeforeExpires() { const session = useSessionState(); + const sessionRef = useRef(session); + sessionRef.current = session; const { lib: { bank }, @@ -192,16 +213,27 @@ export function useRefreshSessionBeforeExpires() { session.state.expiration.t_ms === "never" ? undefined : session.state; + const refreshUsername = refreshSession?.username; + const refreshToken = refreshSession?.token; + const refreshExpiration = refreshSession?.expiration.t_ms; useEffect(() => { - if (!refreshSession) return; + if ( + refreshUsername === undefined || + refreshToken === undefined || + refreshExpiration === undefined || + refreshExpiration === "never" + ) { + return; + } + const expiration = AbsoluteTime.fromMilliseconds(refreshExpiration); + let cancelled = false; + let timeoutId: ReturnType<typeof setTimeout> | undefined; /** * we need to wait before refreshing the session. Waiting too much and the token will * be expired. So 20% before expiration should be close enough. */ - const timeLeftBeforeExpiration = Duration.getRemaining( - refreshSession.expiration, - ); + const timeLeftBeforeExpiration = Duration.getRemaining(expiration); const refreshWindow = Duration.multiply( Duration.fromTalerProtocolDuration(SESSION_DURATION), 0.2, @@ -216,30 +248,54 @@ export function useRefreshSessionBeforeExpires() { 0, ); - const timeoutId = setTimeout(async () => { - const result = await bank.createAccessToken( - refreshSession.username, - { type: "bearer", token: refreshSession.token }, - { - scope: "readwrite", - duration: SESSION_DURATION, - refreshable: true, - }, - ); - if (result.type === "fail") { - console.log( - `could not refresh session ${result.case}: ${JSON.stringify(result)}`, + const attemptRefresh = async (attempt: number): Promise<void> => { + if (cancelled) return; + try { + const result = await bank.createAccessToken( + refreshUsername, + { type: "bearer", token: refreshToken }, + { + scope: "readwrite", + duration: SESSION_DURATION, + refreshable: true, + }, ); + if (cancelled) return; + if (result.type === "ok") { + sessionRef.current.logIn({ + username: refreshUsername, + token: createRFC8959AccessTokenEncoded(result.body.access_token), + expiration: AbsoluteTime.fromProtocolTimestamp( + result.body.expiration, + ), + }); + return; + } + // createAccessToken returns only documented permanent failures + // (unauthorized, forbidden/locked, or missing account). Network and + // unexpected server failures throw and are retried below. + sessionRef.current.expired(); + return; + } catch { + // Network failures are transient while the current token remains valid. + } + + const left = Duration.getRemaining(expiration); + if (left.d_ms === "forever" || left.d_ms <= 0) { + sessionRef.current.expired(); return; } - session.logIn({ - username: refreshSession.username, - token: createRFC8959AccessTokenEncoded(result.body.access_token), - expiration: AbsoluteTime.fromProtocolTimestamp(result.body.expiration), - }); - }, remain); + const retryDelay = Math.min(1000 * 2 ** attempt, 30_000, left.d_ms); + timeoutId = setTimeout( + () => void attemptRefresh(attempt + 1), + retryDelay, + ); + }; + + timeoutId = setTimeout(() => void attemptRefresh(0), remain); return () => { - clearTimeout(timeoutId); + cancelled = true; + if (timeoutId !== undefined) clearTimeout(timeoutId); }; - }, [refreshSession]); + }, [bank, refreshUsername, refreshToken, refreshExpiration]); } diff --git a/packages/libeufin-bank-webui/src/pages/BankFrame.tsx b/packages/libeufin-bank-webui/src/pages/BankFrame.tsx @@ -49,17 +49,16 @@ const TALER_SCREEN_ID = 103; const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined; const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined; +const REVOCATION_WARNING_KEY = "bank-logout-revocation-unconfirmed"; BankFrame.SCREEN_ID = TALER_SCREEN_ID; export function BankFrame({ children, account, routeAccountDetails, - routeNotifications, }: { account?: string; routeAccountDetails?: RouteDefinition; - routeNotifications?: RouteDefinition; children: ComponentChildren; }): VNode { const { i18n } = useTranslationContext(); @@ -71,6 +70,42 @@ export function BankFrame({ const d = useBankCoreApiContext(); const config = d === undefined ? undefined : d.config; const authenticator = d === undefined ? undefined : d.lib.bank; + const [revocationWarning, setRevocationWarning] = useState( + () => + typeof window !== "undefined" && + window.sessionStorage.getItem(REVOCATION_WARNING_KEY) === "true", + ); + + async function logOut(): Promise<void> { + let revocationConfirmed = false; + try { + if (session.state.status === "loggedIn" && authenticator) { + const result = await Promise.race([ + authenticator.deleteAccessToken( + session.state.username, + session.state.token, + ), + new Promise<never>((_, reject) => + setTimeout(() => reject(new Error("revocation timeout")), 5_000), + ), + ]); + revocationConfirmed = result.type === "ok"; + } + } catch { + revocationConfirmed = false; + } finally { + session.logOut(); + resetBankState(); + setRevocationWarning(!revocationConfirmed); + if (typeof window !== "undefined") { + if (revocationConfirmed) { + window.sessionStorage.removeItem(REVOCATION_WARNING_KEY); + } else { + window.sessionStorage.setItem(REVOCATION_WARNING_KEY, "true"); + } + } + } + } const failed = useRenderErrorReport({ hash: __GIT_HASH__, @@ -87,25 +122,11 @@ export function BankFrame({ title={config?.bank_name ?? "Bank"} iconLinkURL={settings.iconLinkURL ?? "#"} profileURL={routeAccountDetails?.url({})} - notificationURL={ - showDebugInfo && routeNotifications - ? routeNotifications.url({}) - : undefined - } + notificationURL={undefined} onLogout={ session.state.status !== "loggedIn" ? undefined - : () => { - if (session.state.status === "loggedIn" && authenticator) { - // FIXME: This returns a promise, should await on it! - authenticator.deleteAccessToken( - session.state.username, - session.state.token, - ); - } - session.logOut(); - resetBankState(); - } + : () => void logOut() } sites={ !settings.topNavSites ? [] : Object.entries(settings.topNavSites) @@ -124,7 +145,7 @@ export function BankFrame({ <span class="flex flex-grow flex-col"> <span class="text-sm text-black font-medium leading-6 " - id="availability-label" + id={`preference-${set}-label`} > {getLabelForPreferences(set, i18n)} </span> @@ -135,9 +156,8 @@ export function BankFrame({ data-enabled={isOn} class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" role="switch" - aria-checked="false" - aria-labelledby="availability-label" - aria-describedby="availability-description" + aria-checked={isOn} + aria-labelledby={`preference-${set}-label`} onClick={() => { updatePreferences(set, !isOn); }} @@ -157,7 +177,7 @@ export function BankFrame({ <span class="flex flex-grow flex-col"> <span class="text-sm text-black font-medium leading-6 " - id="availability-label" + id="debug-preference-label" > <i18n.Translate>Show debug information</i18n.Translate> </span> @@ -168,9 +188,8 @@ export function BankFrame({ data-enabled={showDebugInfo} class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" role="switch" - aria-checked="false" - aria-labelledby="availability-label" - aria-describedby="availability-description" + aria-checked={showDebugInfo} + aria-labelledby="debug-preference-label" onClick={() => { update("showDebugInfo", !showDebugInfo); }} @@ -191,6 +210,32 @@ export function BankFrame({ <div class="fixed z-40 top-10 w-full"> <div class="mx-auto w-4/5"> <ToastBanner /> + {revocationWarning && ( + <div + class="mt-2 flex items-start justify-between gap-4 rounded-md bg-amber-50 p-4 text-sm text-amber-900 shadow" + role="alert" + > + <span> + <i18n.Translate> + You were signed out locally, but the bank could not confirm + that the server token was revoked. Close other sessions or + sign in again to review active tokens. + </i18n.Translate> + </span> + <button + type="button" + class="font-semibold underline" + onClick={() => { + setRevocationWarning(false); + if (typeof window !== "undefined") { + window.sessionStorage.removeItem(REVOCATION_WARNING_KEY); + } + }} + > + <i18n.Translate>Dismiss</i18n.Translate> + </button> + </div> + )} </div> </div>