mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-13 12:17:24 +00:00
(frontend) alternative auth without relying on sameSite cookie
This commit is contained in:
+24
-17
@@ -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 (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{!isSDKContext && <AppInitialization />}
|
||||
<Suspense fallback={null}>
|
||||
<I18nProvider locale={i18n.language}>
|
||||
<Layout>
|
||||
<Switch>
|
||||
{Object.entries(routes).map(([, route], i) => (
|
||||
<Route key={i} path={route.path} component={route.Component} />
|
||||
))}
|
||||
<Route component={NotFoundScreen} />
|
||||
</Switch>
|
||||
</Layout>
|
||||
<ReactQueryDevtools
|
||||
initialIsOpen={false}
|
||||
buttonPosition="bottom-left"
|
||||
/>
|
||||
</I18nProvider>
|
||||
</Suspense>
|
||||
<TransitCodeGate>
|
||||
{!isSDKContext && <AppInitialization />}
|
||||
<Suspense fallback={null}>
|
||||
<I18nProvider locale={i18n.language}>
|
||||
<Layout>
|
||||
<Switch>
|
||||
{Object.entries(routes).map(([, route], i) => (
|
||||
<Route
|
||||
key={i}
|
||||
path={route.path}
|
||||
component={route.Component}
|
||||
/>
|
||||
))}
|
||||
<Route component={NotFoundScreen} />
|
||||
</Switch>
|
||||
</Layout>
|
||||
<ReactQueryDevtools
|
||||
initialIsOpen={false}
|
||||
buttonPosition="bottom-left"
|
||||
/>
|
||||
</I18nProvider>
|
||||
</Suspense>
|
||||
</TransitCodeGate>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import { ApiError } from './ApiError'
|
||||
import { apiUrl } from './apiUrl'
|
||||
import { getAccessToken } from '@/stores/accessToken'
|
||||
|
||||
export const fetchApi = async <T = Record<string, unknown>>(
|
||||
url: string,
|
||||
options?: RequestInit
|
||||
): Promise<T> => {
|
||||
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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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<ApiAccessToken> => {
|
||||
return fetchApi<ApiAccessToken>('/users/exchange-access-token/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code }),
|
||||
})
|
||||
}
|
||||
|
||||
const runInitialization = async (): Promise<void> => {
|
||||
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<void> | 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<void> => {
|
||||
if (!initialization) {
|
||||
initialization = runInitialization()
|
||||
}
|
||||
return initialization
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 <TransitCodeExchange>{children}</TransitCodeExchange>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
) : (
|
||||
<LoadingScreen header={false} footer={false} delay={1000} />
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, () => '')
|
||||
@@ -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<State>({
|
||||
accessToken: null,
|
||||
})
|
||||
|
||||
export const setAccessToken = (accessToken: string | null) => {
|
||||
accessTokenStore.accessToken = accessToken
|
||||
}
|
||||
|
||||
export const getAccessToken = () => accessTokenStore.accessToken
|
||||
Reference in New Issue
Block a user