🔒️(frontend) restrict transit_code exchange to embedded context

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.
This commit is contained in:
lebaudantoine
2026-08-18 11:56:59 +02:00
parent d55d131dc6
commit 1378e59f75
3 changed files with 29 additions and 4 deletions
@@ -1,6 +1,9 @@
import { fetchApi } from '@/api/fetchApi' import { fetchApi } from '@/api/fetchApi'
import { setAccessToken } from '@/stores/accessToken' import { setAccessToken } from '@/stores/accessToken'
import { consumeTransitCodeFromFragment } from '../utils/transitCode' import {
consumeTransitCodeFromFragment,
isEmbedded,
} from '../utils/transitCode'
type ApiAccessToken = { type ApiAccessToken = {
access_token: string access_token: string
@@ -28,6 +31,11 @@ const runInitialization = async (): Promise<void> => {
return return
} }
if (!isEmbedded()) {
console.warn('Transit code ignored outside an embedded context')
return
}
try { try {
const { access_token } = await exchangeAccessToken(code) const { access_token } = await exchangeAccessToken(code)
setAccessToken(access_token) setAccessToken(access_token)
@@ -23,9 +23,13 @@ export const TransitCodeGate = ({
}) => { }) => {
const hash = useHash() const hash = useHash()
// Latch the decision on the initial hash: the bootstrap scrubs the // Note: the exchange only happens in an embedding context. This check lives
// fragment as soon as it starts, and the gate must not flip back to the // in initializeAccessTokenFromFragment, the single funnel for all bootstrap paths.
// fast path while the exchange is still in flight. // 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)) const [needsExchange] = useState(() => hasTransitCodeInFragment(hash))
if (!needsExchange) { if (!needsExchange) {
@@ -1,5 +1,18 @@
const TRANSIT_CODE_FRAGMENT_PARAM = 'transit_code' 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 * Whether a URL fragment carries a transit code. Pure check, does not
* consume anything. * consume anything.