# liveCheck

Self-hosted face verification (ID photo vs. live selfie) with active-challenge liveness detection (blink / head turn), delivered as an embeddable widget for both websites (`<iframe>`) and mobile apps (WebView).

No customer data is ever persisted: ID photos and selfie frames are decoded straight into memory, processed, and discarded when the request ends. Nothing is written to disk or a database. The only "session" state is a short-lived, self-contained JWT — there is no server-side session store.

## How it works

1. Host page/app embeds the widget: `GET /widget?origin=<host origin>`.
2. Widget captures a photo of the user's ID document.
3. Widget requests `POST /api/challenge` → gets a random liveness challenge (`blink`, `turn_left`, `turn_right`) embedded in a signed, expiring JWT.
4. Widget shows a short "get ready" countdown, then records ~5 seconds of webcam frames while the user follows the instruction, and submits everything to `POST /api/verify`.
5. Backend verifies the challenge token, runs liveness detection (MediaPipe FaceMesh) on the frame burst, and — if liveness passes — compares a face encoding (`face_recognition`/dlib) from the ID photo against the best selfie frame.
6. Backend returns `{ passed, confidence, liveness: {...}, face_match: {...} }`. The widget shows the result and forwards it to the host via `window.parent.postMessage(...)` (iframe) and `window.ReactNativeWebView.postMessage(...)` (React Native WebView).

For a step-by-step guide aimed at a third party embedding this in their own website or mobile app, see [INTEGRATE.md](INTEGRATE.md).

## Requirements

- Python 3.9+
- `cmake` on your PATH (required to build `dlib`, a dependency of `face_recognition`) — on macOS: `brew install cmake`

## Setup

```bash
python3 -m venv .venv
source .venv/bin/activate       # Windows: .venv\Scripts\activate
pip install -r requirements.txt
```

`dlib` compiles from source the first time — this can take several minutes.

## Configuration

Set via environment variables before starting the server:

| Variable | Required | Description |
|---|---|---|
| `JWT_SECRET` | Yes | Secret used to sign challenge tokens. The app refuses to start without it. |
| `ALLOWED_ORIGINS` | Recommended | Comma-separated list of origins allowed to embed `/widget` (e.g. `https://app.example.com,https://example.com`). If unset, any origin is allowed — fine for local dev, **not for production**. |

Tunable thresholds live in [verification/config.py](verification/config.py) and [verification/liveness.py](verification/liveness.py) (face match distance cutoff, blink EAR thresholds, head-turn sensitivity, frame count bounds).

## Running

```bash
JWT_SECRET=change-me ALLOWED_ORIGINS=http://localhost:3000 \
  python3 -m uvicorn app:app --reload --port 8123
```

Webcam access requires a secure context, so test at `http://localhost:...` or over HTTPS — not a plain LAN IP.

Try it directly in a browser at:

```
http://localhost:8123/widget?origin=http://localhost:3000
```

For production, run behind HTTPS (e.g. via a reverse proxy) with a real `JWT_SECRET` and a locked-down `ALLOWED_ORIGINS`.

## Embedding

Prefer not to hand-roll the iframe/`postMessage`/WebView-bridge boilerplate
below? See [sdk/](sdk/) for client SDKs (web, iOS, Android) that wrap it.

### Website (iframe)

```html
<iframe
  src="https://verify.yourapp.com/widget?origin=https://your-site.com"
  allow="camera"
  width="420" height="640">
</iframe>
```

```js
window.addEventListener("message", (event) => {
  if (event.origin !== "https://verify.yourapp.com") return;
  if (event.data?.type !== "liveCheck:result") return;
  const { passed, confidence, liveness, face_match } = event.data;
  // handle result
});
```

`origin` in the widget URL must exactly match an entry in `ALLOWED_ORIGINS`, or the widget refuses to render (`403`), and it's also the `postMessage` target the widget will send the result to.

### Mobile app (WebView)

Load the same URL in a WebView pointed at your app's own scheme/origin as `origin`, and listen for messages:

- **React Native** (`react-native-webview`): pass an `onMessage` handler; the widget calls `window.ReactNativeWebView.postMessage(JSON.stringify(result))` automatically.
- **Plain iOS/Android WebViews**: add a JS bridge (`WKScriptMessageHandler` on iOS, `addJavascriptInterface` on Android) and extend [static/widget.js](static/widget.js)'s `postResult()` to also call it — the hook point is already isolated there.

Camera permission must be granted to the WebView (`allow="camera"` has no effect in native WebViews; use the platform's own permission APIs).

## API reference

### `POST /api/challenge`

No body. Returns:

```json
{ "token": "<jwt>", "challenge": "blink", "expires_in": 60 }
```

### `POST /api/verify`

`multipart/form-data`:

| Field | Type | Notes |
|---|---|---|
| `challenge_token` | text | Token from `/api/challenge`, must still be valid (60s TTL). |
| `id_image` | file | Photo of the ID document, must contain exactly one detectable face. |
| `selfie_frames` | file (repeated) | 8–40 frames captured during the challenge window. |

Response:

```json
{
  "passed": true,
  "confidence": 0.87,
  "liveness": { "passed": true, "score": 0.91, "reason": "Blink detected" },
  "face_match": { "matched": true, "confidence": 0.83, "reason": "Face matches ID document" }
}
```

If liveness fails, `face_match` is `null` and the face-match step is skipped entirely.

## Security notes / limitations

- Active-challenge liveness (blink/head-turn) has no passive anti-spoofing (e.g. print/screen-replay detection). A convincing video replay of the requested action could pass. If fraud risk is a concern, that's the natural next addition.
- `frame-ancestors` (via `Content-Security-Policy`) is used instead of `X-Frame-Options` so the widget can be embedded by multiple allowed origins.
- Tune `FACE_MATCH_DISTANCE_THRESHOLD` and the liveness thresholds against your own test data before relying on this in production — defaults are the underlying libraries' general-purpose values, not calibrated for any specific ID-document quality or lighting conditions.
