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:
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user