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