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:
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>