mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-18 14:33: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 './i18n/init'
|
||||||
import { queryClient } from '@/api/queryClient'
|
import { queryClient } from '@/api/queryClient'
|
||||||
import { AppInitialization } from '@/components/AppInitialization'
|
import { AppInitialization } from '@/components/AppInitialization'
|
||||||
|
import { TransitCodeGate } from '@/features/auth/components/TransitCodeGate'
|
||||||
import { useIsSdkContext } from '@/features/sdk/hooks/useIsSdkContext'
|
import { useIsSdkContext } from '@/features/sdk/hooks/useIsSdkContext'
|
||||||
import { useApplyA11yFonts } from '@/hooks/useApplyA11yFonts'
|
import { useApplyA11yFonts } from '@/hooks/useApplyA11yFonts'
|
||||||
|
|
||||||
@@ -24,23 +25,29 @@ function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
{!isSDKContext && <AppInitialization />}
|
<TransitCodeGate>
|
||||||
<Suspense fallback={null}>
|
{!isSDKContext && <AppInitialization />}
|
||||||
<I18nProvider locale={i18n.language}>
|
<Suspense fallback={null}>
|
||||||
<Layout>
|
<I18nProvider locale={i18n.language}>
|
||||||
<Switch>
|
<Layout>
|
||||||
{Object.entries(routes).map(([, route], i) => (
|
<Switch>
|
||||||
<Route key={i} path={route.path} component={route.Component} />
|
{Object.entries(routes).map(([, route], i) => (
|
||||||
))}
|
<Route
|
||||||
<Route component={NotFoundScreen} />
|
key={i}
|
||||||
</Switch>
|
path={route.path}
|
||||||
</Layout>
|
component={route.Component}
|
||||||
<ReactQueryDevtools
|
/>
|
||||||
initialIsOpen={false}
|
))}
|
||||||
buttonPosition="bottom-left"
|
<Route component={NotFoundScreen} />
|
||||||
/>
|
</Switch>
|
||||||
</I18nProvider>
|
</Layout>
|
||||||
</Suspense>
|
<ReactQueryDevtools
|
||||||
|
initialIsOpen={false}
|
||||||
|
buttonPosition="bottom-left"
|
||||||
|
/>
|
||||||
|
</I18nProvider>
|
||||||
|
</Suspense>
|
||||||
|
</TransitCodeGate>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
import { ApiError } from './ApiError'
|
import { ApiError } from './ApiError'
|
||||||
import { apiUrl } from './apiUrl'
|
import { apiUrl } from './apiUrl'
|
||||||
|
import { getAccessToken } from '@/stores/accessToken'
|
||||||
|
|
||||||
export const fetchApi = async <T = Record<string, unknown>>(
|
export const fetchApi = async <T = Record<string, unknown>>(
|
||||||
url: string,
|
url: string,
|
||||||
options?: RequestInit
|
options?: RequestInit
|
||||||
): Promise<T> => {
|
): Promise<T> => {
|
||||||
const csrfToken = getCsrfToken()
|
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), {
|
const response = await fetch(apiUrl(url), {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...(!!csrfToken && { 'X-CSRFToken': csrfToken }),
|
...(!!csrfToken && { 'X-CSRFToken': csrfToken }),
|
||||||
|
...(!!accessToken && { Authorization: `Bearer ${accessToken}` }),
|
||||||
...options?.headers,
|
...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 { fetchApi } from '@/api/fetchApi'
|
||||||
import { type ApiUser } from './ApiUser'
|
import { type ApiUser } from './ApiUser'
|
||||||
import { attemptSilentLogin, canAttemptSilentLogin } from '../utils/silentLogin'
|
import { attemptSilentLogin, canAttemptSilentLogin } from '../utils/silentLogin'
|
||||||
|
import { getAccessToken } from '@/stores/accessToken'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* fetch the logged-in user from the api.
|
* fetch the logged-in user from the api.
|
||||||
@@ -25,7 +26,13 @@ export const fetchUser = (
|
|||||||
if (error instanceof ApiError && error.statusCode === 401) {
|
if (error instanceof ApiError && error.statusCode === 401) {
|
||||||
// make sure to not resolve the promise while trying to silent login
|
// make sure to not resolve the promise while trying to silent login
|
||||||
// so that consumers of fetchUser don't think the work already ended
|
// 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)
|
attemptSilentLogin(30)
|
||||||
} else {
|
} else {
|
||||||
resolve(false)
|
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