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:
@@ -2,6 +2,7 @@
|
|||||||
Identity comes from the YOUreka session; the only operator credential is
|
Identity comes from the YOUreka session; the only operator credential is
|
||||||
the save-time OTP (required by a2loft). Notifications are Carbon toasts. */
|
the save-time OTP (required by a2loft). Notifications are Carbon toasts. */
|
||||||
/* global Vue, axios, Croppa, customElements */
|
/* global Vue, axios, Croppa, customElements */
|
||||||
|
/* window.__SESSION__ is injected server-side by /cropper */
|
||||||
Vue.config.ignoredElements = [/^cds-/];
|
Vue.config.ignoredElements = [/^cds-/];
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
@@ -69,17 +70,13 @@ Vue.config.ignoredElements = [/^cds-/];
|
|||||||
|
|
||||||
created: function () {
|
created: function () {
|
||||||
var self = this;
|
var self = this;
|
||||||
axios.get('/auth/session').then(function (r) {
|
// injected by the server when the page was rendered (no round-trip)
|
||||||
self.session = r.data;
|
this.session = window.__SESSION__ || null;
|
||||||
self.loading = false;
|
if (!this.session) {
|
||||||
}).catch(function (err) {
|
window.location.href = '/auth/login';
|
||||||
if (err.response && err.response.status === 401) {
|
return;
|
||||||
window.location.href = '/auth/login';
|
}
|
||||||
return;
|
this.loading = false;
|
||||||
}
|
|
||||||
self.loading = false;
|
|
||||||
self.toast('error', 'Session error', 'Could not load your session. Please refresh the page.');
|
|
||||||
});
|
|
||||||
Promise.all(
|
Promise.all(
|
||||||
['cds-button', 'cds-toast', 'cds-toast-item', 'cds-tag'].map(function (n) {
|
['cds-button', 'cds-toast', 'cds-toast-item', 'cds-tag'].map(function (n) {
|
||||||
return customElements.whenDefined(n).catch(function () {});
|
return customElements.whenDefined(n).catch(function () {});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/* Fingerprint Agent — enrollment wizard. Captures run through the local
|
/* Fingerprint Agent — enrollment wizard. Captures run through the local
|
||||||
fpagent binary; artifacts are saved via the app's a2loft proxy. */
|
fpagent binary; artifacts are saved via the app's a2loft proxy. */
|
||||||
/* global Vue, axios, io, customElements */
|
/* global Vue, axios, io, customElements */
|
||||||
|
/* window.__SESSION__ is injected server-side by /fingerprint */
|
||||||
Vue.config.ignoredElements = [/^cds-/];
|
Vue.config.ignoredElements = [/^cds-/];
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
@@ -102,19 +103,17 @@ Vue.config.ignoredElements = [/^cds-/];
|
|||||||
},
|
},
|
||||||
|
|
||||||
loadSession: function () {
|
loadSession: function () {
|
||||||
var self = this;
|
// injected by the server when the page was rendered (no round-trip)
|
||||||
return axios.get('/auth/session').then(function (r) {
|
this.session = window.__SESSION__ || null;
|
||||||
self.session = r.data;
|
if (!this.session) {
|
||||||
if (self.session.domains.length) self.domain = self.session.domains[0].code;
|
window.location.href = '/auth/login';
|
||||||
self.loading = false;
|
return Promise.resolve();
|
||||||
}).catch(function (err) {
|
}
|
||||||
if (err.response && err.response.status === 401) {
|
if (this.session.domains && this.session.domains.length) {
|
||||||
window.location.href = '/auth/login';
|
this.domain = this.session.domains[0].code;
|
||||||
return;
|
}
|
||||||
}
|
this.loading = false;
|
||||||
self.loading = false;
|
return Promise.resolve();
|
||||||
self.showNotif('error', 'Session error', 'Could not load your session. Please refresh the page.', true);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
refreshDomains: function () {
|
refreshDomains: function () {
|
||||||
|
|||||||
+1
-28
@@ -22,9 +22,7 @@
|
|||||||
</span>
|
</span>
|
||||||
YOUreka <small>Tools</small>
|
YOUreka <small>Tools</small>
|
||||||
</a>
|
</a>
|
||||||
<div class="nav-right" id="nav-session">
|
<div class="nav-right"><!--session-nav--></div>
|
||||||
<a class="linklike" href="/auth/login">Sign in</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -69,30 +67,5 @@
|
|||||||
<span>Scan captures run locally via the fpagent agent — no scanner data leaves this machine except the enrolled artifacts you save.</span>
|
<span>Scan captures run locally via the fpagent agent — no scanner data leaves this machine except the enrolled artifacts you save.</span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+64
-5
@@ -8,6 +8,7 @@
|
|||||||
// production always runs on the constants below.
|
// production always runs on the constants below.
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import fs from 'node:fs';
|
||||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
import { loadYrkaSession, extractPbCookieValue } from './yrka-auth.js';
|
import { loadYrkaSession, extractPbCookieValue } from './yrka-auth.js';
|
||||||
import { permittedDomains, stripPhonePrefix, CACHE_FRESH, DOMAIN_TTL_MS, cropPermission } from './permit.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) ─────────────────────────────
|
// ── 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 ────────
|
// ── session middleware: recover / refresh the YOUreka Auth session ────────
|
||||||
app.use(async (req, res, next) => {
|
app.use(async (req, res, next) => {
|
||||||
@@ -277,6 +278,56 @@ export function createApp(overrides = {}) {
|
|||||||
domainsCheckedAt: checkedAt,
|
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) => {
|
app.get('/auth/session', requireApi, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { codes, checkedAt } = await domainContext(req);
|
const { codes, checkedAt } = await domainContext(req);
|
||||||
@@ -310,8 +361,12 @@ export function createApp(overrides = {}) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── wizard page (session required) ────────────────────────────────────────
|
// ── wizard page (session required) ────────────────────────────────────────
|
||||||
app.get('/fingerprint', requirePage, (req, res) => {
|
app.get('/fingerprint', requirePage, async (req, res, next) => {
|
||||||
res.sendFile(path.join(__dirname, 'public', 'assets', 'fp', 'index.html'));
|
try {
|
||||||
|
renderPage(res, 'fp/index.html', await sessionPayload(req, { withDomains: true }));
|
||||||
|
} catch (e) {
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── cropper page + namespaced API (session + crop_image required) ────────
|
// ── cropper page + namespaced API (session + crop_image required) ────────
|
||||||
@@ -343,8 +398,12 @@ export function createApp(overrides = {}) {
|
|||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
app.get('/cropper', requirePage, pageCropGate, (req, res) => {
|
app.get('/cropper', requirePage, pageCropGate, (req, res, next) => {
|
||||||
res.sendFile(path.join(__dirname, 'public', 'assets', 'cropper', 'index.html'));
|
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();
|
const cropperClients = cfg.cropperClients !== undefined ? cfg.cropperClients : buildCropperClients();
|
||||||
if (cropperClients) {
|
if (cropperClients) {
|
||||||
|
|||||||
@@ -230,7 +230,9 @@ test('crop gate: permitted operator loads the page; denied operator does not', a
|
|||||||
const cookies = await login(ok.base);
|
const cookies = await login(ok.base);
|
||||||
const page = await fetch(`${ok.base}/cropper`, { headers: { cookie: cookies } });
|
const page = await fetch(`${ok.base}/cropper`, { headers: { cookie: cookies } });
|
||||||
assert.equal(page.status, 200);
|
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 {
|
} finally {
|
||||||
await ok.stop();
|
await ok.stop();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from './helpers.js';
|
} from './helpers.js';
|
||||||
|
|
||||||
// Fake YOUreka portal: web-auth session + openfga verify.
|
// 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) => {
|
return startServer((req, res) => {
|
||||||
let raw = '';
|
let raw = '';
|
||||||
req.on('data', (c) => (raw += c));
|
req.on('data', (c) => (raw += c));
|
||||||
@@ -35,7 +35,7 @@ async function startFakePortal({ cookieStatus = 'Activated', allowed = true, aut
|
|||||||
status: cookieStatus,
|
status: cookieStatus,
|
||||||
cookie: makePbCookie(makeJwt({ expiresInSec: 3600 })),
|
cookie: makePbCookie(makeJwt({ expiresInSec: 3600 })),
|
||||||
phone: '+2348068287222',
|
phone: '+2348068287222',
|
||||||
firstName: 'Ada',
|
firstName,
|
||||||
lastName: 'Obi',
|
lastName: 'Obi',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -280,3 +280,50 @@ test('upstream error text is HTML-escaped on the error page', async () => {
|
|||||||
await stop();
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user