diff --git a/packages/client/src/fetch.ts b/packages/client/src/fetch.ts index b050a618..2cd7d619 100644 --- a/packages/client/src/fetch.ts +++ b/packages/client/src/fetch.ts @@ -57,7 +57,7 @@ const refreshTokenStorage = { export async function refreshSession(apiOrigin: string) { const refreshToken = refreshTokenStorage.get(); - if (!refreshToken) return false; + if (!refreshToken) return refreshSessionViaIframe(); try { const response = await fetch(`${apiOrigin}/auth/refresh`, { method: 'POST', @@ -77,6 +77,46 @@ export async function refreshSession(apiOrigin: string) { } } +async function refreshSessionViaIframe() { + let iframe: HTMLIFrameElement | null = null; + try { + return await new Promise((resolve, reject) => { + const iframeUrl = `${import.meta.env.VITE_HOME_ORIGIN}/refresh-session`; + // go ahead and subscribe to postMessage events + window.addEventListener('message', (event) => { + if (event.data.type === 'refresh-session') { + if (event.data.success) { + console.debug('refreshed session via iframe'); + // store the new refresh token + refreshTokenStorage.set(event.data.success.refreshToken); + resolve(); + } + } + }); + iframe = document.createElement('iframe'); + iframe.style.display = 'none'; + iframe.src = iframeUrl; + iframe.addEventListener('load', () => { + console.debug('iframe loaded'); + }); + iframe.addEventListener('error', (ev) => { + console.error('iframe failed to load', ev.error); + reject(ev.error); + }); + document.body.appendChild(iframe); + + // failure case: if the iframe doesn't load, reject + setTimeout(() => { + reject(new Error('iframe-based session refresh timed out')); + }, 5000); + }); + } finally { + if (iframe) { + document.body.removeChild(iframe); + } + } +} + async function peekAtResponseBody(response: Response): Promise<{ body: any; clone: Response; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a77bd59..1de95e10 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -99,8 +99,6 @@ importers: specifier: ^18.2.0 version: 18.2.0 - apps/gnocchi/verdant/dist/esm/client: {} - apps/gnocchi/web: dependencies: '@a-type/ui': @@ -4739,7 +4737,7 @@ packages: '@types/react-dom': optional: true dependencies: - '@babel/runtime': 7.23.4 + '@babel/runtime': 7.24.1 '@radix-ui/primitive': 1.0.1 '@radix-ui/react-collapsible': 1.0.3(@types/react-dom@18.2.17)(@types/react@18.0.4)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-collection': 1.0.3(@types/react-dom@18.2.17)(@types/react@18.0.4)(react-dom@18.2.0)(react@18.2.0) @@ -4768,7 +4766,7 @@ packages: '@types/react-dom': optional: true dependencies: - '@babel/runtime': 7.23.4 + '@babel/runtime': 7.24.1 '@radix-ui/primitive': 1.0.1 '@radix-ui/react-collapsible': 1.0.3(@types/react@18.0.14)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-collection': 1.0.3(@types/react@18.0.14)(react-dom@18.2.0)(react@18.2.0) @@ -4946,7 +4944,7 @@ packages: '@types/react-dom': optional: true dependencies: - '@babel/runtime': 7.23.4 + '@babel/runtime': 7.24.1 '@radix-ui/primitive': 1.0.1 '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.0.4)(react@18.2.0) '@radix-ui/react-context': 1.0.1(@types/react@18.0.4)(react@18.2.0) @@ -4974,7 +4972,7 @@ packages: '@types/react-dom': optional: true dependencies: - '@babel/runtime': 7.23.4 + '@babel/runtime': 7.24.1 '@radix-ui/primitive': 1.0.1 '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.0.14)(react@18.2.0) '@radix-ui/react-context': 1.0.1(@types/react@18.0.14)(react@18.2.0) @@ -6373,7 +6371,7 @@ packages: '@types/react-dom': optional: true dependencies: - '@babel/runtime': 7.23.4 + '@babel/runtime': 7.24.1 '@radix-ui/primitive': 1.0.1 '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.0.4)(react@18.2.0) '@radix-ui/react-context': 1.0.1(@types/react@18.0.4)(react@18.2.0) @@ -6408,7 +6406,7 @@ packages: '@types/react-dom': optional: true dependencies: - '@babel/runtime': 7.23.4 + '@babel/runtime': 7.24.1 '@radix-ui/primitive': 1.0.1 '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.0.14)(react@18.2.0) '@radix-ui/react-context': 1.0.1(@types/react@18.0.14)(react@18.2.0) @@ -7132,7 +7130,7 @@ packages: '@types/react-dom': optional: true dependencies: - '@babel/runtime': 7.23.4 + '@babel/runtime': 7.24.1 '@radix-ui/primitive': 1.0.1 '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.0.4)(react@18.2.0) '@radix-ui/react-context': 1.0.1(@types/react@18.0.4)(react@18.2.0) @@ -7162,7 +7160,7 @@ packages: '@types/react-dom': optional: true dependencies: - '@babel/runtime': 7.23.4 + '@babel/runtime': 7.24.1 '@radix-ui/primitive': 1.0.1 '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.0.14)(react@18.2.0) '@radix-ui/react-context': 1.0.1(@types/react@18.0.14)(react@18.2.0) @@ -7407,7 +7405,7 @@ packages: '@types/react-dom': optional: true dependencies: - '@babel/runtime': 7.23.4 + '@babel/runtime': 7.24.1 '@radix-ui/number': 1.0.1 '@radix-ui/primitive': 1.0.1 '@radix-ui/react-collection': 1.0.3(@types/react-dom@18.2.17)(@types/react@18.0.4)(react-dom@18.2.0)(react@18.2.0) @@ -7448,7 +7446,7 @@ packages: '@types/react-dom': optional: true dependencies: - '@babel/runtime': 7.23.4 + '@babel/runtime': 7.24.1 '@radix-ui/number': 1.0.1 '@radix-ui/primitive': 1.0.1 '@radix-ui/react-collection': 1.0.3(@types/react@18.0.14)(react-dom@18.2.0)(react@18.2.0) @@ -7808,7 +7806,7 @@ packages: '@types/react-dom': optional: true dependencies: - '@babel/runtime': 7.23.4 + '@babel/runtime': 7.24.1 '@radix-ui/primitive': 1.0.1 '@radix-ui/react-collection': 1.0.3(@types/react-dom@18.2.17)(@types/react@18.0.4)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.0.4)(react@18.2.0) @@ -7840,7 +7838,7 @@ packages: '@types/react-dom': optional: true dependencies: - '@babel/runtime': 7.23.4 + '@babel/runtime': 7.24.1 '@radix-ui/primitive': 1.0.1 '@radix-ui/react-collection': 1.0.3(@types/react@18.0.14)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.0.14)(react@18.2.0) diff --git a/web/src/pages/RefreshSessionPage.tsx b/web/src/pages/RefreshSessionPage.tsx new file mode 100644 index 00000000..c0555a9c --- /dev/null +++ b/web/src/pages/RefreshSessionPage.tsx @@ -0,0 +1,21 @@ +import { useMe } from '@biscuits/client'; +import { useEffect } from 'react'; + +/** + * A stub page that just calls an API request. This will automatically trigger session + * refresh if the user is logged in. This page is rendered in an iframe by other apps + * on other origins to transparently keep the session alive without having to know + * the refresh token, which is stored in this app's origin localStorage. + */ +export function RefreshSessionPage() { + const [data] = useMe(); + const success = data.data?.me; + useEffect(() => { + if (window.top) { + window.top.postMessage({ type: 'refresh-session', success }, '*'); + } + }, [success]); + return null; +} + +export default RefreshSessionPage; diff --git a/web/src/pages/index.tsx b/web/src/pages/index.tsx index b2e60b2f..2f1f7c2f 100644 --- a/web/src/pages/index.tsx +++ b/web/src/pages/index.tsx @@ -2,6 +2,7 @@ import { TopLoader } from '@/components/nav/TopLoader.jsx'; import { Outlet, Router, makeRoutes } from '@verdant-web/react-router'; import { Suspense, lazy } from 'react'; import LoginPage from './LoginPage.jsx'; +import RefreshSessionPage from './RefreshSessionPage.jsx'; const HomePage = lazy(() => import('./HomePage.js')); const routes = makeRoutes([ @@ -30,6 +31,10 @@ const routes = makeRoutes([ path: '/invite/:code', component: lazy(() => import('./ClaimInvitePage.js')), }, + { + path: '/refresh-session', + component: RefreshSessionPage, + }, ]); export function Pages() {