refactor(ui): inject the session server-side instead of fetching it client-side

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.
This commit is contained in:
2026-10-01 03:28:31 +01:00
parent 431a452d52
commit 22735c0709
6 changed files with 137 additions and 60 deletions
+5 -8
View File
@@ -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) {
// 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;
}
self.loading = false;
self.toast('error', 'Session error', 'Could not load your session. Please refresh the page.');
});
this.loading = false;
Promise.all(
['cds-button', 'cds-toast', 'cds-toast-item', 'cds-tag'].map(function (n) {
return customElements.whenDefined(n).catch(function () {});
+10 -11
View File
@@ -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) {
// 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;
return Promise.resolve();
}
self.loading = false;
self.showNotif('error', 'Session error', 'Could not load your session. Please refresh the page.', true);
});
if (this.session.domains && this.session.domains.length) {
this.domain = this.session.domains[0].code;
}
this.loading = false;
return Promise.resolve();
},
refreshDomains: function () {
+1 -28
View File
@@ -22,9 +22,7 @@
</span>
YOUreka <small>Tools</small>
</a>
<div class="nav-right" id="nav-session">
<a class="linklike" href="/auth/login">Sign in</a>
</div>
<div class="nav-right"><!--session-nav--></div>
</div>
</nav>
@@ -69,30 +67,5 @@
<span>Scan captures run locally via the fpagent agent &mdash; no scanner data leaves this machine except the enrolled artifacts you save.</span>
</div>
</footer>
<script>
// Reflect the current session in the header: Sign in vs user chip + Sign out.
fetch('/auth/session')
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (session) {
var host = document.getElementById('nav-session');
if (!session) return; // keep the Sign in link
var name = (session.firstName || session.phone || '?').trim();
host.textContent = '';
var chip = document.createElement('span');
chip.className = 'user-chip';
var avatar = document.createElement('span');
avatar.className = 'avatar';
avatar.textContent = name.slice(0, 2).toUpperCase(); // textContent, never innerHTML
var label = document.createElement('span');
label.textContent = name;
chip.append(avatar, label);
var signOut = document.createElement('a');
signOut.className = 'linklike';
signOut.href = '/auth/logout';
signOut.textContent = 'Sign out';
host.append(chip, signOut);
})
.catch(function () { /* keep Sign in */ });
</script>
</body>
</html>
+64 -5
View File
@@ -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(/</g, '\\u003c') // keep markup out of the script tag
: 'null';
res
.set('Cache-Control', 'no-store')
.set('Vary', 'Cookie')
.type('html')
.send(html.replace('</head>', ` <script>window.__SESSION__ = ${data};</script>\n</head>`));
});
};
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
? `<span class="user-chip"><span class="avatar">${esc((req.session.firstName || req.session.phone || '?').trim().slice(0, 2).toUpperCase())}</span>${esc(req.session.firstName || req.session.phone || '')}</span><a class="linklike" href="/auth/logout">Sign out</a>`
: '<a class="linklike" href="/auth/login">Sign in</a>';
res
.set('Cache-Control', 'no-store')
.set('Vary', 'Cookie')
.type('html')
.send(html.replace('<!--session-nav-->', 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) {
+3 -1
View File
@@ -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();
}
+49 -2
View File
@@ -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</script><script>alert(1)</script>' });
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('</head>'));
const payload = injected.slice(injected.indexOf('= ') + 2, injected.indexOf(';</script>'));
assert.ok(!payload.includes('</script>'), 'raw </script> 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</);
assert.ok(!anon.includes('<!--session-nav-->'), '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</);
} finally {
await stop();
}
});