Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions src/core/services/BiometricService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,66 @@ export class BiometricService {
}
}

/**
* Enroll a face by submitting ONLY its 512-d Facenet512 embedding (privacy-
* preserving counterpart of {@link enrollFace}).
*
* The raw face image NEVER leaves the device: the browser computes the
* authoritative Facenet512 vector locally (via `embedCapturedFace`) and posts
* just the vector, exactly mirroring how the FACE *verify* path submits
* `{ embedding }` instead of an image. This closes audit item H2 — FACE
* enrollment previously always uploaded raw images even when client-side
* embedding was enabled for verify.
*
* Wire contract (FROZEN, mirrors api `EnrollEmbeddingRequest`):
* POST /api/v1/biometric/enroll-embedding/{userId}
* Content-Type: application/json
* body: { embedding: number[512], tenant_id?: string }
* `tenant_id` (snake_case) is added ONLY when `tenantId` is a non-blank
* string (defense-in-depth — the backend already derives tenant from the
* authenticated principal, but explicit forwarding disambiguates admins
* linked to multiple tenants).
*
* The api gate is FAIL-CLOSED: it returns 403 when the tenant's
* `app.auth.client-side-embedding` flag is off (so the caller must flip the
* identity flag before the web flag), and 400 on a wrong-length vector. There
* is intentionally no `optimize`/template-fusion field on this route — a
* re-enroll replaces the stored template.
*
* @param userId The user whose face template is being (re)enrolled.
* @param embedding The L2-normalized 512-float Facenet512 vector.
* @param tenantId Optional tenant scope (forwarded as `tenant_id`).
*/
async enrollFaceEmbedding(
userId: string,
embedding: number[],
tenantId?: string,
): Promise<EnrollmentResult> {
try {
const body: { embedding: number[]; tenant_id?: string } = { embedding }
// Mirror appendTenantAndEmbeddings' blank check — only forward a real tenant.
if (tenantId && tenantId.trim().length > 0) {
body.tenant_id = tenantId
}

const response = await this.client.post(
`/biometric/enroll-embedding/${encodeURIComponent(userId)}`,
body,
{ headers: { 'Content-Type': 'application/json' } },
)

// Proxy returns BiometricVerificationResponse: {verified, confidence, message}.
return {
success: response.data.verified !== false,
userId,
confidence: typeof response.data.confidence === 'number' ? response.data.confidence : 1.0,
message: response.data.message ?? 'Face enrolled successfully',
}
} catch (error) {
throw this.mapEnrollmentError(error)
}
}

/**
* Enroll using multiple images with quality-weighted fusion.
*/
Expand Down
44 changes: 44 additions & 0 deletions src/core/services/__tests__/BiometricService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,50 @@ describe('BiometricService — multipart wire contract', () => {
})
})

describe('enrollFaceEmbedding', () => {
// The embedding enroll route posts a plain JSON object (NOT FormData), so
// read the body straight off the last post call's 2nd arg.
function lastJsonBody(): { embedding?: unknown; tenant_id?: unknown } {
expect(post).toHaveBeenCalled()
const call = post.mock.calls[post.mock.calls.length - 1]
return call[1] as { embedding?: unknown; tenant_id?: unknown }
}

const embedding = Array.from({ length: 512 }, (_, i) => (i % 5) / 5)

it('posts JSON { embedding } to /biometric/enroll-embedding/<userId> and includes tenant_id', async () => {
post.mockResolvedValue({ data: { verified: true, confidence: 0.97 } })

const tenantId = 'tenant-marmara-uuid'
await service.enrollFaceEmbedding('user-1', embedding, tenantId)

const call = post.mock.calls[post.mock.calls.length - 1]
expect(call[0]).toBe('/biometric/enroll-embedding/user-1')
const body = lastJsonBody()
expect(Array.isArray(body.embedding)).toBe(true)
expect((body.embedding as number[]).length).toBe(512)
expect(body.tenant_id).toBe(tenantId)
})

it('omits tenant_id when blank/whitespace-only', async () => {
post.mockResolvedValue({ data: { verified: true } })
await service.enrollFaceEmbedding('user-1', embedding, ' ')

const body = lastJsonBody()
expect(Array.isArray(body.embedding)).toBe(true)
expect('tenant_id' in body).toBe(false)
})

it('returns { success: true } on a { verified: true } response', async () => {
post.mockResolvedValue({ data: { verified: true, confidence: 0.91, message: 'ok' } })
const result = await service.enrollFaceEmbedding('user-1', embedding)

expect(result.success).toBe(true)
expect(result.userId).toBe('user-1')
expect(result.confidence).toBe(0.91)
})
})

describe('verifyFace', () => {
it('forwards tenant_id', async () => {
post.mockResolvedValue({
Expand Down
7 changes: 4 additions & 3 deletions src/features/auth/components/FaceEnrollmentFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@ import { motion, AnimatePresence } from 'framer-motion'
import { useFaceDetection } from '../hooks/useFaceDetection'
import { useFaceChallenge, ChallengeStage } from '../hooks/useFaceChallenge'
import FaceOvalGuide from './FaceOvalGuide'
import type { NormalizedLandmark } from '../../../lib/biometric-engine/types'

interface FaceEnrollmentFlowProps {
open: boolean
onClose: () => void
onComplete: (images: string[], clientEmbeddings?: (number[] | null)[]) => void
onComplete: (images: string[], clientEmbeddings?: (number[] | null)[], captureLandmarks?: (NormalizedLandmark[] | null)[]) => void
}

const STAGE_ICONS: Record<ChallengeStage, string> = {
Expand Down Expand Up @@ -113,7 +114,7 @@ export default function FaceEnrollmentFlow({ open, onClose, onComplete }: FaceEn
const loop = () => {
try {
const d = detectionRef.current
updateChallenge(d, d.cropFace, canvasRef)
updateChallenge(d, d.cropFace, canvasRef, d.captureLandmarks)
} catch {
// Silently ignore individual frame errors to keep the loop running
}
Expand Down Expand Up @@ -151,7 +152,7 @@ export default function FaceEnrollmentFlow({ open, onClose, onComplete }: FaceEn
setSubmitting(true)
// Don't close here — the parent (EnrollmentPage) will close the dialog
// after the biometric API call completes (success or failure)
onComplete(challengeState.captures, challengeState.clientEmbeddings)
onComplete(challengeState.captures, challengeState.clientEmbeddings, challengeState.captureLandmarks)
}

const handleRetry = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import { AuthMethodType } from '@domain/models/AuthMethod'
import { getBiometricService } from '@core/services/BiometricService'
import type { IHttpClient } from '@domain/interfaces/IHttpClient'
import { formatApiError } from '@utils/formatApiError'
import { isClientSideEmbeddingEnabled } from '@features/biometrics/embedding/clientEmbeddingFlag'
import { embedCapturedFace } from '@features/biometrics/embedding/embedCapturedFace'
import type { NormalizedLandmark } from '@/lib/biometric-engine/types'
import type { ShowSnackbar } from '../../types'

interface Props {
Expand Down Expand Up @@ -48,17 +51,41 @@ export default function FaceEnrollmentDialog({
const { t } = useTranslation()

const handleComplete = useCallback(
async (images: string[], clientEmbeddings?: (number[] | null)[]) => {
async (images: string[], clientEmbeddings?: (number[] | null)[], captureLandmarks?: (NormalizedLandmark[] | null)[]) => {
if (!userId || images.length === 0) return
setActionLoading(AuthMethodType.FACE)
try {
const biometric = getBiometricService()
// Send all captured images — enrollFace will use /enroll/multi
// for 2+ images (quality-weighted template fusion).
// clientEmbeddings are 512-dim landmark-geometry vectors computed in-browser
// via EmbeddingComputer (MediaPipe, log-only per D2). Server stores them for
// offline analysis only — never used for auth decisions.
await biometric.enrollFace(userId, images, tenantId, clientEmbeddings, optimize)
if (isClientSideEmbeddingEnabled()) {
// Client-side-embedding ON: compute the Facenet512 embedding for the best
// frontal capture (index 0 = the centered 'position' frame) entirely in the
// browser and submit ONLY the vector — the raw image never leaves the device.
// The captured 478-pt mesh is threaded so the embedder ALIGNS the face (eyes →
// canonical) first, the same as the verify probe → enroll and verify share one
// aligned space (audit H2). GPU-LESS ENFORCEMENT: the CPU-only server 400s the
// image path when its flag is on, so a null embedding must NOT fall back to an
// image upload — surface a retryable error instead (mirrors MfaStepRenderer).
// NOTE: optimize/template-fusion is not yet supported on the embedding enroll
// route; a re-enroll replaces the template.
const frontalImage = images[0]
const frontalLandmarks = captureLandmarks?.[0] ?? undefined
const embedding = await embedCapturedFace(frontalImage, frontalLandmarks ?? undefined)
if (!embedding) {
// On-device prep failed — retryable, never upload the image while the flag is on.
onClose()
showSnackbar(t('enrollmentPage.faceClientPrepFailed'), 'error')
return
}
await biometric.enrollFaceEmbedding(userId, embedding, tenantId)
} else {
// Flag OFF: legacy image upload, byte-identical to before.
// Send all captured images — enrollFace will use /enroll/multi
// for 2+ images (quality-weighted template fusion).
// clientEmbeddings are 512-dim landmark-geometry vectors computed in-browser
// via EmbeddingComputer (MediaPipe, log-only per D2). Server stores them for
// offline analysis only — never used for auth decisions.
await biometric.enrollFace(userId, images, tenantId, clientEmbeddings, optimize)
}

// Create enrollment record and explicitly complete it (FACE is ASYNC_ENROLLMENT_TYPE)
await createEnrollment({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/**
* FaceEnrollmentDialog — enroll submit path: client-side embedding vs legacy image.
*
* Audit item H2: extend the client-side-embedding FACE path to ENROLLMENT. Behind
* the `VITE_CLIENT_SIDE_EMBEDDING` flag (mirrors the server flag
* `app.auth.client-side-embedding`), the enroll dialog computes the Facenet512
* embedding for the best frontal capture (index 0) in the browser and submits ONLY
* the vector via `enrollFaceEmbedding` — the raw image never leaves the device.
*
* Contract verified here (deterministic — embedder + flag + service are mocked):
* - Flag OFF → enrollFace(userId, images, tenantId, clientEmbeddings, optimize),
* never enrollFaceEmbedding, never embedCapturedFace (byte-identical legacy).
* - Flag ON + embedding returns a 512-array → embedCapturedFace(firstImage,
* firstLandmarks) then enrollFaceEmbedding(userId, embedding, tenantId), never
* enrollFace.
* - Flag ON + embedding null → showSnackbar(prep-failed), NEVER enrollFaceEmbedding,
* NEVER enrollFace (no image fallback — GPU-less enforcement), NO onEnrolled.
*
* FaceEnrollmentFlow is mocked to a single button that hands a captured image +
* landmarks up to onComplete, so this suite asserts ONLY the dialog's submit routing.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import '../../../../../../../i18n'

// Inert biometric engine (transitive imports may pull it at module load).
vi.mock('@/lib/biometric-engine/core/BiometricEngine', () => ({
BiometricEngine: {
getInstance: () => ({
initialize: () => Promise.resolve(),
}),
},
}))

// The captured frame + 478-pt mesh the (mocked) FaceEnrollmentFlow hands up.
const CAPTURED_IMAGE = 'data:image/jpeg;base64,AAAA'
const CAPTURED_LANDMARKS = [
{ x: 0.38, y: 0.42, z: 0 },
{ x: 0.62, y: 0.42, z: 0 },
{ x: 0.5, y: 0.6, z: 0 },
]

// Mock FaceEnrollmentFlow down to a single button so we drive ONLY the dialog's
// onComplete routing — no camera / detection in jsdom. It hands up the image, a
// per-capture clientEmbeddings array, and the per-capture landmarks (mirrors the
// real flow's 3-arg onComplete).
vi.mock('../../../../FaceEnrollmentFlow', () => ({
__esModule: true,
default: ({
onComplete,
}: {
onComplete: (
images: string[],
clientEmbeddings?: (number[] | null)[],
captureLandmarks?: (unknown[] | null)[],
) => void
}) => (
<button
type="button"
onClick={() => onComplete([CAPTURED_IMAGE], [null], [CAPTURED_LANDMARKS])}
>
enroll-complete
</button>
),
}))

// Injectable flag + embed function so the test is deterministic (no ONNX).
const flagState = { enabled: false }
const fakeEmbedding = Array.from({ length: 512 }, (_, i) => (i % 7) / 7)
const embedCapturedFaceMock = vi.fn(async () => fakeEmbedding as number[] | null)

vi.mock('@features/biometrics/embedding/clientEmbeddingFlag', () => ({
isClientSideEmbeddingEnabled: () => flagState.enabled,
}))
vi.mock('@features/biometrics/embedding/embedCapturedFace', () => ({
embedCapturedFace: (...args: unknown[]) => embedCapturedFaceMock(...(args as [])),
}))

// Mock the biometric service so we assert which enroll method is called.
const enrollFace = vi.fn().mockResolvedValue({ success: true })
const enrollFaceEmbedding = vi.fn().mockResolvedValue({ success: true })
vi.mock('@core/services/BiometricService', () => ({
getBiometricService: () => ({ enrollFace, enrollFaceEmbedding }),
}))

// Mock the DI container — the dialog resolves an httpClient to PUT the
// enrollment-complete call after a successful enroll.
const httpPut = vi.fn().mockResolvedValue({})
vi.mock('@core/di/container', () => ({
container: { get: () => ({ put: httpPut }) },
}))

import FaceEnrollmentDialog from '../FaceEnrollmentDialog'

const USER_ID = 'user-1'
const TENANT_ID = 'tenant-marmara-uuid'

function renderDialog() {
const onClose = vi.fn()
const onEnrolled = vi.fn()
const showSnackbar = vi.fn()
const setActionLoading = vi.fn()
const createEnrollment = vi.fn().mockResolvedValue({})
render(
<FaceEnrollmentDialog
open
userId={USER_ID}
tenantId={TENANT_ID}
onClose={onClose}
onEnrolled={onEnrolled}
showSnackbar={showSnackbar}
setActionLoading={setActionLoading}
createEnrollment={createEnrollment}
/>,
)
return { onClose, onEnrolled, showSnackbar, setActionLoading, createEnrollment }
}

describe('FaceEnrollmentDialog — enroll submit: client embedding vs legacy image', () => {
beforeEach(() => {
flagState.enabled = false
embedCapturedFaceMock.mockClear()
embedCapturedFaceMock.mockResolvedValue(fakeEmbedding)
enrollFace.mockClear()
enrollFaceEmbedding.mockClear()
httpPut.mockClear()
})

it('flag OFF (default): calls enrollFace with images, never enrollFaceEmbedding/embedCapturedFace', async () => {
flagState.enabled = false
const { onEnrolled } = renderDialog()

await userEvent.click(screen.getByRole('button', { name: 'enroll-complete' }))

await waitFor(() => expect(enrollFace).toHaveBeenCalledTimes(1))
expect(enrollFace).toHaveBeenCalledWith(
USER_ID,
[CAPTURED_IMAGE],
TENANT_ID,
[null],
false,
)
expect(enrollFaceEmbedding).not.toHaveBeenCalled()
expect(embedCapturedFaceMock).not.toHaveBeenCalled()
await waitFor(() => expect(onEnrolled).toHaveBeenCalledTimes(1))
})

it('flag ON + embedding returns 512-array: embeds first image+landmarks then enrollFaceEmbedding, never enrollFace', async () => {
flagState.enabled = true
const { onEnrolled } = renderDialog()

await userEvent.click(screen.getByRole('button', { name: 'enroll-complete' }))

await waitFor(() => expect(enrollFaceEmbedding).toHaveBeenCalledTimes(1))
expect(embedCapturedFaceMock).toHaveBeenCalledWith(CAPTURED_IMAGE, CAPTURED_LANDMARKS)
expect(enrollFaceEmbedding).toHaveBeenCalledWith(USER_ID, fakeEmbedding, TENANT_ID)
expect(enrollFace).not.toHaveBeenCalled()
await waitFor(() => expect(onEnrolled).toHaveBeenCalledTimes(1))
})

it('flag ON + embedding null: shows prep-failed snackbar, no enroll call, no onEnrolled (no image fallback)', async () => {
flagState.enabled = true
embedCapturedFaceMock.mockResolvedValue(null)
const { onEnrolled, showSnackbar } = renderDialog()

await userEvent.click(screen.getByRole('button', { name: 'enroll-complete' }))

await waitFor(() => expect(showSnackbar).toHaveBeenCalledTimes(1))
expect(showSnackbar).toHaveBeenCalledWith(
'Couldn\'t prepare on-device face enrollment. Please check your connection and try again.',
'error',
)
expect(enrollFaceEmbedding).not.toHaveBeenCalled()
expect(enrollFace).not.toHaveBeenCalled()
expect(onEnrolled).not.toHaveBeenCalled()
})
})
Loading
Loading