diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 375d51bc..ae93648a 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -12,6 +12,7 @@ import { routes } from './routes' import './i18n/init' import { queryClient } from '@/api/queryClient' import { AppInitialization } from '@/components/AppInitialization' +import { TransitCodeGate } from '@/features/auth/components/TransitCodeGate' import { useIsSdkContext } from '@/features/sdk/hooks/useIsSdkContext' import { useApplyA11yFonts } from '@/hooks/useApplyA11yFonts' @@ -24,23 +25,29 @@ function App() { return ( - {!isSDKContext && } - - - - - {Object.entries(routes).map(([, route], i) => ( - - ))} - - - - - - + + {!isSDKContext && } + + + + + {Object.entries(routes).map(([, route], i) => ( + + ))} + + + + + + + ) } diff --git a/src/frontend/src/api/fetchApi.ts b/src/frontend/src/api/fetchApi.ts index a3c5039d..d252598a 100644 --- a/src/frontend/src/api/fetchApi.ts +++ b/src/frontend/src/api/fetchApi.ts @@ -1,17 +1,23 @@ import { ApiError } from './ApiError' import { apiUrl } from './apiUrl' +import { getAccessToken } from '@/stores/accessToken' export const fetchApi = async >( url: string, options?: RequestInit ): Promise => { const csrfToken = getCsrfToken() + // Embedded (iframe) mode: the user access token obtained through the + // transit code exchange authenticates requests in place of the session + // cookie, which is blocked in third-party contexts. + const accessToken = getAccessToken() const response = await fetch(apiUrl(url), { credentials: 'include', ...options, headers: { 'Content-Type': 'application/json', ...(!!csrfToken && { 'X-CSRFToken': csrfToken }), + ...(!!accessToken && { Authorization: `Bearer ${accessToken}` }), ...options?.headers, }, }) diff --git a/src/frontend/src/features/auth/api/exchangeAccessToken.ts b/src/frontend/src/features/auth/api/exchangeAccessToken.ts new file mode 100644 index 00000000..35ff4ffe --- /dev/null +++ b/src/frontend/src/features/auth/api/exchangeAccessToken.ts @@ -0,0 +1,64 @@ +import { fetchApi } from '@/api/fetchApi' +import { setAccessToken } from '@/stores/accessToken' +import { consumeTransitCodeFromFragment } from '../utils/transitCode' + +type ApiAccessToken = { + access_token: string + token_type: string + expires_in: number + scope: string +} + +/** + * Exchange a single-use transit code for a user access token. + * + * The endpoint is unauthenticated: the code itself is the credential. + */ +export const exchangeAccessToken = (code: string): Promise => { + return fetchApi('/users/exchange-access-token/', { + method: 'POST', + body: JSON.stringify({ code }), + }) +} + +const runInitialization = async (): Promise => { + const code = consumeTransitCodeFromFragment() + + if (!code) { + return + } + + try { + const { access_token } = await exchangeAccessToken(code) + setAccessToken(access_token) + } catch (error) { + console.warn('Transit code exchange failed:', error) + } +} + +let initialization: Promise | null = null + +/** + * Bootstrap the embedded (iframe) authentication, if applicable. + * + * When, and only when, a transit code is present in the URL fragment, + * exchange it for a user access token and keep it in the in-memory + * accessToken store: fetchApi then sends it as a Bearer header on every + * api call, authenticating the user exactly like a session cookie would. + * + * Must complete before anything fires an authenticated query, which the + * TransitCodeGate component guarantees by gating the app tree on it. + * + * Memoized: the fragment is consumed and the code exchanged exactly once, + * however many times this is called (StrictMode double-invoked effects, + * among others). Subsequent calls await the same promise. + * + * A failed exchange (expired or already used code) is not fatal: the app + * starts unauthenticated, falling back to the regular session flow. + */ +export const initializeAccessTokenFromFragment = (): Promise => { + if (!initialization) { + initialization = runInitialization() + } + return initialization +} diff --git a/src/frontend/src/features/auth/api/fetchUser.ts b/src/frontend/src/features/auth/api/fetchUser.ts index deed1760..57c9e0e8 100644 --- a/src/frontend/src/features/auth/api/fetchUser.ts +++ b/src/frontend/src/features/auth/api/fetchUser.ts @@ -2,6 +2,7 @@ import { ApiError } from '@/api/ApiError' import { fetchApi } from '@/api/fetchApi' import { type ApiUser } from './ApiUser' import { attemptSilentLogin, canAttemptSilentLogin } from '../utils/silentLogin' +import { getAccessToken } from '@/stores/accessToken' /** * fetch the logged-in user from the api. @@ -25,7 +26,13 @@ export const fetchUser = ( if (error instanceof ApiError && error.statusCode === 401) { // make sure to not resolve the promise while trying to silent login // so that consumers of fetchUser don't think the work already ended - if (opts.attemptSilent && canAttemptSilentLogin()) { + // Never attempt a silent login in embedded (token) mode: an OIDC + // redirect inside the iframe would break the embed. + if ( + opts.attemptSilent && + !getAccessToken() && + canAttemptSilentLogin() + ) { attemptSilentLogin(30) } else { resolve(false) diff --git a/src/frontend/src/features/auth/components/TransitCodeGate.tsx b/src/frontend/src/features/auth/components/TransitCodeGate.tsx new file mode 100644 index 00000000..a3081af5 --- /dev/null +++ b/src/frontend/src/features/auth/components/TransitCodeGate.tsx @@ -0,0 +1,67 @@ +import { useEffect, useState } from 'react' +import { LoadingScreen } from '@/components/LoadingScreen' +import { useHash } from '@/hooks/useHash' +import { initializeAccessTokenFromFragment } from '../api/exchangeAccessToken' +import { hasTransitCodeInFragment } from '../utils/transitCode' + +/** + * Gates the app tree on the embedded (iframe) authentication bootstrap. + * + * Without a transit code in the URL fragment — the overwhelmingly common + * case — the component early returns children synchronously: no state, + * no effect, no extra render, no loading screen. + * + * When a transit code is present, children are not mounted until it has + * been exchanged for a user access token, so that every authenticated + * query already carries the Authorization header. A loading screen is + * displayed in the meantime, as UserAware does. + */ +export const TransitCodeGate = ({ + children, +}: { + children: React.ReactNode +}) => { + 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. + const [needsExchange] = useState(() => hasTransitCodeInFragment(hash)) + + if (!needsExchange) { + return children + } + + return {children} +} + +/** + * Only ever mounted when a transit code is present: runs the memoized + * bootstrap (safe against StrictMode double-invoked effects) and holds + * children back until it settles. + */ +const TransitCodeExchange = ({ children }: { children: React.ReactNode }) => { + const [isReady, setIsReady] = useState(false) + + useEffect(() => { + let isMounted = true + initializeAccessTokenFromFragment().finally(() => { + console.log('$$ transit code exchange finished') + if (isMounted) { + console.log('$$ setIsReady') + setIsReady(true) + } + }) + return () => { + isMounted = false + } + }, []) + + console.log('$$ isReady', isReady) + + return isReady ? ( + children + ) : ( + + ) +} diff --git a/src/frontend/src/features/auth/utils/transitCode.ts b/src/frontend/src/features/auth/utils/transitCode.ts new file mode 100644 index 00000000..95b72fdd --- /dev/null +++ b/src/frontend/src/features/auth/utils/transitCode.ts @@ -0,0 +1,46 @@ +const TRANSIT_CODE_FRAGMENT_PARAM = 'transit_code' + +/** + * Whether a URL fragment carries a transit code. Pure check, does not + * consume anything. + */ +export const hasTransitCodeInFragment = (hash: string): boolean => { + if (!hash) { + return false + } + return new URLSearchParams(hash.replace(/^#/, '')).has( + TRANSIT_CODE_FRAGMENT_PARAM + ) +} + +/** + * Extract the transit code from the URL fragment, if any. + * + * The fragment is scrubbed from the address bar immediately, before any + * network call, so the code never lingers in the browser history. Any + * other fragment content is preserved. + */ +export const consumeTransitCodeFromFragment = (): string | null => { + if (typeof window === 'undefined' || !window.location.hash) { + return null + } + + const params = new URLSearchParams(window.location.hash.substring(1)) + const code = params.get(TRANSIT_CODE_FRAGMENT_PARAM) + + if (!code) { + return null + } + + params.delete(TRANSIT_CODE_FRAGMENT_PARAM) + const remaining = params.toString() + window.history.replaceState( + null, + '', + window.location.pathname + + window.location.search + + (remaining ? `#${remaining}` : '') + ) + + return code +} diff --git a/src/frontend/src/hooks/useHash.ts b/src/frontend/src/hooks/useHash.ts new file mode 100644 index 00000000..628800a1 --- /dev/null +++ b/src/frontend/src/hooks/useHash.ts @@ -0,0 +1,10 @@ +import { useLocationProperty } from 'wouter/use-browser-location' + +const hashSelector = () => + typeof window !== 'undefined' ? window.location.hash : '' + +/** + * Reactive window.location.hash, subscribed to wouter's navigation + * events (the same low-level primitive wouter builds useSearch upon). + */ +export const useHash = (): string => useLocationProperty(hashSelector, () => '') diff --git a/src/frontend/src/stores/accessToken.ts b/src/frontend/src/stores/accessToken.ts new file mode 100644 index 00000000..ac590640 --- /dev/null +++ b/src/frontend/src/stores/accessToken.ts @@ -0,0 +1,32 @@ +import { proxy } from 'valtio' + +type State = { + accessToken: string | null +} + +/** + * User access token for the embedded (iframe) mode. + * + * When Meet is rendered inside an iframe, third-party session cookies are + * blocked: the host application passes a single-use transit code in the + * URL fragment, exchanged at startup for a user access token (see + * features/auth/api/exchangeAccessToken) that authenticates every api + * call exactly like a session cookie would. + * + * The token deliberately lives in this in-memory store only: unlike other + * stores, it is never persisted (no subscribe/localStorage) and never + * appears in a URL. It is lost on reload, in which case the host page is + * expected to mint a fresh transit code. + * + * A non-null token also tells the app it is running in embedded mode: + * components can react to it with useSnapshot(accessTokenStore). + */ +export const accessTokenStore = proxy({ + accessToken: null, +}) + +export const setAccessToken = (accessToken: string | null) => { + accessTokenStore.accessToken = accessToken +} + +export const getAccessToken = () => accessTokenStore.accessToken