From 22735c0709e9cd24b43035fb788857954c575f0f Mon Sep 17 00:00:00 2001 From: "Foluso ADEBISI (DevBox Aux)" Date: Thu, 1 Oct 2026 03:28:31 +0100 Subject: [PATCH] refactor(ui): inject the session server-side instead of fetching it client-side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pages are rendered by the server, which already holds the session — so /fingerprint, /cropper and the landing now embed window.__SESSION__ (JSON, with '<' escaped) instead of each fetching /auth/session on load: - /fingerprint gets token, identity, agentUrl and the permitted domains (the same cached context the endpoint returned); /cropper gets identity - landing nav is now server-rendered (Sign in vs user chip + Sign out); its client fetch script is deleted - per-user HTML is sent with Cache-Control: no-store and Vary: Cookie - express.static runs with index:false so '/' can be session-aware while assets stay session-free - GET /auth/session remains as the documented JSON contract (and the canonical place the domain-filter caching is exercised in tests) Client code shrinks by a fetch + error path per page; 3 new tests (injection, escaping, landing header) — 43 total. --- tools/public/assets/cropper/app.js | 19 ++++---- tools/public/assets/fp/app.js | 25 ++++++----- tools/public/index.html | 29 +------------ tools/server.js | 69 +++++++++++++++++++++++++++--- tools/test/cropper.test.js | 4 +- tools/test/server.test.js | 51 +++++++++++++++++++++- 6 files changed, 137 insertions(+), 60 deletions(-) diff --git a/tools/public/assets/cropper/app.js b/tools/public/assets/cropper/app.js index f33e22d..6f2a415 100644 --- a/tools/public/assets/cropper/app.js +++ b/tools/public/assets/cropper/app.js @@ -2,6 +2,7 @@ Identity comes from the YOUreka session; the only operator credential is the save-time OTP (required by a2loft). Notifications are Carbon toasts. */ /* global Vue, axios, Croppa, customElements */ +/* window.__SESSION__ is injected server-side by /cropper */ Vue.config.ignoredElements = [/^cds-/]; (function () { @@ -69,17 +70,13 @@ Vue.config.ignoredElements = [/^cds-/]; created: function () { var self = this; - axios.get('/auth/session').then(function (r) { - self.session = r.data; - self.loading = false; - }).catch(function (err) { - if (err.response && err.response.status === 401) { - window.location.href = '/auth/login'; - return; - } - self.loading = false; - self.toast('error', 'Session error', 'Could not load your session. Please refresh the page.'); - }); + // injected by the server when the page was rendered (no round-trip) + this.session = window.__SESSION__ || null; + if (!this.session) { + window.location.href = '/auth/login'; + return; + } + this.loading = false; Promise.all( ['cds-button', 'cds-toast', 'cds-toast-item', 'cds-tag'].map(function (n) { return customElements.whenDefined(n).catch(function () {}); diff --git a/tools/public/assets/fp/app.js b/tools/public/assets/fp/app.js index 305a662..3e67330 100644 --- a/tools/public/assets/fp/app.js +++ b/tools/public/assets/fp/app.js @@ -1,6 +1,7 @@ /* Fingerprint Agent — enrollment wizard. Captures run through the local fpagent binary; artifacts are saved via the app's a2loft proxy. */ /* global Vue, axios, io, customElements */ +/* window.__SESSION__ is injected server-side by /fingerprint */ Vue.config.ignoredElements = [/^cds-/]; (function () { @@ -102,19 +103,17 @@ Vue.config.ignoredElements = [/^cds-/]; }, loadSession: function () { - var self = this; - return axios.get('/auth/session').then(function (r) { - self.session = r.data; - if (self.session.domains.length) self.domain = self.session.domains[0].code; - self.loading = false; - }).catch(function (err) { - if (err.response && err.response.status === 401) { - window.location.href = '/auth/login'; - return; - } - self.loading = false; - self.showNotif('error', 'Session error', 'Could not load your session. Please refresh the page.', true); - }); + // injected by the server when the page was rendered (no round-trip) + this.session = window.__SESSION__ || null; + if (!this.session) { + window.location.href = '/auth/login'; + return Promise.resolve(); + } + if (this.session.domains && this.session.domains.length) { + this.domain = this.session.domains[0].code; + } + this.loading = false; + return Promise.resolve(); }, refreshDomains: function () { diff --git a/tools/public/index.html b/tools/public/index.html index 9a1ac15..cc902e8 100644 --- a/tools/public/index.html +++ b/tools/public/index.html @@ -22,9 +22,7 @@ YOUreka Tools - + @@ -69,30 +67,5 @@ Scan captures run locally via the fpagent agent — no scanner data leaves this machine except the enrolled artifacts you save. - diff --git a/tools/server.js b/tools/server.js index 57081b2..0e87a4b 100644 --- a/tools/server.js +++ b/tools/server.js @@ -8,6 +8,7 @@ // production always runs on the constants below. import express from 'express'; import path from 'node:path'; +import fs from 'node:fs'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { loadYrkaSession, extractPbCookieValue } from './yrka-auth.js'; import { permittedDomains, stripPhonePrefix, CACHE_FRESH, DOMAIN_TTL_MS, cropPermission } from './permit.js'; @@ -160,7 +161,7 @@ export function createApp(overrides = {}) { }); // ── static assets (no session work per asset) ───────────────────────────── - app.use(express.static(path.join(__dirname, 'public'))); + app.use(express.static(path.join(__dirname, 'public'), { index: false })); // assets only; '/' is session-aware // ── session middleware: recover / refresh the YOUreka Auth session ──────── app.use(async (req, res, next) => { @@ -277,6 +278,56 @@ export function createApp(overrides = {}) { domainsCheckedAt: checkedAt, }); + const sessionPayload = async (req, { withDomains = false } = {}) => { + const base = { + phone: req.session.phone, + firstName: req.session.firstName, + lastName: req.session.lastName, + }; + if (!withDomains) return base; + const { codes, checkedAt } = await domainContext(req); + return { + ...base, + token: req.session.token, + agentUrl: cfg.agentUrl, + domainCacheTtlMs: cfg.ttlMs, + ...domainPayload(codes, checkedAt), + }; + }; + + /** + * Render a page with the operator's session injected server-side, so the + * client never has to fetch it (and never flashes a loading state). + * Per-user content — must not be cached. + */ + const renderPage = (res, file, session) => { + fs.readFile(path.join(__dirname, 'public', 'assets', file), 'utf8', (error, html) => { + if (error) return errorPage(res, 500, 'Something went wrong', 'The page could not be loaded.'); + const data = session + ? JSON.stringify(session).replace(/', ` \n`)); + }); + }; + + app.get('/', (req, res, next) => { + fs.readFile(path.join(__dirname, 'public', 'index.html'), 'utf8', (error, html) => { + if (error) return next(error); + const nav = req.session + ? `${esc((req.session.firstName || req.session.phone || '?').trim().slice(0, 2).toUpperCase())}${esc(req.session.firstName || req.session.phone || '')}Sign out` + : 'Sign in'; + res + .set('Cache-Control', 'no-store') + .set('Vary', 'Cookie') + .type('html') + .send(html.replace('', nav)); + }); + }); + app.get('/auth/session', requireApi, async (req, res, next) => { try { const { codes, checkedAt } = await domainContext(req); @@ -310,8 +361,12 @@ export function createApp(overrides = {}) { }); // ── wizard page (session required) ──────────────────────────────────────── - app.get('/fingerprint', requirePage, (req, res) => { - res.sendFile(path.join(__dirname, 'public', 'assets', 'fp', 'index.html')); + app.get('/fingerprint', requirePage, async (req, res, next) => { + try { + renderPage(res, 'fp/index.html', await sessionPayload(req, { withDomains: true })); + } catch (e) { + next(e); + } }); // ── cropper page + namespaced API (session + crop_image required) ──────── @@ -343,8 +398,12 @@ export function createApp(overrides = {}) { } next(); }; - app.get('/cropper', requirePage, pageCropGate, (req, res) => { - res.sendFile(path.join(__dirname, 'public', 'assets', 'cropper', 'index.html')); + app.get('/cropper', requirePage, pageCropGate, (req, res, next) => { + renderPage(res, 'cropper/index.html', { + phone: req.session.phone, + firstName: req.session.firstName, + lastName: req.session.lastName, + }); }); const cropperClients = cfg.cropperClients !== undefined ? cfg.cropperClients : buildCropperClients(); if (cropperClients) { diff --git a/tools/test/cropper.test.js b/tools/test/cropper.test.js index 869f781..87f03c7 100644 --- a/tools/test/cropper.test.js +++ b/tools/test/cropper.test.js @@ -230,7 +230,9 @@ test('crop gate: permitted operator loads the page; denied operator does not', a const cookies = await login(ok.base); const page = await fetch(`${ok.base}/cropper`, { headers: { cookie: cookies } }); assert.equal(page.status, 200); - assert.match(await page.text(), /Smart Photo Editor|Image Cropper/); + const html = await page.text(); + assert.match(html, /Smart Photo Editor|Image Cropper/); + assert.match(html, /window\.__SESSION__ = \{/, 'session injected server-side'); } finally { await ok.stop(); } diff --git a/tools/test/server.test.js b/tools/test/server.test.js index c082113..55080b0 100644 --- a/tools/test/server.test.js +++ b/tools/test/server.test.js @@ -11,7 +11,7 @@ import { } from './helpers.js'; // Fake YOUreka portal: web-auth session + openfga verify. -async function startFakePortal({ cookieStatus = 'Activated', allowed = true, authErrorMessage } = {}) { +async function startFakePortal({ cookieStatus = 'Activated', allowed = true, authErrorMessage, firstName = 'Ada' } = {}) { return startServer((req, res) => { let raw = ''; req.on('data', (c) => (raw += c)); @@ -35,7 +35,7 @@ async function startFakePortal({ cookieStatus = 'Activated', allowed = true, aut status: cookieStatus, cookie: makePbCookie(makeJwt({ expiresInSec: 3600 })), phone: '+2348068287222', - firstName: 'Ada', + firstName, lastName: 'Obi', }); } @@ -280,3 +280,50 @@ test('upstream error text is HTML-escaped on the error page', async () => { await stop(); } }); + + +test('pages receive the session injected server-side (no client fetch, no-store)', async () => { + const { base, stop } = await bootApp(); + try { + const cookies = await login(base); + const res = await fetch(`${base}/fingerprint`, { headers: { cookie: cookies } }); + assert.equal(res.status, 200); + assert.match(res.headers.get('cache-control') || '', /no-store/); + const html = await res.text(); + assert.match(html, /window\.__SESSION__ = \{/); + assert.match(html, /2348068287222/); + assert.match(html, /"domains"/, 'permitted domains injected too'); + } finally { + await stop(); + } +}); + +test('injected session values cannot break out of the script tag', async () => { + const { base, stop } = await bootApp({}, { firstName: 'Ada' }); + try { + const cookies = await login(base); + const html = await (await fetch(`${base}/fingerprint`, { headers: { cookie: cookies } })).text(); + const injected = html.slice(html.indexOf('window.__SESSION__'), html.indexOf('')); + const payload = injected.slice(injected.indexOf('= ') + 2, injected.indexOf(';')); + assert.ok(!payload.includes(''), 'raw must not appear inside the injected JSON'); + assert.match(payload, /\\u003c/, 'markup is escaped in the payload'); + } finally { + await stop(); + } +}); + +test('landing header is server-rendered for the session', async () => { + const { base, stop } = await bootApp(); + try { + const anon = await (await fetch(`${base}/`)).text(); + assert.match(anon, /href="\/auth\/login">Sign in'), 'marker is replaced'); + + const cookies = await login(base); + const authed = await (await fetch(`${base}/`, { headers: { cookie: cookies } })).text(); + assert.match(authed, /Ada/); + assert.match(authed, /href="\/auth\/logout">Sign out