diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 16243d60..f3b3e1f4 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -303,9 +303,56 @@ export default function Sidebar({ ))} + {/* FIVUCSAS suite bar — cross-site nav to landing + sister tools */} + + + {t('sidebar.suite', 'FIVUCSAS suite')} + + + {t('sidebar.suiteHome', 'Home')} + {t('sidebar.suiteDemo', 'Demo')} + {t('sidebar.suiteWidget', 'Widget')} + amispoof + + + {/* Footer status tile */} { + it.each([ + [ChallengeType.BLINK, 'blink'], + [ChallengeType.SMILE, 'smile'], + [ChallengeType.TURN_LEFT, 'turn_left'], + [ChallengeType.TURN_RIGHT, 'turn_right'], + [ChallengeType.OPEN_MOUTH, 'open_mouth'], + [ChallengeType.RAISE_BOTH_BROWS, 'raise_eyebrows'], + ])('maps %s → %s', (input, expected) => { + expect(faceChallengeToServerAction(input)).toBe(expected) + }) + + it.each([ + ChallengeType.CLOSE_LEFT, + ChallengeType.CLOSE_RIGHT, + ChallengeType.LOOK_UP, + ChallengeType.LOOK_DOWN, + ChallengeType.RAISE_LEFT_BROW, + ChallengeType.RAISE_RIGHT_BROW, + ChallengeType.NOD, + ChallengeType.SHAKE_HEAD, + ])('returns null for unmapped variant %s', (input) => { + expect(faceChallengeToServerAction(input)).toBeNull() + }) +}) + +describe('handPuzzleToServerAction', () => { + it.each([ + [BiometricPuzzleId.HAND_FINGER_COUNT, 'finger_count'], + [BiometricPuzzleId.HAND_WAVE, 'wave'], + [BiometricPuzzleId.HAND_FLIP, 'hand_flip'], + [BiometricPuzzleId.HAND_FINGER_TAP, 'finger_tap'], + [BiometricPuzzleId.HAND_PINCH, 'pinch'], + [BiometricPuzzleId.HAND_PEEK_A_BOO, 'peek_a_boo'], + [BiometricPuzzleId.HAND_SHAPE_TRACE, 'shape_trace'], + [BiometricPuzzleId.HAND_MATH, 'math'], + ])('maps %s → %s', (input, expected) => { + expect(handPuzzleToServerAction(input)).toBe(expected) + }) + + it('returns null for HAND_TRACE_TEMPLATE (client-only variant)', () => { + expect( + handPuzzleToServerAction(BiometricPuzzleId.HAND_TRACE_TEMPLATE), + ).toBeNull() + }) + + it('returns null for face puzzles passed by accident', () => { + // Caller bug guard: a face-modality id must not silently round-trip + // a hand action — the mapper is one-modality-per-function. + expect( + handPuzzleToServerAction(BiometricPuzzleId.FACE_BLINK as unknown as BiometricPuzzleId), + ).toBeNull() + }) +}) diff --git a/src/features/biometric-puzzles/puzzleServerAction.ts b/src/features/biometric-puzzles/puzzleServerAction.ts new file mode 100644 index 00000000..daaa2f0b --- /dev/null +++ b/src/features/biometric-puzzles/puzzleServerAction.ts @@ -0,0 +1,59 @@ +/** + * Mapping from the web client's local enums to the biometric-processor + * ``ChallengeType`` string-values accepted by ``/liveness/verify-challenge``. + * + * The web side uses two distinct enums: + * - ``ChallengeType`` in ``biometric-engine/types`` for FACE puzzles + * (UPPERCASE, e.g. ``BLINK``, ``TURN_LEFT``). + * - ``BiometricPuzzleId`` for the public training registry (FACE_* and + * HAND_* prefixed identifiers). + * + * The server enum is a flat lower_snake_case set in + * ``biometric-processor/app/api/schemas/active_liveness.py``. Some local + * variants (CLOSE_LEFT, CLOSE_RIGHT, LOOK_UP, LOOK_DOWN, RAISE_LEFT_BROW, + * RAISE_RIGHT_BROW, NOD, SHAKE_HEAD, HAND_TRACE_TEMPLATE) don't have a + * 1:1 server counterpart — those rows return ``null`` here, which the + * caller hook treats as "skip server validation". + */ +import { ChallengeType } from '@/lib/biometric-engine/types' +import { BiometricPuzzleId } from './BiometricPuzzleId' +import type { PuzzleServerAction } from './useBiometricPuzzleServer' + +/** Face ChallengeType → server ChallengeType action string. */ +const FACE_CHALLENGE_TO_SERVER: Partial> = { + [ChallengeType.BLINK]: 'blink', + [ChallengeType.SMILE]: 'smile', + [ChallengeType.TURN_LEFT]: 'turn_left', + [ChallengeType.TURN_RIGHT]: 'turn_right', + [ChallengeType.OPEN_MOUTH]: 'open_mouth', + [ChallengeType.RAISE_BOTH_BROWS]: 'raise_eyebrows', + // CLOSE_LEFT, CLOSE_RIGHT, LOOK_UP, LOOK_DOWN, RAISE_LEFT_BROW, + // RAISE_RIGHT_BROW, NOD, SHAKE_HEAD: no 1:1 server enum — skip. +} + +/** Hand BiometricPuzzleId → server ChallengeType action string. */ +const HAND_PUZZLE_TO_SERVER: Partial> = { + [BiometricPuzzleId.HAND_FINGER_COUNT]: 'finger_count', + [BiometricPuzzleId.HAND_WAVE]: 'wave', + [BiometricPuzzleId.HAND_FLIP]: 'hand_flip', + [BiometricPuzzleId.HAND_FINGER_TAP]: 'finger_tap', + [BiometricPuzzleId.HAND_PINCH]: 'pinch', + [BiometricPuzzleId.HAND_PEEK_A_BOO]: 'peek_a_boo', + [BiometricPuzzleId.HAND_SHAPE_TRACE]: 'shape_trace', + [BiometricPuzzleId.HAND_MATH]: 'math', + // HAND_TRACE_TEMPLATE: client-only variant of SHAPE_TRACE — skip. +} + +/** Resolve a face ChallengeType to its server action, or null if unmapped. */ +export function faceChallengeToServerAction( + ct: ChallengeType, +): PuzzleServerAction | null { + return FACE_CHALLENGE_TO_SERVER[ct] ?? null +} + +/** Resolve a hand BiometricPuzzleId to its server action, or null if unmapped. */ +export function handPuzzleToServerAction( + id: BiometricPuzzleId, +): PuzzleServerAction | null { + return HAND_PUZZLE_TO_SERVER[id] ?? null +} diff --git a/src/features/biometric-puzzles/puzzles/FacePuzzle.tsx b/src/features/biometric-puzzles/puzzles/FacePuzzle.tsx index ddc17561..e55848e0 100644 --- a/src/features/biometric-puzzles/puzzles/FacePuzzle.tsx +++ b/src/features/biometric-puzzles/puzzles/FacePuzzle.tsx @@ -41,6 +41,8 @@ import { } from '@/lib/biometric-engine/core/BiometricPuzzle' import { ChallengeType } from '@/lib/biometric-engine/types' import type { BiometricPuzzleProps } from '../biometricPuzzleRegistry' +import { useBiometricPuzzleServer } from '../useBiometricPuzzleServer' +import { faceChallengeToServerAction } from '../puzzleServerAction' // Lazily import DrawingUtils + FaceLandmarker statics from MediaPipe so the // puzzle bundle can render the 468-point mesh + named contours over the @@ -86,12 +88,19 @@ function FacePuzzle({ onSuccess, onError, challengeType, i18nKey }: Props) { const { engine, isReady, isLoading, error: engineError } = useBiometricEngine() const puzzleEngineRef = useRef(null) + // Bug 4 (2026-05-12) — server validation client. We post the completion to + // /biometric/puzzles/verify-challenge before resolving onSuccess. See + // useBiometricPuzzleServer.ts for the graceful-degradation behavior when + // the proxy endpoint isn't deployed yet. + const { verifyChallenge } = useBiometricPuzzleServer() + const [cameraActive, setCameraActive] = useState(false) const [videoReady, setVideoReady] = useState(false) const [cameraError, setCameraError] = useState(null) const [progress, setProgress] = useState(0) const [detected, setDetected] = useState(false) const [running, setRunning] = useState(false) + const [serverVerifying, setServerVerifying] = useState(false) // Build the per-challenge puzzle instance once the engine is ready. useEffect(() => { @@ -299,7 +308,48 @@ function FacePuzzle({ onSuccess, onError, challengeType, i18nKey }: Props) { completedRef.current = true setRunning(false) setProgress(100) - onSuccess() + + // Bug 4 (2026-05-12) — round-trip the completion through + // the server before resolving onSuccess. The mapper + // returns null for face variants without a 1:1 server + // enum (CLOSE_LEFT/RIGHT, LOOK_UP/DOWN, individual + // brow raises, NOD, SHAKE_HEAD) — in that case the + // local verdict is final because the server can't + // express the same challenge today. + const serverAction = faceChallengeToServerAction(challengeType) + if (!serverAction) { + onSuccess() + return + } + const endTs = performance.now() + setServerVerifying(true) + verifyChallenge( + { + action: serverAction, + startTimestampMs: startTsRef.current, + endTimestampMs: endTs, + confidence: result.detected ? 0.9 : 0.5, + metrics: { progress: result.progress }, + }, + t, + ) + .then((outcome) => { + setServerVerifying(false) + if (outcome.kind === 'success' || outcome.kind === 'soft_pass') { + onSuccess() + } else { + onError(outcome.message) + } + }) + .catch(() => { + setServerVerifying(false) + onError( + t('biometricPuzzle.serverError', { + defaultValue: + 'Server validation failed. Please try again.', + }), + ) + }) return } } else { @@ -319,7 +369,7 @@ function FacePuzzle({ onSuccess, onError, challengeType, i18nKey }: Props) { animFrameRef.current = 0 } } - }, [engine, isReady, cameraActive, videoReady, challengeType, onSuccess, onError, t, drawFaceLandmarks, clearOverlay, detected]) + }, [engine, isReady, cameraActive, videoReady, challengeType, onSuccess, onError, t, drawFaceLandmarks, clearOverlay, detected, verifyChallenge]) const hint = t(`${i18nKey}.hint`, { defaultValue: '' }) @@ -468,6 +518,18 @@ function FacePuzzle({ onSuccess, onError, challengeType, i18nKey }: Props) { )} + {serverVerifying && ( + + {t('biometricPuzzle.verifyingServer', { + defaultValue: 'Verifying with server…', + })} + + )} + {running && ( (null) const [promptCode, setPromptCode] = useState(null) + const [serverVerifying, setServerVerifying] = useState(false) const handLandmarker = useHandLandmarker(cameraActive) + // Bug 4 (2026-05-12) — server validation client. See useBiometricPuzzleServer.ts. + const { verifyChallenge } = useBiometricPuzzleServer() + const startCamera = useCallback(async () => { try { setCameraError(null) @@ -274,7 +280,46 @@ function HandGesturePuzzle({ onSuccess, onError, puzzleId, i18nKey }: Props) { completedRef.current = true setRunning(false) setProgress(100) - onSuccess() + + // Bug 4 (2026-05-12) — round-trip the completion through the + // server before resolving onSuccess. The mapper returns null + // for hand variants without a 1:1 server enum (currently + // HAND_TRACE_TEMPLATE), in which case the local verdict is + // final because the server can't express the same challenge. + const serverAction = handPuzzleToServerAction(puzzleId) + if (!serverAction) { + onSuccess() + return + } + const endTs = performance.now() + setServerVerifying(true) + verifyChallenge( + { + action: serverAction, + startTimestampMs: startTsRef.current, + endTimestampMs: endTs, + confidence: 0.9, + metrics: { progress: evalResult.progress ?? 0 }, + }, + t, + ) + .then((outcome) => { + setServerVerifying(false) + if (outcome.kind === 'success' || outcome.kind === 'soft_pass') { + onSuccess() + } else { + onError(outcome.message) + } + }) + .catch(() => { + setServerVerifying(false) + onError( + t('biometricPuzzle.serverError', { + defaultValue: + 'Server validation failed. Please try again.', + }), + ) + }) return } @@ -288,7 +333,7 @@ function HandGesturePuzzle({ onSuccess, onError, puzzleId, i18nKey }: Props) { animFrameRef.current = 0 } } - }, [handLandmarker, cameraActive, videoReady, puzzleId, onSuccess, onError, t, drawHandLandmarks, clearOverlay]) + }, [handLandmarker, cameraActive, videoReady, puzzleId, onSuccess, onError, t, drawHandLandmarks, clearOverlay, verifyChallenge]) const hint = t(`${i18nKey}.hint`, { defaultValue: '' }) @@ -502,6 +547,18 @@ function HandGesturePuzzle({ onSuccess, onError, puzzleId, i18nKey }: Props) { )} + {serverVerifying && ( + + {t('biometricPuzzle.verifyingServer', { + defaultValue: 'Verifying with server…', + })} + + )} + {running && ( +} + +export interface PuzzleVerifyResult { + kind: 'success' + durationSeconds: number +} +export interface PuzzleVerifyError { + kind: 'error' + message: string + /** Server reason code (e.g. DURATION_TOO_SHORT) when available. */ + reasonCode?: string +} +export interface PuzzleVerifySoftPass { + /** + * Server endpoint isn't deployed yet (404). The hook treats this as a + * soft-pass so the training UI continues to work during rollout. + */ + kind: 'soft_pass' + reason: 'endpoint_not_deployed' +} + +export type PuzzleVerifyOutcome = + | PuzzleVerifyResult + | PuzzleVerifyError + | PuzzleVerifySoftPass + +/** identity-core-api proxy path for the bio `/liveness/verify-challenge` route. */ +const VERIFY_CHALLENGE_PATH = '/biometric/puzzles/verify-challenge' + +interface ServerResponse { + verified?: boolean + action?: string + duration_seconds?: number + reason_code?: string | null + message?: string +} + +export function useBiometricPuzzleServer() { + const httpClient = useService(TYPES.HttpClient) + /** Throttle 404 warnings — log once per session, not per puzzle attempt. */ + const not_deployed_warned_ref = useRef(false) + + const verifyChallenge = useCallback( + async ( + payload: PuzzleVerifyRequestPayload, + t: TFunction, + ): Promise => { + const body = { + action: payload.action, + start_timestamp_ms: payload.startTimestampMs, + end_timestamp_ms: payload.endTimestampMs, + confidence: payload.confidence, + tenant_id: payload.tenantId, + user_id: payload.userId, + metrics: payload.metrics ?? {}, + } + + try { + const res = await httpClient.post( + VERIFY_CHALLENGE_PATH, + body, + ) + if (res.data?.verified === true) { + return { + kind: 'success', + durationSeconds: res.data.duration_seconds ?? 0, + } + } + return { + kind: 'error', + message: + res.data?.message || + t('biometricPuzzle.serverRejected', { + defaultValue: 'Server rejected the challenge.', + }), + reasonCode: res.data?.reason_code ?? undefined, + } + } catch (err) { + // Endpoint not deployed yet (404) → soft pass, log once. + const status = (err as { response?: { status?: number } }) + ?.response?.status + if (status === 404) { + if (!not_deployed_warned_ref.current) { + not_deployed_warned_ref.current = true + console.warn( + '[biometric-puzzles] /biometric/puzzles/verify-challenge ' + + 'proxy not deployed yet — running in soft-pass mode. ' + + 'Operator: merge bio fix/2026-05-12-liveness-and-puzzles ' + + 'and the matching identity-core-api proxy to enable ' + + 'server validation.', + ) + } + return { kind: 'soft_pass', reason: 'endpoint_not_deployed' } + } + return { + kind: 'error', + message: formatApiError(err, t), + } + } + }, + [httpClient], + ) + + return useMemo(() => ({ verifyChallenge }), [verifyChallenge]) +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 9a274d7f..cabe650e 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1696,7 +1696,11 @@ "verify": "Verify" }, "sidebar": { - "systemStatus": "All systems operational" + "systemStatus": "All systems operational", + "suite": "FIVUCSAS suite", + "suiteHome": "Home", + "suiteDemo": "Demo", + "suiteWidget": "Widget" }, "authMethodsTesting": { "pageTitle": "Auth Methods Testing", @@ -1808,6 +1812,9 @@ "promptLabel": "Your prompt", "handFingerCountPrompt": "Show {{count}} finger(s) and hold.", "handMathPrompt": "Show the answer to {{expr}} on your fingers and hold.", + "verifyingServer": "Verifying with server…", + "serverRejected": "Server rejected the challenge.", + "serverError": "Server validation failed. Please try again.", "difficulty": { "beginner": "Beginner", "intermediate": "Intermediate", diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index c32f7eef..055a28dc 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -1696,7 +1696,11 @@ "verify": "Doğrula" }, "sidebar": { - "systemStatus": "Tüm sistemler çalışıyor" + "systemStatus": "Tüm sistemler çalışıyor", + "suite": "FIVUCSAS paketi", + "suiteHome": "Anasayfa", + "suiteDemo": "Demo", + "suiteWidget": "Widget" }, "authMethodsTesting": { "pageTitle": "Kimlik Doğrulama Yöntemi Denemeleri", @@ -1808,6 +1812,9 @@ "promptLabel": "Görev", "handFingerCountPrompt": "{{count}} parmağınızı gösterip tutun.", "handMathPrompt": "{{expr}} işleminin sonucunu parmaklarınızla gösterip tutun.", + "verifyingServer": "Sunucu ile doğrulanıyor…", + "serverRejected": "Sunucu doğrulamayı reddetti.", + "serverError": "Sunucu doğrulaması başarısız oldu. Lütfen tekrar deneyin.", "difficulty": { "beginner": "Başlangıç", "intermediate": "Orta", diff --git a/src/verify-app/index.html b/src/verify-app/index.html index 0c723290..34e44a0e 100644 --- a/src/verify-app/index.html +++ b/src/verify-app/index.html @@ -3,7 +3,22 @@ - + + + + + + + + + + + + + + + + - FIVUCSAS Verify + FIVUCSAS Verify — hosted login + embeddable auth widget + + +
+
+
+
+
F
+
FIVUCSAS Verify
+
+ +

Hosted login and embeddable auth widget for FIVUCSAS.

+

+ Authenticate your users with face, voice, fingerprint, NFC ID, passkeys, TOTP, and OTP — without writing + biometric or session code. Drop-in OAuth 2.0 / OpenID Connect for web, mobile, and desktop apps. +

+ + + +
+
+

Hosted login (recommended)

+

Redirect users to verify.fivucsas.com/login, get a code back, exchange for tokens. Standard OAuth 2.0 + PKCE.

+
+
+

Embeddable widget

+

Iframe-friendly step-up MFA for sensitive actions. Camera, microphone, and WebAuthn delegated to the widget origin.

+
+
+

Multi-factor by default

+

Tenants configure which factors are required per flow. Backend enforces the policy — clients can't downgrade.

+
+
+ +

10 supported auth methods

+
+ PASSWORD + EMAIL_OTP + SMS_OTP + TOTP + FACE + VOICE + FINGERPRINT + HARDWARE_KEY + QR_CODE + NFC_DOCUMENT +
+ +

Integrate in three lines

+
// Web (loadFivucsasAuth from https://verify.fivucsas.com/fivucsas-auth.esm.js)
+const auth = await loadFivucsasAuth({ apiBaseUrl: "https://api.fivucsas.com" });
+await auth.loginRedirect({ clientId: "YOUR_CLIENT_ID", redirectUri: "https://your-app.com/callback" });
+// User lands back at redirectUri with ?code=… — exchange at /oauth2/token.
+ +
+

+ Direct here without an auth flow? + You probably came from a tenant integration that hasn't started the flow yet, or you're a developer evaluating FIVUCSAS. + See fivucsas.com for the product, or + amispoof.fivucsas.com for the browser-side anti-spoof tester. +

+

+ FIVUCSAS · multi-tenant biometric authentication platform · operated from Türkiye. + Operator: rollingcat.help@gmail.com. +

+
+
+
diff --git a/src/verify-app/main.tsx b/src/verify-app/main.tsx index 9ac54104..590db800 100644 --- a/src/verify-app/main.tsx +++ b/src/verify-app/main.tsx @@ -34,14 +34,37 @@ if (!rootElement) { } // Hosted-first routing: top-level /login renders the full-page OIDC surface; -// everything else (including framed widget) renders VerifyApp. +// a framed or parameterised widget renders VerifyApp. const path = window.location.pathname.replace(/\/+$/, '') || '/' -const isHosted = - window.top === window.self && - (path === '/login' || path.endsWith('/login')) +const isFramed = window.top !== window.self +const isHosted = !isFramed && (path === '/login' || path.endsWith('/login')) -ReactDOM.createRoot(rootElement).render( - - {isHosted ? : } - -) +// A bare, top-level visit to the root (no widget context) must NOT mount +// VerifyApp: with no session/client params it renders a "missing parameters" +// error — which is what a direct visitor to verify.fivucsas.com would see. +// In that case we leave the static landing (#verify-landing in index.html) +// visible by NOT flipping data-mounted. React is only mounted when there is a +// real surface to render: the hosted /login, an embedded (framed) widget, or a +// widget invoked with the parameters it needs. +const params = new URLSearchParams(window.location.search) +const hasWidgetContext = + isFramed || + params.has('session_id') || + params.has('client_id') || + params.has('flow') || + params.has('user_id') + +if (isHosted || hasWidgetContext) { + ReactDOM.createRoot(rootElement).render( + + {isHosted ? : } + + ) + // Hide the static landing once React has painted (CSS rule + // `#verify-root[data-mounted="true"] ~ #verify-landing { display:none }`). + // rAF avoids a flash between landing-hidden and React's first paint. + requestAnimationFrame(() => { + rootElement.setAttribute('data-mounted', 'true') + }) +} +// else: bare direct root visit → leave #verify-landing showing; nothing to mount. diff --git a/vite.verify.config.ts b/vite.verify.config.ts index 9fb79516..ce065af4 100644 --- a/vite.verify.config.ts +++ b/vite.verify.config.ts @@ -23,6 +23,11 @@ import path from 'path' export default defineConfig(({ mode }) => ({ plugins: [react()], root: path.resolve(__dirname, 'src/verify-app'), + // Load .env. from the project root, not from `root` (src/verify-app). + // Without this, VITE_API_BASE_URL is undefined at build time and the + // fail-fast in src/config/env.ts throws at module load → React never + // mounts at /login (regression from PR #62 centralizing the env var). + envDir: path.resolve(__dirname), resolve: { alias: { '@': path.resolve(__dirname, './src'),