Trade each sign-in code once #115
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user