fix: full-scope review — tip-inclusive amount_due, sweep deposit-strand, A6 clamp cap, B13 clawback, 2FA single-use, mint audit, account-deletion re-auth, refresh dedup

Full-scope Loop A restart review (18 findings across money/security/dup-mod):

MONEY:
- HIGH: amount_paid/amount_due CTEs now exclude payment_type='tip' (bookings.go x6, today.go) — a tip before the final balance no longer undercharges the booking
- MEDIUM-HIGH: pending payment row stores the actual chargeAmount (not req.Amount) so the sweep replay amount-match rescues deposit-with-discount rows instead of auto-refunding them; refundSweepDuplicateCharge refunds the replayed payment's actual amount
- MEDIUM: A6 deposit clamp-up now caps at the discounted obligation (remainingPence - eligibleDiscountPence) — no more silent overcharge when a campaign discount >= deposit
- MEDIUM: B13 campaign-loss balance credits are clawed back on cancellation (clawbackB13CampaignCredit in ProcessCancellationRefundTx)
- LOW: replayLegitimateRetryWindow extended 22h->24h so a legitimate same-key retry in the retry-eligible window is rescued, not auto-refunded

SECURITY:
- 2FA single-use strengthened (consume-at-gate for fresh charges, re-issue on failure)
- Admin 2FA mint now writes admin_audit_log + logs code reuse
- Account deletion requires current password (and 2FA when enforced) — stolen token can no longer destroy the account
- Multi-tab refresh-token replay deduped via cross-tab lock (no false family-kill alerts)
- family-alive cache invalidated on password change / GDPR erasure
- Login lockout keyed per user+IP with a capped ceiling

FRONTEND/DUP-MOD:
- OverflowTipConfirm shared component (UserPaymentModal + BookingFlow); overflow computation aligned (deposit-discount-aware)
- PaymentModal admin 2FA gate now method-conditioned (no over-reveal on cash/giftcard)
- requestTwoFactorCode shared helper (requestNewTwoFactorCode + adminRequestNewTwoFactorCode)
- BookingFlow deposit display aligned to the discounted amount; formatCurrency used consistently

26/26 backend packages; 80/80 frontend tests + build; env-docs 41/41.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent b46927336b
commit 9a182db932
27 changed files with 1279 additions and 300 deletions
@@ -41,12 +41,15 @@
import { POLICY } from '$lib/constants/policy';
import {
canSaveCardsForRole,
campaignDiscountPence,
depositChargePence,
isNonceStale,
isOverflowTipConfirmationRequired,
isTwoFactorVerificationGateFailure,
submitPaymentWithRetry
} from '$lib/square/square';
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
import OverflowTipConfirm from '$lib/components/payments/OverflowTipConfirm.svelte';
import { extractBookedSlots, getLunchProtectionForSlots } from '$lib/lunchProtection';
import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
import {
@@ -249,6 +252,13 @@
discounted_total: number;
} | null>(null);
function formatCurrency(pence: number): string {
return new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: 'GBP'
}).format(pence / 100);
}
// =============== Payment Functions ===============
async function fetchUserDepositsRequired() {
if (!authStore.isAuthenticated) {
@@ -522,12 +532,23 @@
// idempotency key are NOT cleared — the confirm resend is the same
// logical charge.
if (!confirmOverflowTip && isOverflowTipConfirmationRequired(text)) {
// The backend's overflow guard compares against the DISCOUNTED
// remaining (remaining + eligible campaign credit), and for a
// deposit it charges req.Amount the campaign credit (the
// frontend sends deposits raw). Both the displayed overflow and
// the amount actually charged must therefore account for the
// eligible campaign discount — mirroring UserPaymentModal so the
// two surfaces can't show different amounts for the same booking.
const depositDiscountPence = campaignDiscountPence(discountPreview);
overflowConfirm = {
amountPence,
overflowPence: Math.max(
0,
amountPence - Math.round((confirmedBooking?.amount_due ?? 0) * 100)
amountPence -
Math.round((confirmedBooking?.amount_due ?? 0) * 100) -
depositDiscountPence
),
chargePence: Math.max(0, amountPence - depositDiscountPence),
depositAmount,
body
};
@@ -603,6 +624,10 @@
let overflowConfirm = $state<{
amountPence: number;
overflowPence: number;
// Actual amount the backend will charge. Deposits are sent RAW and the
// backend charges amountPence minus the eligible campaign credit, so
// this can differ from amountPence (mirrors UserPaymentModal).
chargePence?: number;
depositAmount: number;
body: Record<string, unknown>;
} | null>(null);
@@ -2573,47 +2598,21 @@
<!-- Pre-start overpayment confirmation: the backend rejected the
payment because the booking's remaining balance has changed
since it was loaded (stale data). The excess over the
remaining balance will be recorded as a tip once confirmed. -->
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4">
<div class="flex items-start gap-2.5">
<svg
class="mt-0.5 h-5 w-5 shrink-0 text-amber-600"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M12 16v-4M12 8h.01" />
<circle cx="12" cy="12" r="10" />
</svg>
<div>
<p class="font-semibold text-amber-900">Confirm extra as tip</p>
<p class="mt-1 text-sm text-amber-800">
The balance for this booking has changed since it was last loaded. The extra £{(
overflowConfirm.overflowPence / 100
).toFixed(2)} will be recorded as a tip. Confirm to continue?
</p>
</div>
</div>
<div class="mt-4 flex gap-2">
<Button
class="flex-1"
loading={isProcessingPayment}
disabled={isProcessingPayment}
onclick={confirmOverflowPayment}
>
Confirm
</Button>
<Button
variant="outline"
class="flex-1"
disabled={isProcessingPayment}
onclick={cancelOverflowConfirmation}
>
Cancel
</Button>
</div>
</div>
remaining balance will be recorded as a tip once confirmed.
Shared markup with the customer payment modal
(OverflowTipConfirm) so the two surfaces can't drift. -->
<OverflowTipConfirm
overflowPence={overflowConfirm.overflowPence}
discountNote={overflowConfirm.chargePence !== undefined &&
overflowConfirm.chargePence < overflowConfirm.amountPence
? `An eligible campaign discount of ${formatCurrency(
Math.max(0, overflowConfirm.amountPence - overflowConfirm.chargePence)
)} applies you'll be charged ${formatCurrency(overflowConfirm.chargePence)}.`
: undefined}
loading={isProcessingPayment}
onConfirm={confirmOverflowPayment}
onCancel={cancelOverflowConfirmation}
/>
{:else}
<BookingSummary
services={selectedServices}
@@ -2698,7 +2697,12 @@
>
{isProcessingPayment
? 'Processing...'
: `Pay Deposit £${calculateDepositAmount()}`}
: `Pay Deposit ${formatCurrency(
depositChargePence(
Math.round(calculateDepositAmount() * 100),
campaignDiscountPence(discountPreview)
)
)}`}
</Button>
</div>
@@ -0,0 +1,72 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
interface Props {
overflowPence: number;
onConfirm: () => void;
onCancel: () => void;
loading?: boolean;
// Optional pre-formatted note describing a discount on the charge (e.g.
// a campaign credit applied to a deposit), shown under the main text.
discountNote?: string;
}
const { overflowPence, onConfirm, onCancel, loading = false, discountNote }: Props = $props();
function formatCurrency(pence: number): string {
return new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: 'GBP'
}).format(pence / 100);
}
</script>
<!-- Overpayment confirmation: the backend rejected the payment because the
booking's remaining balance has changed since it was loaded (stale data).
The excess over the remaining balance will be recorded as a tip once
confirmed. Shared by the customer payment modal and the booking-flow
deposit step so the two surfaces can't drift on the markup or the
confirm/cancel wiring. -->
<div class="rounded-md border border-amber-200 bg-amber-50 p-4">
<div class="flex items-start gap-2.5">
<svg
class="mt-0.5 h-5 w-5 shrink-0 text-amber-600"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M12 16v-4M12 8h.01" />
<circle cx="12" cy="12" r="10" />
</svg>
<div>
<p class="font-semibold text-amber-900">Confirm extra as tip</p>
<p class="mt-1 text-sm text-amber-800">
The balance for this booking has changed since it was last loaded. The extra
{formatCurrency(overflowPence)} will be recorded as a tip. Confirm to continue?
</p>
{#if discountNote}
<p class="mt-2 text-sm font-medium text-amber-800">{discountNote}</p>
{/if}
</div>
</div>
<div class="mt-4 flex gap-2">
<Button
class="flex-1"
loading={loading}
disabled={loading}
autofocus
onclick={onConfirm}
>
Confirm
</Button>
<Button
variant="outline"
class="flex-1"
disabled={loading}
onclick={onCancel}
>
Cancel
</Button>
</div>
</div>
@@ -90,7 +90,8 @@
// irrelevant to the backend gate, so `enabled` is always true.
const twoFactor = useTwoFactorCodeForSavedCard({
enabled: () => true,
gateActive: () => twoFactorEnforced && customerTwoFactorEnabled,
gateActive: () =>
twoFactorEnforced && customerTwoFactorEnabled && selectedMethod === 'savedcard',
mint: () => {
const customerID = booking.user_id ?? booking.user?.id;
return customerID ? adminRequestNewTwoFactorCode(customerID) : requestNewTwoFactorCode();
@@ -110,7 +111,18 @@
// by /api/admin/today/current-next carries no amount_paid/amount_due/
// payments, so this is fetched fresh from the admin booking detail endpoint
// on mount and subtracted from the charge (see netTotal).
//
// Money finding 1: `amount_paid` (summed over ALL completed payments) can
// include tips — a tip is gratuity, not booking credit, so it must not
// reduce what the customer still owes. The booking detail endpoint computes
// amount_paid in Go over every completed payment (no payment_type filter),
// so the tip-excluded obligation is derived here from the payments list
// rather than trusting amount_paid. This stays consistent whether or not
// the backend starts excluding tips from amount_paid (idempotent either
// way). The tip-INCLUSIVE amount_paid is kept for the "Already paid"
// display (mirrors the customer modal's "Amount Paid" row).
let amountPaidPence = $state(0);
let tipExcludedPaidPence = $state(0);
async function fetchAmountPaid() {
try {
const resp = await apiFetch(`/api/admin/bookings/${booking.id}`);
@@ -118,20 +130,29 @@
const data = await resp.json();
if (typeof data.amount_paid === 'number') {
amountPaidPence = Math.round(data.amount_paid * 100);
return;
}
tipExcludedPaidPence = Math.round(
(data.payments ?? [])
.filter(
(p: { status: string; payment_type: string }) =>
p.status === 'completed' && p.payment_type !== 'tip'
)
.reduce((sum: number, p: { amount: number }) => sum + (p.amount || 0), 0) * 100
);
return;
}
} catch (_err) {
// fall through to the booking prop below
}
amountPaidPence = Math.round((booking.amount_paid ?? 0) * 100);
tipExcludedPaidPence = amountPaidPence;
}
const loyaltyEligible = $derived(
stamps >= 10 &&
!(booking.discounts ?? []).some((d: BookingDiscount) => d.discount_source === 'loyalty') &&
booking.total_amount > 0 &&
amountPaidPence === 0
tipExcludedPaidPence === 0
);
const loyaltyDiscount = $derived(
@@ -283,7 +304,10 @@
// remaining value, so the frontend charge and the backend record now agree
// and a prior deposit can no longer land as an unintended tip.
const netTotal = $derived(
Math.max(0, subtotal - discountSum - campaignDiscountPence(discountPreview) - amountPaidPence)
Math.max(
0,
subtotal - discountSum - campaignDiscountPence(discountPreview) - tipExcludedPaidPence
)
);
const tipPercentages = $derived.by(() => {
@@ -9,6 +9,7 @@
import type { Booking } from '$lib/types/booking';
import CardSelection from '$lib/components/payments/CardSelection.svelte';
import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
import OverflowTipConfirm from '$lib/components/payments/OverflowTipConfirm.svelte';
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { savedCardsStore } from '$lib/stores/savedCards.svelte';
@@ -17,6 +18,7 @@
import { generateUUID } from '$lib/utils/uuid';
import {
campaignDiscountPence,
depositChargePence,
isNonceStale,
isOverflowTipConfirmationRequired,
isSavedCardVerificationRequired,
@@ -723,58 +725,21 @@
<!-- Overpayment confirmation: the backend rejected the payment because
the booking's remaining balance has changed since it was loaded
(stale data). The excess over the remaining balance will be
recorded as a tip once confirmed. Applies both before and after
the appointment has started (B12). -->
<div class="rounded-md border border-amber-200 bg-amber-50 p-4">
<div class="flex items-start gap-2.5">
<svg
class="mt-0.5 h-5 w-5 shrink-0 text-amber-600"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M12 16v-4M12 8h.01" />
<circle cx="12" cy="12" r="10" />
</svg>
<div>
<p class="font-semibold text-amber-900">Confirm extra as tip</p>
<p class="mt-1 text-sm text-amber-800">
The balance for this booking has changed since it was last loaded. The extra
{formatCurrency(overflowConfirm.overflowPence)} will be recorded as a tip. Confirm to
continue?
</p>
{#if overflowConfirm.paymentType === 'deposit' && overflowConfirm.chargePence !== undefined}
<p class="mt-2 text-sm font-medium text-amber-800">
An eligible campaign discount of
{formatCurrency(
Math.max(0, overflowConfirm.amountPence - overflowConfirm.chargePence)
)}
applies — you'll be charged {formatCurrency(overflowConfirm.chargePence)}.
</p>
{/if}
</div>
</div>
<div class="mt-4 flex gap-2">
<Button
class="flex-1"
loading={status === 'processing'}
disabled={status === 'processing'}
autofocus
onclick={confirmOverflowPayment}
>
Confirm
</Button>
<Button
variant="outline"
class="flex-1"
disabled={status === 'processing'}
onclick={cancelOverflowConfirmation}
>
Cancel
</Button>
</div>
</div>
recorded as a tip once confirmed. Shared markup with the
booking-flow deposit step (OverflowTipConfirm) so the two surfaces
can't drift. -->
<OverflowTipConfirm
overflowPence={overflowConfirm.overflowPence}
discountNote={overflowConfirm.paymentType === 'deposit' &&
overflowConfirm.chargePence !== undefined
? `An eligible campaign discount of ${formatCurrency(
Math.max(0, overflowConfirm.amountPence - overflowConfirm.chargePence)
)} applies you'll be charged ${formatCurrency(overflowConfirm.chargePence)}.`
: undefined}
loading={status === 'processing'}
onConfirm={confirmOverflowPayment}
onCancel={cancelOverflowConfirmation}
/>
<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
<Button
variant="ghost"
@@ -1049,9 +1014,12 @@
>
{#if paymentType === 'deposit'}
Pay Deposit ({formatCurrency(
booking.deposit_amount
? Math.round(booking.deposit_amount * 100)
: Math.round(booking.total_amount * 0.2 * 100)
depositChargePence(
booking.deposit_amount
? Math.round(booking.deposit_amount * 100)
: Math.round(booking.total_amount * 0.2 * 100),
campaignDiscountPence(discountPreview)
)
)})
{:else}
Pay {formatCurrency(
+74
View File
@@ -3,8 +3,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import {
NONCE_STALENESS_MS,
SAVED_CARD_VERIFICATION_MESSAGE,
adminRequestNewTwoFactorCode,
campaignDiscountPence,
canSaveCardsForRole,
depositChargePence,
isAmbiguousPaymentFailure,
isNonceStale,
isOverflowTipConfirmationRequired,
@@ -420,3 +422,75 @@ describe('requestNewTwoFactorCode', () => {
expect((init.headers as Record<string, string>)['Authorization']).toBe('Bearer abc.def.ghi');
});
});
describe('adminRequestNewTwoFactorCode', () => {
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' }
});
}
afterEach(() => {
vi.unstubAllGlobals();
});
it('POSTs to the admin customer-scoped mint URL', async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ message: 'ok' }));
vi.stubGlobal('fetch', fetchMock);
const result = await adminRequestNewTwoFactorCode('usr_abc');
expect(result.ok).toBe(true);
const [url] = fetchMock.mock.calls[0] as [string];
expect(url).toBe('/api/admin/users/usr_abc/2fa/code');
});
it('URL-encodes the customer userID', async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ message: 'ok' }));
vi.stubGlobal('fetch', fetchMock);
await adminRequestNewTwoFactorCode('usr a/b');
const [url] = fetchMock.mock.calls[0] as [string];
expect(url).toBe('/api/admin/users/usr%20a%2Fb/2fa/code');
});
it('surfaces the 429 mint-cooldown error message', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(jsonResponse({ error: 'Too many requests. Wait before requesting.' }, 429))
);
const result = await adminRequestNewTwoFactorCode('usr_abc');
expect(result.ok).toBe(false);
expect(result.status).toBe(429);
expect(result.message).toContain('Too many requests');
});
it('attaches the Bearer token from localStorage and POSTs', async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ message: 'ok' }));
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('localStorage', {
getItem: (key: string) => (key === 'authToken' ? 'abc.def.ghi' : null)
});
await adminRequestNewTwoFactorCode('usr_abc');
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe('/api/admin/users/usr_abc/2fa/code');
expect(init.method).toBe('POST');
expect((init.headers as Record<string, string>)['Authorization']).toBe('Bearer abc.def.ghi');
});
});
describe('depositChargePence', () => {
it('subtracts the eligible campaign credit when it is smaller than the deposit', () => {
expect(depositChargePence(2000, 500)).toBe(1500);
});
it('charges the full deposit when the credit equals the deposit (A6 clamp-up)', () => {
expect(depositChargePence(2000, 2000)).toBe(2000);
});
it('charges the full deposit when the credit exceeds the deposit (A6 clamp-up)', () => {
expect(depositChargePence(2000, 2500)).toBe(2000);
});
it('is unchanged when no campaign credit applies', () => {
expect(depositChargePence(2000, 0)).toBe(2000);
});
});
+22 -24
View File
@@ -73,6 +73,19 @@ export function campaignDiscountPence(discountPreview: DiscountPreview | null):
: 0;
}
/**
* The deposit charge as the backend computes it (A4/A6 in
* backend/handlers/payments/handlers.go): deposits are sent RAW by the
* frontend and charged at `deposit eligible campaign credit`, clamping UP
* to the full deposit when the credit ≥ the deposit (the credit then covers
* the residual balance via the discount row). Payment surfaces must display
* this same amount so the customer never sees a higher deposit than the card
* is actually charged.
*/
export function depositChargePence(depositPence: number, discountPence: number): number {
return discountPence >= depositPence ? depositPence : depositPence - discountPence;
}
/** True when a cached card nonce can no longer be reused: it was tokenized for a
* different amount than `amount`, or it is older than NONCE_STALENESS_MS. */
export function isNonceStale(
@@ -154,14 +167,16 @@ export interface TwoFactorCodeRequestResult {
* `$lib` imports and the pure-logic vitest suite can exercise it without a
* SvelteKit plugin resolving the `$lib` alias.
*/
export async function requestNewTwoFactorCode(): Promise<TwoFactorCodeRequestResult> {
// Shared 2FA code-mint POST — the session and admin variants differ only in
// the URL, so keeping one body stops the two copies drifting apart.
async function requestTwoFactorCode(path: string): Promise<TwoFactorCodeRequestResult> {
const headers: Record<string, string> = {};
if (typeof localStorage !== 'undefined') {
const token = localStorage.getItem('authToken');
if (token) headers['Authorization'] = `Bearer ${token}`;
}
try {
const response = await fetch('/api/user/2fa/code', { method: 'POST', headers });
const response = await fetch(path, { method: 'POST', headers });
if (response.ok) {
const data = (await response.json().catch(() => null)) as { message?: unknown } | null;
const message =
@@ -179,34 +194,17 @@ export async function requestNewTwoFactorCode(): Promise<TwoFactorCodeRequestRes
}
}
export async function requestNewTwoFactorCode(): Promise<TwoFactorCodeRequestResult> {
return requestTwoFactorCode('/api/user/2fa/code');
}
/** Admin-scoped 2FA mint: requests a fresh code FOR the given customer (the
* card owner) at the till/admin payment modal. The backend keys the mint to
* the CUSTOMER's userID, so the code is delivered to the customer and can
* satisfy the card-owner gate — the admin's session never receives or
* authenticates the customer's card. */
export async function adminRequestNewTwoFactorCode(userID: string): Promise<TwoFactorCodeRequestResult> {
const headers: Record<string, string> = {};
if (typeof localStorage !== 'undefined') {
const token = localStorage.getItem('authToken');
if (token) headers['Authorization'] = `Bearer ${token}`;
}
try {
const response = await fetch(`/api/admin/users/${encodeURIComponent(userID)}/2fa/code`, { method: 'POST', headers });
if (response.ok) {
const data = (await response.json().catch(() => null)) as { message?: unknown } | null;
const message =
typeof data?.message === 'string' ? data.message : 'A new verification code has been sent.';
return { status: response.status, ok: true, message };
}
const body = await response.text();
return {
status: response.status,
ok: false,
message: extractServerErrorMessage(body) || 'Failed to request a new verification code'
};
} catch {
return { status: 0, ok: false, message: 'Network error requesting a new code' };
}
return requestTwoFactorCode(`/api/admin/users/${encodeURIComponent(userID)}/2fa/code`);
}
/** Minimal `{"error"|"message": "..."}` extractor for the 2FA code-request
+189 -27
View File
@@ -2,6 +2,19 @@
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
// Cross-tab refresh-token coordination (LOW-MEDIUM 4): every tab shares ONE
// opaque refresh token in localStorage (`authRefreshToken`) and every refresh
// ROTATES it server-side. Without coordination, two tabs refreshing on load
// would both present the same token: the first rotation consumes it, and the
// second replay either 401s that tab (inside the server's 60s reuse grace) or —
// beyond the grace — kills the ENTIRE rotation family with a false theft alert.
// The lock below guarantees only ONE tab performs the rotation; the others
// adopt the rotated pair from the BroadcastChannel broadcast (or, on a
// stale-lock timeout, from localStorage).
const REFRESH_LOCK_KEY = 'authRefreshInProgress';
const REFRESH_LOCK_TTL_MS = 15_000;
const REFRESH_CHANNEL_NAME = 'crussell-auth-refresh';
type UserRole = 'unverified_email' | 'verified_email' | 'admin' | 'guest' | 'affiliate';
interface DecodedToken {
@@ -40,10 +53,14 @@ class AuthStore {
private refreshToken = $state<string | null>(null);
private user = $state<User | null>(null);
private loading = $state(true);
// Broadcast channel used to announce a completed rotation to sibling tabs
// (finding 4). Null when the platform has no BroadcastChannel support.
private refreshChannel: BroadcastChannel | null = null;
constructor() {
if (browser) {
this.initializeAuth();
this.setupCrossTabRefresh();
// Check token refresh every 2 minutes
// (must be shorter than the 5-minute threshold so the
// refresh check fires before the token actually expires)
@@ -230,6 +247,135 @@ class AuthStore {
}
}
// --- Cross-tab refresh coordination (finding 4) ---
// Sets up the two coordination channels for the shared refresh token: a
// `storage` listener (fires in sibling tabs when this tab writes
// localStorage) and a BroadcastChannel for announcing rotations.
private setupCrossTabRefresh() {
window.addEventListener('storage', (e) => {
if (e.key === 'authToken' || e.key === 'authRefreshToken') {
this.adoptFromLocalStorage();
}
});
if (typeof BroadcastChannel !== 'undefined') {
this.refreshChannel = new BroadcastChannel(REFRESH_CHANNEL_NAME);
}
}
// Adopts the shared session's current localStorage state. Only acts when
// this tab is ALREADY authenticated: a rotation keeps the same account, so
// only an existing session adopts a changed token — a logged-out sibling
// tab must NOT silently log in via another tab's login. A cleared
// authToken (logout or failed refresh in a sibling) clears this tab too.
private adoptFromLocalStorage() {
const storedToken = localStorage.getItem('authToken');
if (storedToken && storedToken !== this.token && this.token) {
this.adoptTokens(storedToken, localStorage.getItem('authRefreshToken'));
} else if (!storedToken && this.token) {
this.clearAuth();
}
}
// Swaps in a rotated token pair without re-writing localStorage (that would
// re-trigger sibling storage events pointlessly) and without re-fetching the
// profile (the rotating tab already did; profile data is account-wide).
private adoptTokens(token: string, refreshToken: string | null) {
const decoded = this.decodeToken(token);
if (!decoded) return;
this.token = token;
if (refreshToken) this.refreshToken = refreshToken;
if (!this.user || this.user.id !== decoded.user_id) {
this.user = {
id: decoded.user_id,
role: decoded.role,
email: '',
firstName: '',
lastName: '',
twoFactorEnabled: false,
twoFactorRequired: false
};
}
}
// tryAcquireRefreshLock atomically-ish claims the single-rotator lock. A
// fresh lock held by another tab means it is mid-rotation — back off. The
// post-write re-read closes the cross-tab check-then-set race: if two tabs
// set the flag in the same instant, the loser's re-read sees the winner's
// timestamp. A stale lock (crash / suspended tab) is stolen after
// REFRESH_LOCK_TTL_MS.
private tryAcquireRefreshLock(): boolean {
const now = Date.now();
const existing = localStorage.getItem(REFRESH_LOCK_KEY);
if (existing) {
const ts = Number(existing);
if (Number.isFinite(ts) && now - ts < REFRESH_LOCK_TTL_MS) {
return false;
}
}
localStorage.setItem(REFRESH_LOCK_KEY, String(now));
return localStorage.getItem(REFRESH_LOCK_KEY) === String(now);
}
private releaseRefreshLock() {
localStorage.removeItem(REFRESH_LOCK_KEY);
}
private broadcastRefresh(token: string, refreshToken: string | null) {
this.refreshChannel?.postMessage({ type: 'auth-refreshed', token, refreshToken });
}
private broadcastRefreshFailure() {
this.refreshChannel?.postMessage({ type: 'auth-refresh-failed' });
}
// Waits for the sibling tab that holds the rotation lock to finish, then
// adopts the outcome. With BroadcastChannel support it waits for the
// `auth-refreshed` / `auth-refresh-failed` announcement; without it, it
// polls for the lock to clear. Either way it falls through to adopting
// whatever the rotating tab wrote to localStorage (the rotating tab
// persists the pair before broadcasting), so a channel-less sibling still
// converges. A 401 failure in the rotating tab clears auth in this tab too.
private async waitForAnotherTabRefresh(): Promise<void> {
const result = await new Promise<'ok' | 'failed' | 'timeout'>((resolve) => {
if (!this.refreshChannel) {
const deadline = Date.now() + REFRESH_LOCK_TTL_MS;
const poll = setInterval(() => {
if (localStorage.getItem(REFRESH_LOCK_KEY) === null || Date.now() >= deadline) {
clearInterval(poll);
resolve('timeout');
}
}, 250);
return;
}
const onMessage = (ev: MessageEvent) => {
if (!ev.data) return;
if (ev.data.type === 'auth-refreshed') {
cleanup();
resolve('ok');
} else if (ev.data.type === 'auth-refresh-failed') {
cleanup();
resolve('failed');
}
};
const timer = setTimeout(() => {
cleanup();
resolve('timeout');
}, REFRESH_LOCK_TTL_MS + 5_000);
const cleanup = () => {
clearTimeout(timer);
this.refreshChannel?.removeEventListener('message', onMessage);
};
this.refreshChannel.addEventListener('message', onMessage);
});
if (result === 'failed') {
this.clearAuth();
return;
}
this.adoptFromLocalStorage();
}
hasRole(requiredRole: UserRole | UserRole[]): boolean {
if (!this.user) return false;
@@ -265,37 +411,53 @@ class AuthStore {
// Refresh if token expires in less than 5 minutes
// (1-hour token lifetime from backend)
const fiveMinutes = 5 * 60 * 1000;
if (decoded.exp * 1000 - Date.now() < fiveMinutes) {
// B5: without the opaque refresh token we cannot refresh — the
// access token is not an accepted credential here. Clear auth
// rather than send a guaranteed-401 request.
if (!this.refreshToken) {
this.clearAuth();
return;
}
if (decoded.exp * 1000 - Date.now() >= fiveMinutes) return;
try {
const response = await fetch('/api/refresh-token', {
method: 'POST',
headers: {
Authorization: `Bearer ${this.refreshToken}`
}
});
// B5: without the opaque refresh token we cannot refresh — the access
// token is not an accepted credential here. Clear auth rather than send
// a guaranteed-401 request.
if (!this.refreshToken) {
this.clearAuth();
return;
}
if (response.ok) {
const data = await response.json();
// B5 contract: `{token, refreshToken}` — every refresh ROTATES
// both tokens, so store the fresh pair. (The snake_case
// `refresh_token` key is handled for parity with login.)
this.setToken(data.token, data.refreshToken ?? data.refresh_token ?? null);
} else {
// Refresh failed (revoked/expired refresh token → 401, etc.).
// Clear auth — never retry with the access token.
this.clearAuth();
// Cross-tab dedup (finding 4): only ONE tab may present the shared
// refresh token to /api/refresh-token — a concurrent sibling would
// replay the just-rotated token and trigger the server's reuse/theft
// handling. If another tab holds the lock, wait for its rotation and
// adopt the result.
if (!this.tryAcquireRefreshLock()) {
await this.waitForAnotherTabRefresh();
return;
}
try {
const response = await fetch('/api/refresh-token', {
method: 'POST',
headers: {
Authorization: `Bearer ${this.refreshToken}`
}
} catch (error) {
console.error('Token refresh failed:', error);
});
if (response.ok) {
const data = await response.json();
// B5 contract: `{token, refreshToken}` — every refresh ROTATES
// both tokens, so store the fresh pair. (The snake_case
// `refresh_token` key is handled for parity with login.)
const newRefreshToken = data.refreshToken ?? data.refresh_token ?? null;
this.setToken(data.token, newRefreshToken);
this.broadcastRefresh(data.token, newRefreshToken);
} else {
// Refresh failed (revoked/expired refresh token → 401, etc.).
// Clear auth — never retry with the access token. Tell sibling
// tabs the shared session died so they clear too.
this.clearAuth();
this.broadcastRefreshFailure();
}
} catch (error) {
console.error('Token refresh failed:', error);
} finally {
this.releaseRefreshLock();
}
}