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
+8 -8
View File
@@ -107,11 +107,10 @@ is treated as anonymous and the stale cookie cleared.
### a2loft proxy
Staff lookup and the fingerprint save are proxied to `https://apis.a2loft.net`
under same-origin routes. The proxy attaches the operator's phone from the
server-side session and forwards the OTP supplied by the page; the operator
never sends credentials to a third party directly. The cropper's save call
instead authenticates with the session token (`Bearer`) after a live permission
check.
under same-origin routes. The proxy authenticates the operator with the
signed-in session's PocketBase token, sent to a2loft as a `Bearer` credential;
the operator never sends credentials to a third party directly. The cropper's
save call uses the same token flow after a live permission check.
## The Image Cropper
@@ -145,12 +144,13 @@ dotenvx set MINIO_SECRET_KEY "<value>" -f .env # add/rotate a value
The `/fingerprint` wizard walks the operator through:
1. **Locate profile** — pick a permitted domain, enter the staff reference and
OTP, and look up the record through the a2loft proxy.
1. **Locate profile** — pick a permitted domain, enter the staff reference, and
look up the record through the a2loft proxy.
2. **Capture** — right thumb and right index finger, one capture at a time, with
per-scan progress and cancel. The PocketBase token, domain and reference
travel with every `fprint-enroll` to fpagent.
3. **Save** — both templates are posted to a2loft with a fresh OTP.
3. **Save** — both templates are posted to a2loft authenticated with the
session token.
The wizard tracks fpagent's scanner availability: the header tag and the capture
panel show when the agent is offline or no reader is attached, and capture is
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-02
@@ -0,0 +1,49 @@
# Design
## Context
See proposal.md for motivation. The wizard already holds the PocketBase token
in the page (`window.__SESSION__.token`) for fpagent enrollment, and the
cropper's save already sends `Authorization: Bearer ${req.session.token}` to
a2loft through its own proxy (`tools/cropper.js`). The two remaining OTP calls
are the wizard's staff-lookup and fingerprint-save proxies in `tools/server.js`;
their handlers receive the a2loft URL from `a2loftUrl`/`a2loftSaveUrl`, which
currently append `phone`+`otp`, and `validateProxy` rejects a missing `otp`.
## Goals / Non-Goals
**Goals:** both wizard a2loft calls use the session token; no OTP in the
wizard; proxy validation keeps rejecting unauthenticated calls; deployable
after the a2loft change with a clean rollback.
**Non-Goals:** fpagent capture (already carries the token); the cropper; a2loft's
own OTP compatibility; the legacy singular fingerprint endpoint.
## Decisions
1. **Bearer from the server session**, never the browser: the proxy attaches
`Authorization: Bearer ${req.session.token}`, mirroring `tools/cropper.js`.
The page's token copy stays for fpagent only.
2. **Drop the OTP check in `validateProxy`**, along with the now-unnecessary
phone check; keep the hardcoded-domain and reference checks. `requireApi`
already guarantees a session.
3. **Remove the OTP inputs and state** rather than hiding them;
`canLookup`/`canSave` no longer require an OTP.
## Risks / Trade-offs
- [a2loft not yet dual-mode] → deploy the a2loft change first; this proxy change
depends on it.
- [Operators outside a domain's uploader list can now enroll] → intended;
a2loft authorizes `capture_biometric` with the `domain:idid` fallback.
- [Rollback] → revert the proxy and re-add the OTP fields; a2loft stays
dual-mode.
## Migration Plan
Deploy a2loft dual-mode → deploy this app → field pass: look up a reference and
save both fingerprints with no OTP; a bogus-token call fails cleanly.
## Open Questions
None.
@@ -0,0 +1,45 @@
# Proposal: fingerprint-token-auth
## Why
The Fingerprint Agent wizard's two a2loft calls — staff lookup and fingerprint
save — prove the operator with a phone number and an OTP forwarded through
same-origin proxies. a2loft resolves that phone against the domain being
addressed, so an operator who holds the OpenFGA `capture_biometric` relation
but is not a super-user of that domain's uploader list is rejected. The cropper
already saves with the session's PocketBase token; the wizard's a2loft calls
should use the same token flow.
## What Changes
- The `/api/:domain/staff/:reference` and
`/api/:domain/staff/:reference/fingerprints` proxies send the session's
PocketBase token as an `Authorization: Bearer` header instead of forwarding
`phone`+`otp` query parameters.
- The OTP validation is removed from the proxies; the session token is the
credential.
- The wizard's lookup and save steps drop their OTP fields and OTP state.
- The Fingerprint Agent and tools-app specs, the README, and the Help page are
updated.
- Requires the a2loft `fingerprint-token-auth` change deployed first.
## Capabilities
### New Capabilities
(none)
### Modified Capabilities
- `fingerprint-wizard`: the two-finger enrollment flow and the per-call OTP
requirement become token-authenticated.
- `tools-app`: the a2loft proxies requirement now injects the session token
instead of forwarding `phone`+`otp`.
## Impact
- `tools/server.js`: the two a2loft proxy handlers and `validateProxy`.
- `tools/public/assets/fp/index.html`, `tools/public/assets/fp/app.js`: the OTP
fields, state and request parameters removed.
- `tools/test/server.test.js`: proxy assertions.
- Documentation: README and the Help page.
@@ -0,0 +1,60 @@
# Spec Delta: fingerprint-wizard
## MODIFIED Requirements
### Requirement: Two-finger enrollment flow
The wizard SHALL enroll a profile by: selecting a permitted domain, looking
up staff by reference (token-validated through the a2loft proxy), displaying
the staff details, capturing the right thumb and right index finger, and
saving both fingerprints through the a2loft proxy authenticated with the
session token. The wizard SHALL offer per-finger recapture until save, SHALL
require both fingers before save, and SHALL reset domain-independent state
(reference, staff details, captured artifacts) when starting a new
enrollment.
#### Scenario: Complete enrollment
- **WHEN** the operator looks up staff, captures both fingers, and saves
- **THEN** both fingerprints are saved through the a2loft proxy and the
wizard reports success
#### Scenario: Save requires both fingers
- **WHEN** the operator attempts to save with only one finger captured
- **THEN** the wizard rejects the save and explains both fingers are
required
#### Scenario: New enrollment resets state
- **WHEN** the operator starts a new enrollment after a save
- **THEN** the reference, staff details, and captured finger artifacts are
cleared while the permitted domain list persists
## REMOVED Requirements
### Requirement: OTP validated on every a2loft call
**Reason**: a2loft's fingerprint staff-lookup and save endpoints now accept the
session's PocketBase token, so the operator proves identity with the session
instead of a per-call OTP. The OTP scoped operators to the domain's uploader
list and rejected capture agents who hold only `capture_biometric`.
**Migration**: the wizard sends the session token through the same-origin
proxy and collects no OTP; a2loft keeps OTP mode for other clients.
## ADDED Requirements
### Requirement: Token authenticated on every a2loft call
The wizard SHALL authenticate each a2loft call — staff lookup and fingerprint
save — with the signed-in operator's PocketBase session token, sent by the
same-origin proxy as an `Authorization: Bearer` credential. The wizard SHALL
NOT collect an OTP.
#### Scenario: Lookup uses the session token
- **WHEN** the operator submits a reference
- **THEN** the wizard calls the staff-lookup proxy and a2loft authenticates the
session token
#### Scenario: Save uses the session token
- **WHEN** the operator saves the captured fingerprints
- **THEN** the wizard calls the fingerprint-save proxy and a2loft authenticates
the session token
#### Scenario: No OTP field
- **WHEN** the wizard page is served
- **THEN** it offers no OTP input for lookup or save
@@ -0,0 +1,26 @@
# Spec Delta: tools-app
## MODIFIED Requirements
### Requirement: a2loft proxies inject operator identity
The app SHALL proxy staff lookup and fingerprint-save calls to
`https://apis.a2loft.net` under same-origin API routes. The proxy SHALL
authenticate the operator with the signed-in session's PocketBase token, sent
as an `Authorization: Bearer` credential, preserving the a2loft request and
response shapes. The proxies SHALL require a session; a request without one
SHALL be rejected without contacting a2loft. The proxies SHALL NOT forward or
require an OTP.
#### Scenario: Staff lookup via proxy
- **WHEN** the page requests staff details for a domain and reference
- **THEN** the app calls a2loft's identity endpoint with the session token as a
bearer credential and returns a2loft's response unchanged
#### Scenario: Fingerprint save via proxy
- **WHEN** the page saves fingerprints for a domain and reference
- **THEN** the app posts them to a2loft's fingerprint endpoint with the session
token as a bearer credential
#### Scenario: Unauthenticated proxy call rejected
- **WHEN** a proxy route is called without a valid session
- **THEN** the app rejects the call without contacting a2loft
@@ -0,0 +1,29 @@
# Tasks
## 1. Proxies
- [x] 1.1 `tools/server.js`: `a2loftUrl`/`a2loftSaveUrl` drop `phone`/`otp`;
both handlers send `Authorization: Bearer ${req.session.token}`;
`validateProxy` drops the OTP/phone checks and keeps domain + reference.
Verified: the proxy tests assert the Bearer header and no query auth.
- [x] 1.2 Update the proxy tests: assert no `?otp`, assert the Bearer header,
and drop the no-OTP rejection case while the auth/domain rejection remains
(no a2loft call). Verified.
## 2. Wizard UI
- [x] 2.1 `tools/public/assets/fp/index.html`: remove the lookup and save OTP
inputs.
- [x] 2.2 `tools/public/assets/fp/app.js`: remove `otpLookup`/`otpSave`, the
`otp` request params, and the OTP resets; tighten `canLookup`/`canSave`.
Verified: the served page has no OTP field (server test) and both gates pass
without a credential (wizard test).
- [x] 2.3 Update the `fingerprint-wizard` and `tools-app` spec deltas, README
steps 1/3, the a2loft proxy paragraph, and the Help page.
## 3. Verify
- [x] 3.1 Run the full tools test suite (`node --test`) and confirm it passes.
(66/66 pass.)
- [ ] 3.2 Field pass against a real domain (a2loft deployed): look up a
reference and save both fingerprints with no OTP.
+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();
}