Trade each sign-in code once #115

Merged
jcoffey-dev merged 1 commits from fix/oauth-code-once into main 2026-10-18 22:40:32 +00:00
3 changed files with 114 additions and 1 deletions

No files matched your search

+19 -1
View File
@@ -1,7 +1,10 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* 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);
}
}
+54
View File
@@ -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');
});
});
+41
View File
@@ -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.
}
}