diff --git a/website/src/main/resources/static/js/app.js b/website/src/main/resources/static/js/app.js index bc3deef2..8d5aa025 100644 --- a/website/src/main/resources/static/js/app.js +++ b/website/src/main/resources/static/js/app.js @@ -11,7 +11,13 @@ import './components/footer.js'; import './components/blog.js'; import './components/gallery.js'; import pubsub from './components/pubsub.js'; -import { clearSharedFolderStreamingAuth } from './lib/shared-folder-streaming.js'; +import { + clearSharedFolderStreamingAuth, + installSharedFolderAuthRecovery, +} from './lib/shared-folder-streaming.js'; +import { getAuthToken } from './lib/util.js'; + +installSharedFolderAuthRecovery(getAuthToken); /** Wire core layout and global auth behavior once DOM is ready. */ document.addEventListener('DOMContentLoaded', () => { diff --git a/website/src/main/resources/static/js/lib/shared-folder-streaming.js b/website/src/main/resources/static/js/lib/shared-folder-streaming.js index 801ea1e7..9f62e00d 100644 --- a/website/src/main/resources/static/js/lib/shared-folder-streaming.js +++ b/website/src/main/resources/static/js/lib/shared-folder-streaming.js @@ -74,6 +74,23 @@ export function clearSharedFolderStreamingAuth() { } } +/** Let every signed-in page rehydrate the active worker after a page navigation. */ +export function installSharedFolderAuthRecovery( + getToken, + serviceWorker = typeof navigator === 'undefined' ? null : navigator.serviceWorker, +) { + if (typeof getToken !== 'function' || !serviceWorker?.addEventListener) return; + serviceWorker.addEventListener('message', event => { + if (event.data?.type !== 'shared-folder-auth-request-token') return; + const controller = serviceWorker.controller; + if (event.source !== controller || !event.ports?.[0]) return; + event.ports[0].postMessage({ + type: 'shared-folder-auth-recovery', + token: getToken() || null, + }); + }); +} + function isExpectedWorker(worker) { return !!worker && new URL(worker.scriptURL).pathname === SHARED_FOLDER_AUTH_WORKER_PATH; diff --git a/website/src/main/resources/static/js/lib/shared-folder-worker-runtime.js b/website/src/main/resources/static/js/lib/shared-folder-worker-runtime.js index 4ed3f3eb..9240b9c7 100644 --- a/website/src/main/resources/static/js/lib/shared-folder-worker-runtime.js +++ b/website/src/main/resources/static/js/lib/shared-folder-worker-runtime.js @@ -32,6 +32,7 @@ export async function respondToSharedFolderFetch({ downloadTokens, nowFn(), ); + if (!token) token = requestBearerToken(request); if (!token) token = clientTokens.get(clientId); if (!token) { token = await recoverClientToken({ @@ -64,6 +65,12 @@ export async function respondToSharedFolderFetch({ return response; } +function requestBearerToken(request) { + const authorization = request.headers.get('Authorization') || ''; + if (!authorization.startsWith('Bearer ')) return ''; + return authorization.substring('Bearer '.length).trim(); +} + /** Stage one exact short-lived download token without persisting it or putting it in the URL. */ export function stageSharedFolderDownloadAuthorization({ requestUrl, diff --git a/website/src/test/js/shared-folder-streaming.test.js b/website/src/test/js/shared-folder-streaming.test.js index b9a3bb31..eb631dfa 100644 --- a/website/src/test/js/shared-folder-streaming.test.js +++ b/website/src/test/js/shared-folder-streaming.test.js @@ -4,6 +4,7 @@ import test from 'node:test'; import { attachSharedFolderAuthorization, + installSharedFolderAuthRecovery, isSharedFolderApiRequest, sharedFolderDownloadRequestUrl, prepareSharedFolderStreamingAuth, @@ -79,6 +80,31 @@ test('download and binary-preview denial states are actionable without a rejecte }); }); +test('global auth recovery answers an existing worker after navigation to Back Office', () => { + let onMessage; + const replies = []; + const controller = { scriptURL: `${origin}/shared-folder-auth-sw.js` }; + const serviceWorker = { + controller, + addEventListener(type, listener) { + assert.equal(type, 'message'); + onMessage = listener; + }, + }; + + installSharedFolderAuthRecovery(() => 'current-jwt', serviceWorker); + onMessage({ + data: { type: 'shared-folder-auth-request-token' }, + source: controller, + ports: [{ postMessage: reply => replies.push(reply) }], + }); + + assert.deepEqual(replies, [{ + type: 'shared-folder-auth-recovery', + token: 'current-jwt', + }]); +}); + test('shared-folder page starts native anchor and media requests without Blob buffering', () => { const page = fs.readFileSync('website/src/main/resources/static/js/shared-folder.js', 'utf8'); const worker = fs.readFileSync('website/src/main/resources/static/shared-folder-auth-sw.js', 'utf8'); diff --git a/website/src/test/js/shared-folder-worker-runtime.test.js b/website/src/test/js/shared-folder-worker-runtime.test.js index 15c26968..d3e94a7e 100644 --- a/website/src/test/js/shared-folder-worker-runtime.test.js +++ b/website/src/test/js/shared-folder-worker-runtime.test.js @@ -53,6 +53,28 @@ test('lost worker token is rehydrated from the initiating controlled client befo assert.equal(requests[0].options.cache, 'no-store'); }); +test('an authenticated shared-folder API fetch keeps its bearer token across page navigation', async () => { + const requests = []; + const response = await respondToSharedFolderFetch({ + request: new Request(apiUrl, { + headers: { Authorization: 'Bearer back-office-jwt' }, + }), + clientId: 'back-office-client', + clientTokens: new Map(), + clients: { + get: async () => assert.fail('an explicit bearer token must not require client recovery'), + }, + origin, + fetchFn: async request => { + requests.push(request); + return new Response('admin data', { status: 200 }); + }, + }); + + assert.equal(response.status, 200); + assert.equal(requests[0].headers.get('Authorization'), 'Bearer back-office-jwt'); +}); + test('a token-recovery timeout returns a controlled denial instead of falling through unauthenticated', async () => { const tokens = new Map(); const messages = [];