feat(fpagent): authenticate the wizard's a2loft calls with the session token

The fingerprint wizard's staff-lookup and fingerprint-save proxies forwarded the page's OTP with the session phone. a2loft resolves that phone against the domain being addressed, so an operator who holds only capture_biometric was rejected unless they were a domain super-user. The cropper already saves with the session token.

- tools/server.js: both a2loft proxies send Authorization: Bearer <session token>, no phone/otp; validateProxy drops the OTP and phone checks.
- tools/public/assets/fp: both OTP fields, otpLookup/otpSave state, request params and resets removed; canLookup/canSave no longer gate on OTP.
- Tests assert the Bearer header, no query auth, no OTP field in the served page, and both gates without a credential (66/66 pass).
- README and Help page updated.

Requires the a2loft fingerprint-token-auth change deployed first. OpenSpec change: fingerprint-token-auth.
This commit is contained in:
2026-10-02 20:41:43 +01:00
parent 548e33390c
commit 14f6fd6a8e
13 changed files with 262 additions and 67 deletions
+4 -11
View File
@@ -25,8 +25,6 @@ Vue.config.ignoredElements = [/^cds-/];
agent: { connected: false, version: '', captureAgentId: '', available: null, device: '' },
domain: '',
reference: '',
otpLookup: '',
otpSave: '',
staff: null,
busy: { lookup: false, save: false },
refreshing: false,
@@ -49,10 +47,10 @@ Vue.config.ignoredElements = [/^cds-/];
this.fingers['right-index-finger'].status === 'done';
},
canLookup: function () {
return !!(this.domain && this.reference && this.otpLookup) && !this.busy.lookup;
return !!(this.domain && this.reference) && !this.busy.lookup;
},
canSave: function () {
return this.bothCaptured && !!this.otpSave && !this.busy.save;
return this.bothCaptured && !this.busy.save;
},
refreshUnlocksAt: function () {
var ttl = (this.session && this.session.domainCacheTtlMs) || 5 * 60 * 1000;
@@ -296,9 +294,7 @@ Vue.config.ignoredElements = [/^cds-/];
if (!this.canLookup) return;
this.busy.lookup = true;
axios.post(
'/api/' + encodeURIComponent(this.domain) + '/staff/' + encodeURIComponent(this.reference),
null,
{ params: { otp: this.otpLookup } }
'/api/' + encodeURIComponent(this.domain) + '/staff/' + encodeURIComponent(this.reference)
).then(function (r) {
if (!r.data.ok) {
self.showNotif('error', 'Not found', 'Operation failed. ' + (r.data.message || '') + ' (reference ' + self.reference + ')');
@@ -322,8 +318,7 @@ Vue.config.ignoredElements = [/^cds-/];
});
axios.post(
'/api/' + encodeURIComponent(this.domain) + '/staff/' + encodeURIComponent(this.reference) + '/fingerprints',
{ fingerprints: fingerprints },
{ params: { otp: this.otpSave } }
{ fingerprints: fingerprints }
).then(function (r) {
if (r.data.ok) {
self.showNotif('success', 'Enrollment saved',
@@ -343,8 +338,6 @@ Vue.config.ignoredElements = [/^cds-/];
newEnrollment: function () {
this.staff = null;
this.reference = '';
this.otpLookup = '';
this.otpSave = '';
this.fingers = { 'right-thumb': newFinger(), 'right-index-finger': newFinger() };
this.capturing = null;
this.cancelling = false;
-8
View File
@@ -54,10 +54,6 @@
<label class="cds--label" for="reference">Staff reference</label>
<input id="reference" class="cds--text-input" v-model.trim="reference" v-on:keyup.enter="lookup" placeholder="e.g. UC/0231">
</div>
<div style="min-width:130px; max-width:170px;">
<label class="cds--label" for="otpLookup">OTP</label>
<input id="otpLookup" class="cds--text-input" v-model.trim="otpLookup" v-on:keyup.enter="lookup" autocomplete="one-time-code">
</div>
<cds-button v-bind:disabled="!canLookup" v-on:click="lookup">
<svg slot="icon" width="16" height="16" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M29,27.5859l-7.5521-7.5521a11.0177,11.0177,0,1,0-1.4141,1.4141L27.5859,29ZM4,13a9,9,0,1,1,9,9A9.01,9.01,0,0,1,4,13Z"/></svg>
<span v-if="!busy.lookup">Get details</span>
@@ -136,10 +132,6 @@
<span class="hint">Both fingerprints will be saved for {{ staff ? staff.name : '' }} ({{ reference }})</span>
</div>
<div class="row-gap" style="align-items:flex-end;">
<div style="min-width:150px; max-width:200px;">
<label class="cds--label" for="otpSave">OTP</label>
<input id="otpSave" class="cds--text-input" v-model.trim="otpSave" autocomplete="one-time-code">
</div>
<cds-button v-bind:disabled="!canSave" v-on:click="save">
<svg slot="icon" width="16" height="16" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="M27.71,9.29l-5-5A1,1,0,0,0,22,4H6A2,2,0,0,0,4,6V26a2,2,0,0,0,2,2H26a2,2,0,0,0,2-2V10A1,1,0,0,0,27.71,9.29ZM12,6h8v4H12Zm8,20H12V18h8Zm2,0V18a2,2,0,0,0-2-2H12a2,2,0,0,0-2,2v8H6V6h4v4a2,2,0,0,0,2,2h8a2,2,0,0,0,2-2V6.41l4,4V26Z"/></svg>
<span v-if="!busy.save">Save enrollment</span>
+1 -1
View File
@@ -48,7 +48,7 @@
<p>Enroll staff fingerprints with the scanner attached to this laptop. Captures are authorized per domain.</p>
<ol class="help-steps">
<li>Open <strong>Fingerprint Agent</strong> from the landing page or the menu.</li>
<li>Choose the domain, enter the staff reference and the OTP, then look up the profile.</li>
<li>Choose the domain, enter the staff reference, then look up the profile.</li>
<li>Confirm the profile is the right person.</li>
<li>Follow the on-screen prompts and place each finger on the attached scanner when asked.</li>
<li>Review the captured prints and save.</li>
+15 -24
View File
@@ -704,19 +704,11 @@ export function createApp(overrides = {}) {
);
}
// ── a2loft proxies: same-origin, phone injected from the session ──────────
const a2loftUrl = (domain, reference, otp, phone) => {
const q = new URLSearchParams({ phone, otp });
return `${cfg.a2loftBase.replace(/\/+$/, '')}/identity/${encodeURIComponent(domain)}/${encodeURIComponent(
reference
)}?${q.toString()}`;
};
const a2loftSaveUrl = (domain, reference, otp, phone) => {
const q = new URLSearchParams({ phone, otp });
return `${cfg.a2loftBase.replace(/\/+$/, '')}/identity/${encodeURIComponent(domain)}/fingerprints/${encodeURIComponent(
reference
)}?${q.toString()}`;
};
// ── a2loft proxies: same-origin, bearer token from the session ────────────
const a2loftUrl = (domain, reference) =>
`${cfg.a2loftBase.replace(/\/+$/, '')}/identity/${encodeURIComponent(domain)}/${encodeURIComponent(reference)}`;
const a2loftSaveUrl = (domain, reference) =>
`${cfg.a2loftBase.replace(/\/+$/, '')}/identity/${encodeURIComponent(domain)}/fingerprints/${encodeURIComponent(reference)}`;
const validateProxy = (req, res) => {
const { domain, reference } = req.params;
@@ -728,21 +720,15 @@ export function createApp(overrides = {}) {
res.status(400).json({ ok: false, message: 'Missing staff reference.' });
return false;
}
if (!req.query.otp) {
res.status(400).json({ ok: false, message: 'OTP is required.' });
return false;
}
if (!req.session.phone) {
res.status(400).json({ ok: false, message: 'The signed-in account has no phone number.' });
return false;
}
return true;
};
app.post('/api/:domain/staff/:reference', requireApi, async (req, res) => {
if (!validateProxy(req, res)) return;
try {
const r = await cfg.fetch(a2loftUrl(req.params.domain, req.params.reference, req.query.otp, req.session.phone));
const r = await cfg.fetch(a2loftUrl(req.params.domain, req.params.reference), {
headers: { authorization: `Bearer ${req.session.token}` },
});
res.status(r.status).type('json').send(await r.text());
} catch {
res.status(502).json({ ok: false, message: 'Could not reach the identity service.' });
@@ -755,8 +741,13 @@ export function createApp(overrides = {}) {
return res.status(400).json({ ok: false, message: 'A fingerprints array is required.' });
}
try {
const r = await cfg.fetch(a2loftSaveUrl(req.params.domain, req.params.reference, req.query.otp, req.session.phone), {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(req.body)
const r = await cfg.fetch(a2loftSaveUrl(req.params.domain, req.params.reference), {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${req.session.token}`,
},
body: JSON.stringify(req.body),
});
res.status(r.status).type('json').send(await r.text());
} catch {
+10
View File
@@ -129,6 +129,16 @@ test('capture is refused when no reader is attached', () => {
assert.equal(vmInst.notif.show, true);
});
test('lookup and save need no OTP credential', () => {
const vmInst = makeVm(loadApp());
vmInst.domain = 'uc';
vmInst.reference = 'UC/1';
assert.equal(vmInst.canLookup, true);
vmInst.fingers['right-thumb'].status = 'done';
vmInst.fingers['right-index-finger'].status = 'done';
assert.equal(vmInst.canSave, true);
});
test('disconnect clears availability', () => {
const app = loadApp();
const vmInst = makeVm(app);
+13 -15
View File
@@ -62,7 +62,7 @@ async function startFakeA2loft() {
let raw = '';
req.on('data', (c) => (raw += c));
req.on('end', () => {
calls.push({ method: req.method, url: req.url, body: raw ? JSON.parse(raw) : null });
calls.push({ method: req.method, url: req.url, headers: req.headers, body: raw ? JSON.parse(raw) : null });
const staff = {
ok: true,
data: {
@@ -207,11 +207,11 @@ test('domain refresh refuses while the cache is fresh, succeeds after the TTL',
}
});
test('staff lookup proxy injects the session phone and forwards a2loft responses', async () => {
test('staff lookup proxy forwards the session token as a bearer credential', async () => {
const { base, a2loft, stop } = await bootApp();
try {
const cookies = await login(base);
const res = await fetch(`${base}/api/uc/staff/UC%2F0231?otp=4455`, {
const res = await fetch(`${base}/api/uc/staff/UC%2F0231`, {
method: 'POST',
headers: { cookie: cookies },
});
@@ -224,19 +224,19 @@ test('staff lookup proxy injects the session phone and forwards a2loft responses
assert.equal(call.method, 'GET');
const u = new URL(call.url, 'http://x');
assert.equal(decodeURIComponent(u.pathname), '/identity/uc/UC/0231');
assert.equal(u.searchParams.get('phone'), '+2348068287222');
assert.equal(u.searchParams.get('otp'), '4455');
assert.equal(u.search, '', 'no phone/otp query parameters');
assert.match(call.headers.authorization, /^Bearer .+/, 'session token forwarded');
} finally {
await stop();
}
});
test('fingerprint save proxy forwards the body unchanged', async () => {
test('fingerprint save proxy forwards the body and the token, not OTP', async () => {
const { base, a2loft, stop } = await bootApp();
try {
const cookies = await login(base);
const payload = { fingerprints: [{ finger: 'right-thumb', fingerprint: 'QUJD' }] };
const res = await fetch(`${base}/api/uc/staff/UC0231/fingerprints?otp=99`, {
const res = await fetch(`${base}/api/uc/staff/UC0231/fingerprints`, {
method: 'POST',
headers: { cookie: cookies, 'content-type': 'application/json' },
body: JSON.stringify(payload),
@@ -245,24 +245,21 @@ test('fingerprint save proxy forwards the body unchanged', async () => {
assert.deepEqual(await res.json(), { ok: true, message: 'saved' });
const call = a2loft.calls[0];
assert.equal(call.method, 'POST');
assert.ok(call.url.startsWith('/identity/uc/fingerprints/UC0231?'), `save URL shape, got: ${call.url}`);
assert.equal(call.url, '/identity/uc/fingerprints/UC0231');
assert.deepEqual(call.body, payload);
assert.match(call.headers.authorization, /^Bearer .+/, 'session token forwarded');
} finally {
await stop();
}
});
test('proxy validation: auth, otp, and domain are enforced before contacting a2loft', async () => {
test('proxy validation: auth and domain are enforced before contacting a2loft', async () => {
const { base, a2loft, stop } = await bootApp();
try {
const cookies = await login(base);
const anon = await fetch(`${base}/api/uc/staff/REF?otp=1`, { method: 'POST' });
const anon = await fetch(`${base}/api/uc/staff/REF`, { method: 'POST' });
assert.equal(anon.status, 401);
const noOtp = await fetch(`${base}/api/uc/staff/REF`, {
method: 'POST', headers: { cookie: cookies },
});
assert.equal(noOtp.status, 400);
const badDomain = await fetch(`${base}/api/nope/staff/REF?otp=1`, {
const badDomain = await fetch(`${base}/api/nope/staff/REF`, {
method: 'POST', headers: { cookie: cookies },
});
assert.equal(badDomain.status, 400);
@@ -298,6 +295,7 @@ test('pages receive the session injected server-side (no client fetch, no-store)
assert.match(html, /window\.__SESSION__ = \{/);
assert.match(html, /2348068287222/);
assert.match(html, /"domains"/, 'permitted domains injected too');
assert.ok(!/otpLookup|otpSave/.test(html), 'the wizard has no OTP fields');
} finally {
await stop();
}