From 98626c5215f37c604459ccab22d04e7b086126df Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Tue, 18 Aug 2026 11:56:59 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=92=EF=B8=8F(frontend)=20restrict=20tr?= =?UTF-8?q?ansit=5Fcode=20exchange=20to=20embedded=20context?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only run the transit_code exchange flow when the app is loaded in an embedded context (i.e. inside an iframe). Combined with the CSP rules that will restrict which origins are allowed to embed the app, this gives us a client-side lever to control which integrations can actually use this authentication path. --- .../src/features/auth/api/exchangeAccessToken.ts | 10 +++++++++- .../features/auth/components/TransitCodeGate.tsx | 10 +++++++--- src/frontend/src/features/auth/utils/transitCode.ts | 13 +++++++++++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/frontend/src/features/auth/api/exchangeAccessToken.ts b/src/frontend/src/features/auth/api/exchangeAccessToken.ts index 35ff4ffe..d907eb8d 100644 --- a/src/frontend/src/features/auth/api/exchangeAccessToken.ts +++ b/src/frontend/src/features/auth/api/exchangeAccessToken.ts @@ -1,6 +1,9 @@ import { fetchApi } from '@/api/fetchApi' import { setAccessToken } from '@/stores/accessToken' -import { consumeTransitCodeFromFragment } from '../utils/transitCode' +import { + consumeTransitCodeFromFragment, + isEmbedded, +} from '../utils/transitCode' type ApiAccessToken = { access_token: string @@ -28,6 +31,11 @@ const runInitialization = async (): Promise => { return } + if (!isEmbedded()) { + console.warn('Transit code ignored outside an embedded context') + return + } + try { const { access_token } = await exchangeAccessToken(code) setAccessToken(access_token) diff --git a/src/frontend/src/features/auth/components/TransitCodeGate.tsx b/src/frontend/src/features/auth/components/TransitCodeGate.tsx index 48069d53..838519f9 100644 --- a/src/frontend/src/features/auth/components/TransitCodeGate.tsx +++ b/src/frontend/src/features/auth/components/TransitCodeGate.tsx @@ -23,9 +23,13 @@ export const TransitCodeGate = ({ }) => { const hash = useHash() - // Latch the decision on the initial hash: the bootstrap scrubs the - // fragment as soon as it starts, and the gate must not flip back to the - // fast path while the exchange is still in flight. + // Note: the exchange only happens in an embedding context. This check lives + // in initializeAccessTokenFromFragment, the single funnel for all bootstrap paths. + // The gate still mounts top-level to scrub the fragment, but bootstrap then resolves + // immediately without exchanging. + // + // Latch the decision on the initial hash: bootstrap scrubs it immediately, and the + // gate must not switch back to the fast path while the exchange is in flight. const [needsExchange] = useState(() => hasTransitCodeInFragment(hash)) if (!needsExchange) { diff --git a/src/frontend/src/features/auth/utils/transitCode.ts b/src/frontend/src/features/auth/utils/transitCode.ts index 95b72fdd..08dda7fb 100644 --- a/src/frontend/src/features/auth/utils/transitCode.ts +++ b/src/frontend/src/features/auth/utils/transitCode.ts @@ -1,5 +1,18 @@ const TRANSIT_CODE_FRAGMENT_PARAM = 'transit_code' +/** + * Whether the app is rendered inside an embedding context (iframe). + * + * Comparing window references never throws, even when the parent is + * cross-origin. Defaults to false outside a browser environment. + */ +export const isEmbedded = (): boolean => { + if (typeof window === 'undefined') { + return false + } + return window.self !== window.top +} + /** * Whether a URL fragment carries a transit code. Pure check, does not * consume anything.