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:
@@ -107,11 +107,10 @@ is treated as anonymous and the stale cookie cleared.
|
|||||||
### a2loft proxy
|
### a2loft proxy
|
||||||
|
|
||||||
Staff lookup and the fingerprint save are proxied to `https://apis.a2loft.net`
|
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
|
under same-origin routes. The proxy authenticates the operator with the
|
||||||
server-side session and forwards the OTP supplied by the page; the operator
|
signed-in session's PocketBase token, sent to a2loft as a `Bearer` credential;
|
||||||
never sends credentials to a third party directly. The cropper's save call
|
the operator never sends credentials to a third party directly. The cropper's
|
||||||
instead authenticates with the session token (`Bearer`) after a live permission
|
save call uses the same token flow after a live permission check.
|
||||||
check.
|
|
||||||
|
|
||||||
## The Image Cropper
|
## 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:
|
The `/fingerprint` wizard walks the operator through:
|
||||||
|
|
||||||
1. **Locate profile** — pick a permitted domain, enter the staff reference and
|
1. **Locate profile** — pick a permitted domain, enter the staff reference, and
|
||||||
OTP, and look up the record through the a2loft proxy.
|
look up the record through the a2loft proxy.
|
||||||
2. **Capture** — right thumb and right index finger, one capture at a time, with
|
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
|
per-scan progress and cancel. The PocketBase token, domain and reference
|
||||||
travel with every `fprint-enroll` to fpagent.
|
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
|
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
|
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.
|
||||||
@@ -25,8 +25,6 @@ Vue.config.ignoredElements = [/^cds-/];
|
|||||||
agent: { connected: false, version: '', captureAgentId: '', available: null, device: '' },
|
agent: { connected: false, version: '', captureAgentId: '', available: null, device: '' },
|
||||||
domain: '',
|
domain: '',
|
||||||
reference: '',
|
reference: '',
|
||||||
otpLookup: '',
|
|
||||||
otpSave: '',
|
|
||||||
staff: null,
|
staff: null,
|
||||||
busy: { lookup: false, save: false },
|
busy: { lookup: false, save: false },
|
||||||
refreshing: false,
|
refreshing: false,
|
||||||
@@ -49,10 +47,10 @@ Vue.config.ignoredElements = [/^cds-/];
|
|||||||
this.fingers['right-index-finger'].status === 'done';
|
this.fingers['right-index-finger'].status === 'done';
|
||||||
},
|
},
|
||||||
canLookup: function () {
|
canLookup: function () {
|
||||||
return !!(this.domain && this.reference && this.otpLookup) && !this.busy.lookup;
|
return !!(this.domain && this.reference) && !this.busy.lookup;
|
||||||
},
|
},
|
||||||
canSave: function () {
|
canSave: function () {
|
||||||
return this.bothCaptured && !!this.otpSave && !this.busy.save;
|
return this.bothCaptured && !this.busy.save;
|
||||||
},
|
},
|
||||||
refreshUnlocksAt: function () {
|
refreshUnlocksAt: function () {
|
||||||
var ttl = (this.session && this.session.domainCacheTtlMs) || 5 * 60 * 1000;
|
var ttl = (this.session && this.session.domainCacheTtlMs) || 5 * 60 * 1000;
|
||||||
@@ -296,9 +294,7 @@ Vue.config.ignoredElements = [/^cds-/];
|
|||||||
if (!this.canLookup) return;
|
if (!this.canLookup) return;
|
||||||
this.busy.lookup = true;
|
this.busy.lookup = true;
|
||||||
axios.post(
|
axios.post(
|
||||||
'/api/' + encodeURIComponent(this.domain) + '/staff/' + encodeURIComponent(this.reference),
|
'/api/' + encodeURIComponent(this.domain) + '/staff/' + encodeURIComponent(this.reference)
|
||||||
null,
|
|
||||||
{ params: { otp: this.otpLookup } }
|
|
||||||
).then(function (r) {
|
).then(function (r) {
|
||||||
if (!r.data.ok) {
|
if (!r.data.ok) {
|
||||||
self.showNotif('error', 'Not found', 'Operation failed. ' + (r.data.message || '') + ' (reference ' + self.reference + ')');
|
self.showNotif('error', 'Not found', 'Operation failed. ' + (r.data.message || '') + ' (reference ' + self.reference + ')');
|
||||||
@@ -322,8 +318,7 @@ Vue.config.ignoredElements = [/^cds-/];
|
|||||||
});
|
});
|
||||||
axios.post(
|
axios.post(
|
||||||
'/api/' + encodeURIComponent(this.domain) + '/staff/' + encodeURIComponent(this.reference) + '/fingerprints',
|
'/api/' + encodeURIComponent(this.domain) + '/staff/' + encodeURIComponent(this.reference) + '/fingerprints',
|
||||||
{ fingerprints: fingerprints },
|
{ fingerprints: fingerprints }
|
||||||
{ params: { otp: this.otpSave } }
|
|
||||||
).then(function (r) {
|
).then(function (r) {
|
||||||
if (r.data.ok) {
|
if (r.data.ok) {
|
||||||
self.showNotif('success', 'Enrollment saved',
|
self.showNotif('success', 'Enrollment saved',
|
||||||
@@ -343,8 +338,6 @@ Vue.config.ignoredElements = [/^cds-/];
|
|||||||
newEnrollment: function () {
|
newEnrollment: function () {
|
||||||
this.staff = null;
|
this.staff = null;
|
||||||
this.reference = '';
|
this.reference = '';
|
||||||
this.otpLookup = '';
|
|
||||||
this.otpSave = '';
|
|
||||||
this.fingers = { 'right-thumb': newFinger(), 'right-index-finger': newFinger() };
|
this.fingers = { 'right-thumb': newFinger(), 'right-index-finger': newFinger() };
|
||||||
this.capturing = null;
|
this.capturing = null;
|
||||||
this.cancelling = false;
|
this.cancelling = false;
|
||||||
|
|||||||
@@ -54,10 +54,6 @@
|
|||||||
<label class="cds--label" for="reference">Staff reference</label>
|
<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">
|
<input id="reference" class="cds--text-input" v-model.trim="reference" v-on:keyup.enter="lookup" placeholder="e.g. UC/0231">
|
||||||
</div>
|
</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">
|
<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>
|
<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>
|
<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>
|
<span class="hint">Both fingerprints will be saved for {{ staff ? staff.name : '' }} ({{ reference }})</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="row-gap" style="align-items:flex-end;">
|
<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">
|
<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>
|
<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>
|
<span v-if="!busy.save">Save enrollment</span>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
<p>Enroll staff fingerprints with the scanner attached to this laptop. Captures are authorized per domain.</p>
|
<p>Enroll staff fingerprints with the scanner attached to this laptop. Captures are authorized per domain.</p>
|
||||||
<ol class="help-steps">
|
<ol class="help-steps">
|
||||||
<li>Open <strong>Fingerprint Agent</strong> from the landing page or the menu.</li>
|
<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>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>Follow the on-screen prompts and place each finger on the attached scanner when asked.</li>
|
||||||
<li>Review the captured prints and save.</li>
|
<li>Review the captured prints and save.</li>
|
||||||
|
|||||||
+15
-24
@@ -704,19 +704,11 @@ export function createApp(overrides = {}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── a2loft proxies: same-origin, phone injected from the session ──────────
|
// ── a2loft proxies: same-origin, bearer token from the session ────────────
|
||||||
const a2loftUrl = (domain, reference, otp, phone) => {
|
const a2loftUrl = (domain, reference) =>
|
||||||
const q = new URLSearchParams({ phone, otp });
|
`${cfg.a2loftBase.replace(/\/+$/, '')}/identity/${encodeURIComponent(domain)}/${encodeURIComponent(reference)}`;
|
||||||
return `${cfg.a2loftBase.replace(/\/+$/, '')}/identity/${encodeURIComponent(domain)}/${encodeURIComponent(
|
const a2loftSaveUrl = (domain, reference) =>
|
||||||
reference
|
`${cfg.a2loftBase.replace(/\/+$/, '')}/identity/${encodeURIComponent(domain)}/fingerprints/${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()}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateProxy = (req, res) => {
|
const validateProxy = (req, res) => {
|
||||||
const { domain, reference } = req.params;
|
const { domain, reference } = req.params;
|
||||||
@@ -728,21 +720,15 @@ export function createApp(overrides = {}) {
|
|||||||
res.status(400).json({ ok: false, message: 'Missing staff reference.' });
|
res.status(400).json({ ok: false, message: 'Missing staff reference.' });
|
||||||
return false;
|
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;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
app.post('/api/:domain/staff/:reference', requireApi, async (req, res) => {
|
app.post('/api/:domain/staff/:reference', requireApi, async (req, res) => {
|
||||||
if (!validateProxy(req, res)) return;
|
if (!validateProxy(req, res)) return;
|
||||||
try {
|
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());
|
res.status(r.status).type('json').send(await r.text());
|
||||||
} catch {
|
} catch {
|
||||||
res.status(502).json({ ok: false, message: 'Could not reach the identity service.' });
|
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.' });
|
return res.status(400).json({ ok: false, message: 'A fingerprints array is required.' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const r = await cfg.fetch(a2loftSaveUrl(req.params.domain, req.params.reference, req.query.otp, req.session.phone), {
|
const r = await cfg.fetch(a2loftSaveUrl(req.params.domain, req.params.reference), {
|
||||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(req.body)
|
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());
|
res.status(r.status).type('json').send(await r.text());
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -129,6 +129,16 @@ test('capture is refused when no reader is attached', () => {
|
|||||||
assert.equal(vmInst.notif.show, true);
|
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', () => {
|
test('disconnect clears availability', () => {
|
||||||
const app = loadApp();
|
const app = loadApp();
|
||||||
const vmInst = makeVm(app);
|
const vmInst = makeVm(app);
|
||||||
|
|||||||
+13
-15
@@ -62,7 +62,7 @@ async function startFakeA2loft() {
|
|||||||
let raw = '';
|
let raw = '';
|
||||||
req.on('data', (c) => (raw += c));
|
req.on('data', (c) => (raw += c));
|
||||||
req.on('end', () => {
|
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 = {
|
const staff = {
|
||||||
ok: true,
|
ok: true,
|
||||||
data: {
|
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();
|
const { base, a2loft, stop } = await bootApp();
|
||||||
try {
|
try {
|
||||||
const cookies = await login(base);
|
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',
|
method: 'POST',
|
||||||
headers: { cookie: cookies },
|
headers: { cookie: cookies },
|
||||||
});
|
});
|
||||||
@@ -224,19 +224,19 @@ test('staff lookup proxy injects the session phone and forwards a2loft responses
|
|||||||
assert.equal(call.method, 'GET');
|
assert.equal(call.method, 'GET');
|
||||||
const u = new URL(call.url, 'http://x');
|
const u = new URL(call.url, 'http://x');
|
||||||
assert.equal(decodeURIComponent(u.pathname), '/identity/uc/UC/0231');
|
assert.equal(decodeURIComponent(u.pathname), '/identity/uc/UC/0231');
|
||||||
assert.equal(u.searchParams.get('phone'), '+2348068287222');
|
assert.equal(u.search, '', 'no phone/otp query parameters');
|
||||||
assert.equal(u.searchParams.get('otp'), '4455');
|
assert.match(call.headers.authorization, /^Bearer .+/, 'session token forwarded');
|
||||||
} finally {
|
} finally {
|
||||||
await stop();
|
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();
|
const { base, a2loft, stop } = await bootApp();
|
||||||
try {
|
try {
|
||||||
const cookies = await login(base);
|
const cookies = await login(base);
|
||||||
const payload = { fingerprints: [{ finger: 'right-thumb', fingerprint: 'QUJD' }] };
|
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',
|
method: 'POST',
|
||||||
headers: { cookie: cookies, 'content-type': 'application/json' },
|
headers: { cookie: cookies, 'content-type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
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' });
|
assert.deepEqual(await res.json(), { ok: true, message: 'saved' });
|
||||||
const call = a2loft.calls[0];
|
const call = a2loft.calls[0];
|
||||||
assert.equal(call.method, 'POST');
|
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.deepEqual(call.body, payload);
|
||||||
|
assert.match(call.headers.authorization, /^Bearer .+/, 'session token forwarded');
|
||||||
} finally {
|
} finally {
|
||||||
await stop();
|
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();
|
const { base, a2loft, stop } = await bootApp();
|
||||||
try {
|
try {
|
||||||
const cookies = await login(base);
|
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);
|
assert.equal(anon.status, 401);
|
||||||
const noOtp = await fetch(`${base}/api/uc/staff/REF`, {
|
const badDomain = await fetch(`${base}/api/nope/staff/REF`, {
|
||||||
method: 'POST', headers: { cookie: cookies },
|
|
||||||
});
|
|
||||||
assert.equal(noOtp.status, 400);
|
|
||||||
const badDomain = await fetch(`${base}/api/nope/staff/REF?otp=1`, {
|
|
||||||
method: 'POST', headers: { cookie: cookies },
|
method: 'POST', headers: { cookie: cookies },
|
||||||
});
|
});
|
||||||
assert.equal(badDomain.status, 400);
|
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, /window\.__SESSION__ = \{/);
|
||||||
assert.match(html, /2348068287222/);
|
assert.match(html, /2348068287222/);
|
||||||
assert.match(html, /"domains"/, 'permitted domains injected too');
|
assert.match(html, /"domains"/, 'permitted domains injected too');
|
||||||
|
assert.ok(!/otpLookup|otpSave/.test(html), 'the wizard has no OTP fields');
|
||||||
} finally {
|
} finally {
|
||||||
await stop();
|
await stop();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user