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
+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();
}