From b4367668808b5c83e7b4dddfb0272e3e9d104d51 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 18 Oct 2026 15:33:19 -0700 Subject: [PATCH] Trade each sign-in code once The callback page traded the code every time its effect ran: twice under StrictMode, again when the translations loaded, and again on a reload of the page. The server refuses a spent code, so a sign-in that had worked could end on an error. A code is now traded once. A second run leaves it to the trade under way, and a code already traded (kept for the tab in sessionStorage) goes on to the console when signed in, or to a fresh sign-in when not. No new strings. --- src/pages/OAuthCallback.tsx | 20 ++++++++++- src/services/auth/codeOnce.test.ts | 54 ++++++++++++++++++++++++++++++ src/services/auth/codeOnce.ts | 41 +++++++++++++++++++++++ 3 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 src/services/auth/codeOnce.test.ts create mode 100644 src/services/auth/codeOnce.ts diff --git a/src/pages/OAuthCallback.tsx b/src/pages/OAuthCallback.tsx index b2a6e78..cb65c87 100644 --- a/src/pages/OAuthCallback.tsx +++ b/src/pages/OAuthCallback.tsx @@ -1,7 +1,10 @@ /* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ import { useEffect, useState } from 'react'; @@ -13,6 +16,7 @@ import { useAuthStore } from '@/stores/authStore'; import { useDocumentTitle } from '@/hooks/useDocumentTitle'; import { getBasePath } from '@/lib/basePath'; import { exchangeCode, getStoredOAuthData, clearStoredOAuthData, getOAuthRedirectUri } from '@/services/auth/oauth'; +import { claimCode, settleCode } from '@/services/auth/codeOnce'; export default function OAuthCallback() { const { t } = useTranslation(); @@ -24,8 +28,19 @@ export default function OAuthCallback() { useEffect(() => { async function handleCallback() { + // inbuxa: each code is traded once (see codeOnce.ts). Back on this page + // with a code already traded, go on as signed in, or sign in afresh. + const code = searchParams.get('code'); + if (code) { + const claim = claimCode(code); + if (claim === 'inFlight') return; + if (claim === 'traded') { + navigate(useAuthStore.getState().isAuthenticated() ? '/' : '/login', { replace: true }); + return; + } + } + let traded = false; try { - const code = searchParams.get('code'); const stateParam = searchParams.get('state'); const errorParam = searchParams.get('error'); const errorDescription = searchParams.get('error_description'); @@ -60,6 +75,7 @@ export default function OAuthCallback() { tokenEndpoint, endSessionEndpoint, ); + traded = true; clearStoredOAuthData(); @@ -76,6 +92,8 @@ export default function OAuthCallback() { } catch (err) { clearStoredOAuthData(); setError(err instanceof Error ? err.message : t('oauth.error', 'Authentication failed')); + } finally { + if (code) settleCode(code, traded); } } diff --git a/src/services/auth/codeOnce.test.ts b/src/services/auth/codeOnce.test.ts new file mode 100644 index 0000000..8b90d60 --- /dev/null +++ b/src/services/auth/codeOnce.test.ts @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// Fresh module per test: the trade under way is kept at module level. +async function load() { + vi.resetModules(); + return import('./codeOnce'); +} + +beforeEach(() => { + sessionStorage.clear(); +}); + +describe('claimCode', () => { + it('trades a new code', async () => { + const { claimCode } = await load(); + expect(claimCode('c1')).toBe('trade'); + }); + + it('leaves a second run to the trade under way', async () => { + const { claimCode } = await load(); + claimCode('c1'); + expect(claimCode('c1')).toBe('inFlight'); + }); + + it("doesn't trade a code again after it brought tokens, even after a reload", async () => { + const first = await load(); + first.claimCode('c1'); + first.settleCode('c1', true); + expect(first.claimCode('c1')).toBe('traded'); + + const reloaded = await load(); + expect(reloaded.claimCode('c1')).toBe('traded'); + }); + + it('lets a failed trade go without marking the code traded', async () => { + const { claimCode, settleCode } = await load(); + claimCode('c1'); + settleCode('c1', false); + expect(claimCode('c1')).toBe('trade'); + }); + + it('trades a new code after an old one', async () => { + const { claimCode, settleCode } = await load(); + claimCode('c1'); + settleCode('c1', true); + expect(claimCode('c2')).toBe('trade'); + }); +}); diff --git a/src/services/auth/codeOnce.ts b/src/services/auth/codeOnce.ts new file mode 100644 index 0000000..c362004 --- /dev/null +++ b/src/services/auth/codeOnce.ts @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +// A sign-in code is traded for tokens once. The callback page's effect can +// run again for the same code (StrictMode runs it twice, and it reruns when +// the translations load), and a reload of the page brings the code back; the +// server refuses a spent code, so trading it again showed an error after a +// sign-in that had worked. + +const TRADED_KEY = 'inbuxa_admin_traded_code'; + +let inFlight: string | null = null; + +/** + * What to do with `code`: trade it, leave it to the trade already under way, + * or nothing, since it was traded already. + */ +export function claimCode(code: string): 'trade' | 'inFlight' | 'traded' { + if (inFlight === code) return 'inFlight'; + try { + if (sessionStorage.getItem(TRADED_KEY) === code) return 'traded'; + } catch { + // Storage off: only the in-flight guard applies. + } + inFlight = code; + return 'trade'; +} + +/** The trade for `code` is over; `traded` when it brought tokens. */ +export function settleCode(code: string, traded: boolean): void { + if (inFlight === code) inFlight = null; + if (!traded) return; + try { + sessionStorage.setItem(TRADED_KEY, code); + } catch { + // Storage off: a reload would try the spent code and say so. + } +} -- 2.54.0