diff --git a/README.md b/README.md index 8d0c604..fa94fe9 100644 --- a/README.md +++ b/README.md @@ -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 "" -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 diff --git a/openspec/changes/fingerprint-token-auth/.openspec.yaml b/openspec/changes/fingerprint-token-auth/.openspec.yaml new file mode 100644 index 0000000..ad94bad --- /dev/null +++ b/openspec/changes/fingerprint-token-auth/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-02 diff --git a/openspec/changes/fingerprint-token-auth/design.md b/openspec/changes/fingerprint-token-auth/design.md new file mode 100644 index 0000000..3691a48 --- /dev/null +++ b/openspec/changes/fingerprint-token-auth/design.md @@ -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. diff --git a/openspec/changes/fingerprint-token-auth/proposal.md b/openspec/changes/fingerprint-token-auth/proposal.md new file mode 100644 index 0000000..49530af --- /dev/null +++ b/openspec/changes/fingerprint-token-auth/proposal.md @@ -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. diff --git a/openspec/changes/fingerprint-token-auth/specs/fingerprint-wizard/spec.md b/openspec/changes/fingerprint-token-auth/specs/fingerprint-wizard/spec.md new file mode 100644 index 0000000..c5c0f30 --- /dev/null +++ b/openspec/changes/fingerprint-token-auth/specs/fingerprint-wizard/spec.md @@ -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 diff --git a/openspec/changes/fingerprint-token-auth/specs/tools-app/spec.md b/openspec/changes/fingerprint-token-auth/specs/tools-app/spec.md new file mode 100644 index 0000000..0baff61 --- /dev/null +++ b/openspec/changes/fingerprint-token-auth/specs/tools-app/spec.md @@ -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 diff --git a/openspec/changes/fingerprint-token-auth/tasks.md b/openspec/changes/fingerprint-token-auth/tasks.md new file mode 100644 index 0000000..2d093e6 --- /dev/null +++ b/openspec/changes/fingerprint-token-auth/tasks.md @@ -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. diff --git a/tools/public/assets/fp/app.js b/tools/public/assets/fp/app.js index d880139..775e649 100644 --- a/tools/public/assets/fp/app.js +++ b/tools/public/assets/fp/app.js @@ -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; diff --git a/tools/public/assets/fp/index.html b/tools/public/assets/fp/index.html index 703de0f..9f15da8 100644 --- a/tools/public/assets/fp/index.html +++ b/tools/public/assets/fp/index.html @@ -54,10 +54,6 @@ -
- - -
Get details @@ -136,10 +132,6 @@ Both fingerprints will be saved for {{ staff ? staff.name : '' }} ({{ reference }})
-
- - -
Save enrollment diff --git a/tools/public/assets/help.html b/tools/public/assets/help.html index 4c029b1..d9bd229 100644 --- a/tools/public/assets/help.html +++ b/tools/public/assets/help.html @@ -48,7 +48,7 @@

Enroll staff fingerprints with the scanner attached to this laptop. Captures are authorized per domain.

  1. Open Fingerprint Agent from the landing page or the menu.
  2. -
  3. Choose the domain, enter the staff reference and the OTP, then look up the profile.
  4. +
  5. Choose the domain, enter the staff reference, then look up the profile.
  6. Confirm the profile is the right person.
  7. Follow the on-screen prompts and place each finger on the attached scanner when asked.
  8. Review the captured prints and save.
  9. diff --git a/tools/server.js b/tools/server.js index 294fa02..e00aa6e 100644 --- a/tools/server.js +++ b/tools/server.js @@ -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 { diff --git a/tools/test/fp-wizard.test.js b/tools/test/fp-wizard.test.js index 69f0b39..c0948ef 100644 --- a/tools/test/fp-wizard.test.js +++ b/tools/test/fp-wizard.test.js @@ -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); diff --git a/tools/test/server.test.js b/tools/test/server.test.js index 6077e52..98beb9b 100644 --- a/tools/test/server.test.js +++ b/tools/test/server.test.js @@ -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(); }