@openliveness/web 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +121 -2
- package/dist/index.d.ts +4 -0
- package/dist/index.js +47 -0
- package/dist/qr.d.ts +1 -0
- package/dist/qr.js +12 -0
- package/dist/react/LivenessWidget.d.ts +6 -0
- package/dist/react/LivenessWidget.js +34 -0
- package/dist/session.d.ts +16 -0
- package/dist/session.js +116 -0
- package/dist/types.d.ts +39 -0
- package/dist/types.js +19 -0
- package/dist/websocket.d.ts +6 -0
- package/dist/websocket.js +76 -0
- package/package.json +67 -4
package/README.md
CHANGED
|
@@ -1,3 +1,122 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @openliveness/web
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> Browser SDK for [openliveness](https://github.com/ramuvenky/openliveness) — proves there is a real, live human in front of the camera, without sending any biometric data to a server.
|
|
4
|
+
|
|
5
|
+
In a world where deepfakes and AI-generated video are becoming indistinguishable from reality, this package lets your web app verify that the person on the other end of a camera is actually a live human — **without the user's face, video, or any biometric features ever leaving their phone**.
|
|
6
|
+
|
|
7
|
+
## Why liveness without the cloud
|
|
8
|
+
|
|
9
|
+
Browser-only liveness is broken. Virtual-camera software (OBS, ManyCam) lets an attacker feed AI-generated video into any `getUserMedia()` call, and the browser has no way to tell real-camera footage from injected footage. This is the dominant fraud vector against remote liveness checks today.
|
|
10
|
+
|
|
11
|
+
Every commercial fix ships your users' faces to the vendor's cloud. **openliveness does not.** All biometric computation runs on the user's phone, which cryptographically proves its camera is genuine using hardware attestation (Apple App Attest / Google Play Integrity). Your backend — and this package — only ever see a signed attestation JWT.
|
|
12
|
+
|
|
13
|
+
## How it works
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
Browser shows QR → User scans with phone → Phone runs liveness locally
|
|
17
|
+
→ Phone signs result with hardware key
|
|
18
|
+
→ Server verifies proof
|
|
19
|
+
→ Browser session complete
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
This package handles the browser half: display the QR, wait for the phone to finish, hand you the JWT.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pnpm add @openliveness/web
|
|
28
|
+
# or
|
|
29
|
+
npm install @openliveness/web
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Zero runtime dependencies beyond `qrcode`. Works with any framework or no framework.
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { verify } from '@openliveness/web';
|
|
38
|
+
|
|
39
|
+
const result = await verify({
|
|
40
|
+
relayUrl: 'localhost:3000', // your relay — see "Running the relay" below
|
|
41
|
+
assuranceRequired: 'IAL2', // 'IAL2' | 'software_only'
|
|
42
|
+
onQRReady: (dataUrl) => (document.getElementById('qr').src = dataUrl),
|
|
43
|
+
onPhoneConnected: () => setStatus('Follow the steps on your phone'),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// result.decision === 'pass'
|
|
47
|
+
// result.assuranceLevel === 'IAL2'
|
|
48
|
+
// result.token === JWT — forward it to your backend
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Send `result.token` to your backend and verify it with [`@openliveness/verify`](https://www.npmjs.com/package/@openliveness/verify). **Never trust the browser result on its own** — the JWT is the cryptographic proof; everything else is UI state.
|
|
52
|
+
|
|
53
|
+
### React
|
|
54
|
+
|
|
55
|
+
```tsx
|
|
56
|
+
import { LivenessWidget } from '@openliveness/web/react';
|
|
57
|
+
|
|
58
|
+
<LivenessWidget
|
|
59
|
+
relayUrl="localhost:3000"
|
|
60
|
+
onComplete={(r) => send(r.token)}
|
|
61
|
+
/>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Errors
|
|
65
|
+
|
|
66
|
+
If the phone reports a failed check, `verify()` rejects with `CDLRetryableError` — call `verify()` again for a fresh session.
|
|
67
|
+
|
|
68
|
+
Transport: WebSocket first, falling back to HTTP polling (1s, growing 1.5x up to 5s) if the socket fails.
|
|
69
|
+
|
|
70
|
+
## Running the relay
|
|
71
|
+
|
|
72
|
+
This package talks to a small Node service — the relay — that brokers the browser↔phone handshake and issues the JWT. **You run the relay yourself.** It is open source and ships in the same repo:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
git clone https://github.com/ramuvenky/openliveness.git
|
|
76
|
+
cd openliveness/relay && docker compose up -d
|
|
77
|
+
# relay listens on http://localhost:3000
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Point `relayUrl` at wherever you deploy it. The relay never sees biometric data — only signed scores.
|
|
81
|
+
|
|
82
|
+
## The complete picture
|
|
83
|
+
|
|
84
|
+
openliveness is four pieces working together. This package is one of them:
|
|
85
|
+
|
|
86
|
+
| Where | Package | Role |
|
|
87
|
+
|-------|---------|------|
|
|
88
|
+
| Browser | **`@openliveness/web`** *(this package)* | Shows QR, waits for the phone |
|
|
89
|
+
| Phone | [Android SDK](https://jitpack.io/#ramuvenky/openliveness) · iOS SDK (coming soon) | Runs the five liveness layers, signs the result |
|
|
90
|
+
| Relay | [`relay/`](https://github.com/ramuvenky/openliveness/tree/main/relay) | Brokers the handshake, issues the JWT |
|
|
91
|
+
| Server | [`@openliveness/verify`](https://www.npmjs.com/package/@openliveness/verify) | Verifies the JWT on your backend |
|
|
92
|
+
|
|
93
|
+
## What you don't ship by using this
|
|
94
|
+
|
|
95
|
+
- Video, photos, face embeddings, or any biometric features leaving the user's device
|
|
96
|
+
- A dependency on anyone's cloud (relay is self-host; phone runs locally)
|
|
97
|
+
- GDPR Article 9 scope for your backend — you never process special-category data
|
|
98
|
+
|
|
99
|
+
## Who this is for
|
|
100
|
+
|
|
101
|
+
- **Banks and financial institutions** — IAL2-grade liveness for KYC onboarding without cloud biometric processing
|
|
102
|
+
- **Healthcare** — patient verification where biometric data must stay on-device (HIPAA, GDPR)
|
|
103
|
+
- **Government and public sector** — remote liveness proofing with auditable, open-source algorithms
|
|
104
|
+
- **KYC and identity platforms** — add liveness without becoming a biometric data processor
|
|
105
|
+
- **Any application** — where proving human presence must not compromise privacy
|
|
106
|
+
|
|
107
|
+
## Documentation
|
|
108
|
+
|
|
109
|
+
- [Project README](https://github.com/ramuvenky/openliveness#readme) — overview and design principles
|
|
110
|
+
- [Technical specification](https://github.com/ramuvenky/openliveness/blob/main/docs/Spec.md) — full 28-section protocol reference
|
|
111
|
+
- [Threat model](https://github.com/ramuvenky/openliveness/blob/main/protocol/ThreatModel.md) — what this protects against, what it does not
|
|
112
|
+
- [Attestation schema](https://github.com/ramuvenky/openliveness/blob/main/protocol/AttestationSchema.json) — JWT payload contract
|
|
113
|
+
|
|
114
|
+
## Contributing
|
|
115
|
+
|
|
116
|
+
Issues and pull requests welcome at [github.com/ramuvenky/openliveness](https://github.com/ramuvenky/openliveness). See [CONTRIBUTING.md](https://github.com/ramuvenky/openliveness/blob/main/CONTRIBUTING.md) — the iOS SDK port is currently the highest-value contribution.
|
|
117
|
+
|
|
118
|
+
Report security issues privately per [SECURITY.md](https://github.com/ramuvenky/openliveness/blob/main/SECURITY.md).
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
Apache 2.0
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CDLRetryableError = exports.CDLError = void 0;
|
|
4
|
+
exports.verify = verify;
|
|
5
|
+
const qr_1 = require("./qr");
|
|
6
|
+
const session_1 = require("./session");
|
|
7
|
+
const types_1 = require("./types");
|
|
8
|
+
const websocket_1 = require("./websocket");
|
|
9
|
+
async function verify(options) {
|
|
10
|
+
const session = await (0, session_1.initiate)(options);
|
|
11
|
+
options.onQRReady?.(await (0, qr_1.qrDataUrl)(session.qr_payload));
|
|
12
|
+
const limit = options.timeoutMs ?? Math.max(Date.parse(session.expires_at) - Date.now(), 0) + 60000;
|
|
13
|
+
// One controller lets the timeout and the caller's own signal stop both transports.
|
|
14
|
+
const controller = new AbortController();
|
|
15
|
+
options.signal?.addEventListener('abort', () => controller.abort());
|
|
16
|
+
const timer = setTimeout(() => controller.abort(), limit);
|
|
17
|
+
const signal = controller.signal;
|
|
18
|
+
try {
|
|
19
|
+
try {
|
|
20
|
+
return await (0, websocket_1.waitForResult)(session.ws_url, session.poll_token, session.session_id, {
|
|
21
|
+
onPhoneConnected: options.onPhoneConnected,
|
|
22
|
+
onProcessing: options.onProcessing,
|
|
23
|
+
signal,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
if (!(0, websocket_1.isFallback)(e) || signal.aborted)
|
|
28
|
+
throw e;
|
|
29
|
+
return await (0, session_1.pollWithBackoff)(session.session_id, session.poll_token, options.relayUrl, (state) => {
|
|
30
|
+
if (state === 'phone_connected')
|
|
31
|
+
options.onPhoneConnected?.();
|
|
32
|
+
}, { signal });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
if (e instanceof types_1.CDLError && e.code === 'aborted' && !options.signal?.aborted) {
|
|
37
|
+
throw new types_1.CDLError('timeout', 'liveness check timed out');
|
|
38
|
+
}
|
|
39
|
+
throw e;
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
var types_2 = require("./types");
|
|
46
|
+
Object.defineProperty(exports, "CDLError", { enumerable: true, get: function () { return types_2.CDLError; } });
|
|
47
|
+
Object.defineProperty(exports, "CDLRetryableError", { enumerable: true, get: function () { return types_2.CDLRetryableError; } });
|
package/dist/qr.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function qrDataUrl(qrPayload: string): Promise<string>;
|
package/dist/qr.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.qrDataUrl = qrDataUrl;
|
|
7
|
+
const qrcode_1 = __importDefault(require("qrcode"));
|
|
8
|
+
// The relay already returns qr_payload as base64 JSON. The image holds that string as is,
|
|
9
|
+
// which is what the phone SDKs expect to base64 decode.
|
|
10
|
+
async function qrDataUrl(qrPayload) {
|
|
11
|
+
return qrcode_1.default.toDataURL(qrPayload, { errorCorrectionLevel: 'M', margin: 2, width: 320 });
|
|
12
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { CDLOptions, CDLResult } from '../types';
|
|
2
|
+
export interface LivenessWidgetProps extends Omit<CDLOptions, 'onQRReady' | 'signal'> {
|
|
3
|
+
onComplete: (result: CDLResult) => void;
|
|
4
|
+
onError?: (error: Error) => void;
|
|
5
|
+
}
|
|
6
|
+
export declare function LivenessWidget({ onComplete, onError, ...options }: LivenessWidgetProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LivenessWidget = LivenessWidget;
|
|
4
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
5
|
+
const react_1 = require("react");
|
|
6
|
+
const index_1 = require("../index");
|
|
7
|
+
function LivenessWidget({ onComplete, onError, ...options }) {
|
|
8
|
+
const [qr, setQr] = (0, react_1.useState)(null);
|
|
9
|
+
const [status, setStatus] = (0, react_1.useState)('Preparing...');
|
|
10
|
+
(0, react_1.useEffect)(() => {
|
|
11
|
+
const controller = new AbortController();
|
|
12
|
+
(0, index_1.verify)({
|
|
13
|
+
...options,
|
|
14
|
+
signal: controller.signal,
|
|
15
|
+
onQRReady: (url) => {
|
|
16
|
+
setQr(url);
|
|
17
|
+
setStatus('Scan the code with your phone');
|
|
18
|
+
},
|
|
19
|
+
onPhoneConnected: () => setStatus('Phone connected, follow the steps on your phone'),
|
|
20
|
+
onProcessing: (progress) => setStatus(`Checking... ${Math.round(progress * 100)}%`),
|
|
21
|
+
})
|
|
22
|
+
.then(onComplete)
|
|
23
|
+
.catch((e) => {
|
|
24
|
+
if (controller.signal.aborted)
|
|
25
|
+
return;
|
|
26
|
+
setStatus('Something went wrong, please try again');
|
|
27
|
+
onError?.(e);
|
|
28
|
+
});
|
|
29
|
+
return () => controller.abort();
|
|
30
|
+
// the session is started once per mount
|
|
31
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
32
|
+
}, []);
|
|
33
|
+
return ((0, jsx_runtime_1.jsxs)("div", { children: [qr && (0, jsx_runtime_1.jsx)("img", { src: qr, alt: "Scan with your phone to start liveness check", width: 320, height: 320 }), (0, jsx_runtime_1.jsx)("p", { role: "status", children: status })] }));
|
|
34
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { CDLOptions, CDLResult, InitiateResponse } from './types';
|
|
2
|
+
export declare const POLL_INITIAL_INTERVAL_MS = 1000;
|
|
3
|
+
export declare const POLL_MAX_INTERVAL_MS = 5000;
|
|
4
|
+
export declare const POLL_BACKOFF_MULTIPLIER = 1.5;
|
|
5
|
+
type Fetch = typeof fetch;
|
|
6
|
+
export declare function httpBase(relayUrl: string): string;
|
|
7
|
+
export declare function isLocalHost(host: string): boolean;
|
|
8
|
+
export declare const sleep: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
9
|
+
export declare function initiate(options: CDLOptions, doFetch?: Fetch): Promise<InitiateResponse>;
|
|
10
|
+
export declare function toResult(status: Record<string, string>, sessionId: string): CDLResult;
|
|
11
|
+
export declare function pollWithBackoff(sessionId: string, pollToken: string, relayUrl: string, onUpdate: (state: string) => void, opts?: {
|
|
12
|
+
signal?: AbortSignal;
|
|
13
|
+
doFetch?: Fetch;
|
|
14
|
+
sleepFn?: (ms: number) => Promise<void>;
|
|
15
|
+
}): Promise<CDLResult>;
|
|
16
|
+
export {};
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.sleep = exports.POLL_BACKOFF_MULTIPLIER = exports.POLL_MAX_INTERVAL_MS = exports.POLL_INITIAL_INTERVAL_MS = void 0;
|
|
4
|
+
exports.httpBase = httpBase;
|
|
5
|
+
exports.isLocalHost = isLocalHost;
|
|
6
|
+
exports.initiate = initiate;
|
|
7
|
+
exports.toResult = toResult;
|
|
8
|
+
exports.pollWithBackoff = pollWithBackoff;
|
|
9
|
+
const types_1 = require("./types");
|
|
10
|
+
exports.POLL_INITIAL_INTERVAL_MS = 1000;
|
|
11
|
+
exports.POLL_MAX_INTERVAL_MS = 5000;
|
|
12
|
+
exports.POLL_BACKOFF_MULTIPLIER = 1.5;
|
|
13
|
+
// "relay.openliveness.dev" becomes https, local / private addresses become http.
|
|
14
|
+
// RFC 1918 covers 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 — the previous
|
|
15
|
+
// regex missed the 172.16/12 range, so a developer running the relay on a
|
|
16
|
+
// private corporate subnet in that range got https:// (and a TLS handshake
|
|
17
|
+
// failure) instead of http://. We also recognize 0.0.0.0 (wildcard bind
|
|
18
|
+
// addresses hit from the same host) and IPv6 loopback / unique-local
|
|
19
|
+
// prefixes. If a caller is on a non-loopback IPv6 and wants plaintext they
|
|
20
|
+
// can still pass the scheme explicitly in relayUrl.
|
|
21
|
+
function httpBase(relayUrl) {
|
|
22
|
+
const withScheme = /^https?:\/\//i.test(relayUrl)
|
|
23
|
+
? relayUrl
|
|
24
|
+
: `${isLocalHost(relayUrl) ? 'http' : 'https'}://${relayUrl}`;
|
|
25
|
+
return withScheme.replace(/\/+$/, '');
|
|
26
|
+
}
|
|
27
|
+
// Exported so tests can pin the host-classification contract directly.
|
|
28
|
+
function isLocalHost(host) {
|
|
29
|
+
// Strip :port and any trailing path for the match. For bracketed IPv6
|
|
30
|
+
// ([::1]:3000) the bracketed form is kept so the ipv6 branch sees it.
|
|
31
|
+
const bare = host.replace(/\/.*$/, '').replace(/:(\d+)$/, '');
|
|
32
|
+
if (/^localhost$/i.test(bare))
|
|
33
|
+
return true;
|
|
34
|
+
if (bare === '0.0.0.0')
|
|
35
|
+
return true;
|
|
36
|
+
if (/^127\./.test(bare))
|
|
37
|
+
return true;
|
|
38
|
+
if (/^10\./.test(bare))
|
|
39
|
+
return true;
|
|
40
|
+
if (/^192\.168\./.test(bare))
|
|
41
|
+
return true;
|
|
42
|
+
// RFC 1918 172.16.0.0/12 — second octet 16..31. The previous heuristic
|
|
43
|
+
// missed this entirely.
|
|
44
|
+
if (/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(bare))
|
|
45
|
+
return true;
|
|
46
|
+
// IPv6: ::1 (loopback), fc00::/7 (unique local), fe80::/10 (link local).
|
|
47
|
+
// Accept with or without brackets. Match the leading hex chunk only; a
|
|
48
|
+
// bogus host like "fcx..." falls through to https.
|
|
49
|
+
const ipv6 = bare.replace(/^\[|\]$/g, '').toLowerCase();
|
|
50
|
+
if (ipv6 === '::1')
|
|
51
|
+
return true;
|
|
52
|
+
if (/^(fc|fd)[0-9a-f]{2}:/.test(ipv6))
|
|
53
|
+
return true;
|
|
54
|
+
if (/^fe[89ab][0-9a-f]:/.test(ipv6))
|
|
55
|
+
return true;
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
const sleep = (ms, signal) => new Promise((resolve, reject) => {
|
|
59
|
+
const t = setTimeout(resolve, ms);
|
|
60
|
+
signal?.addEventListener('abort', () => {
|
|
61
|
+
clearTimeout(t);
|
|
62
|
+
reject(new types_1.CDLError('aborted'));
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
exports.sleep = sleep;
|
|
66
|
+
async function initiate(options, doFetch = fetch) {
|
|
67
|
+
const relyingParty = options.relyingParty ?? (typeof location !== 'undefined' ? location.hostname : undefined);
|
|
68
|
+
if (!relyingParty)
|
|
69
|
+
throw new types_1.CDLError('ERR_SCHEMA_INVALID', 'relyingParty is required outside a browser');
|
|
70
|
+
const res = await doFetch(`${httpBase(options.relayUrl)}/cdl/session/initiate`, {
|
|
71
|
+
method: 'POST',
|
|
72
|
+
headers: { 'Content-Type': 'application/json' },
|
|
73
|
+
body: JSON.stringify({
|
|
74
|
+
relying_party: relyingParty,
|
|
75
|
+
assurance_required: options.assuranceRequired ?? 'IAL2',
|
|
76
|
+
webhook_url: options.webhookUrl,
|
|
77
|
+
}),
|
|
78
|
+
signal: options.signal,
|
|
79
|
+
});
|
|
80
|
+
const body = await res.json().catch(() => ({}));
|
|
81
|
+
if (!res.ok)
|
|
82
|
+
throw new types_1.CDLError(body.error_code ?? 'ERR_INTERNAL', body.message);
|
|
83
|
+
return body;
|
|
84
|
+
}
|
|
85
|
+
function toResult(status, sessionId) {
|
|
86
|
+
return {
|
|
87
|
+
decision: status.liveness_decision,
|
|
88
|
+
assuranceLevel: status.assurance_level,
|
|
89
|
+
token: status.attestation_token,
|
|
90
|
+
sessionId,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
// Fallback for networks that block WebSockets.
|
|
94
|
+
async function pollWithBackoff(sessionId, pollToken, relayUrl, onUpdate, opts = {}) {
|
|
95
|
+
const doFetch = opts.doFetch ?? fetch;
|
|
96
|
+
const wait = opts.sleepFn ?? ((ms) => (0, exports.sleep)(ms, opts.signal));
|
|
97
|
+
let interval = exports.POLL_INITIAL_INTERVAL_MS;
|
|
98
|
+
for (;;) {
|
|
99
|
+
await wait(interval);
|
|
100
|
+
interval = Math.min(interval * exports.POLL_BACKOFF_MULTIPLIER, exports.POLL_MAX_INTERVAL_MS);
|
|
101
|
+
const res = await doFetch(`${httpBase(relayUrl)}/cdl/session/${sessionId}/status`, {
|
|
102
|
+
headers: { Authorization: `Bearer ${pollToken}` },
|
|
103
|
+
signal: opts.signal,
|
|
104
|
+
});
|
|
105
|
+
const status = await res.json();
|
|
106
|
+
if (!res.ok)
|
|
107
|
+
throw new types_1.CDLError(status.error_code ?? 'ERR_INTERNAL', status.message);
|
|
108
|
+
onUpdate(status.state);
|
|
109
|
+
if (status.state === 'complete')
|
|
110
|
+
return toResult(status, sessionId);
|
|
111
|
+
if (status.state === 'failed')
|
|
112
|
+
throw new types_1.CDLRetryableError('liveness_check_failed');
|
|
113
|
+
if (status.state === 'expired')
|
|
114
|
+
throw new types_1.CDLError('session_expired');
|
|
115
|
+
}
|
|
116
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export interface CDLOptions {
|
|
2
|
+
/** Relay host or url, e.g. "relay.openliveness.dev" or "http://localhost:3000". */
|
|
3
|
+
relayUrl: string;
|
|
4
|
+
/** Who the token is issued for. Defaults to the current page hostname. */
|
|
5
|
+
relyingParty?: string;
|
|
6
|
+
assuranceRequired?: 'IAL2' | 'IAL1' | 'any';
|
|
7
|
+
/** Optional url the relay calls when the session finishes. */
|
|
8
|
+
webhookUrl?: string;
|
|
9
|
+
onQRReady?: (qrDataUrl: string) => void;
|
|
10
|
+
onPhoneConnected?: () => void;
|
|
11
|
+
onProcessing?: (progress: number, layer: string) => void;
|
|
12
|
+
/** Overall limit for the whole flow. Defaults to the session lifetime plus a minute. */
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
/** Stop waiting and reject. */
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
}
|
|
17
|
+
export interface CDLResult {
|
|
18
|
+
decision: 'pass' | 'fail' | 'inconclusive';
|
|
19
|
+
assuranceLevel: 'IAL2' | 'IAL1' | 'software_only';
|
|
20
|
+
token: string;
|
|
21
|
+
sessionId: string;
|
|
22
|
+
}
|
|
23
|
+
export declare class CDLError extends Error {
|
|
24
|
+
readonly code: string;
|
|
25
|
+
constructor(code: string, message?: string);
|
|
26
|
+
}
|
|
27
|
+
/** The session failed but the user can start over by calling verify() again. */
|
|
28
|
+
export declare class CDLRetryableError extends CDLError {
|
|
29
|
+
constructor(reason: string);
|
|
30
|
+
}
|
|
31
|
+
export interface InitiateResponse {
|
|
32
|
+
session_id: string;
|
|
33
|
+
challenge_id: string;
|
|
34
|
+
challenge_sequence: string[];
|
|
35
|
+
qr_payload: string;
|
|
36
|
+
poll_token: string;
|
|
37
|
+
expires_at: string;
|
|
38
|
+
ws_url: string;
|
|
39
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CDLRetryableError = exports.CDLError = void 0;
|
|
4
|
+
class CDLError extends Error {
|
|
5
|
+
constructor(code, message) {
|
|
6
|
+
super(message ?? code);
|
|
7
|
+
this.name = 'CDLError';
|
|
8
|
+
this.code = code;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
exports.CDLError = CDLError;
|
|
12
|
+
/** The session failed but the user can start over by calling verify() again. */
|
|
13
|
+
class CDLRetryableError extends CDLError {
|
|
14
|
+
constructor(reason) {
|
|
15
|
+
super(reason);
|
|
16
|
+
this.name = 'CDLRetryableError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
exports.CDLRetryableError = CDLRetryableError;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { CDLOptions, CDLResult } from './types';
|
|
2
|
+
export interface WsFallback extends Error {
|
|
3
|
+
fallback: true;
|
|
4
|
+
}
|
|
5
|
+
export declare const isFallback: (e: unknown) => e is WsFallback;
|
|
6
|
+
export declare function waitForResult(wsUrl: string, pollToken: string, sessionId: string, options: Pick<CDLOptions, 'onPhoneConnected' | 'onProcessing' | 'signal'>): Promise<CDLResult>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isFallback = void 0;
|
|
4
|
+
exports.waitForResult = waitForResult;
|
|
5
|
+
const types_1 = require("./types");
|
|
6
|
+
const fallbackError = (msg) => Object.assign(new Error(msg), { fallback: true });
|
|
7
|
+
const isFallback = (e) => !!e?.fallback;
|
|
8
|
+
exports.isFallback = isFallback;
|
|
9
|
+
// Resolves with the result. Rejects with a WsFallback if the socket itself fails,
|
|
10
|
+
// so the caller can switch to polling instead of giving up.
|
|
11
|
+
function waitForResult(wsUrl, pollToken, sessionId, options) {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
if (typeof WebSocket === 'undefined')
|
|
14
|
+
return reject(fallbackError('no WebSocket'));
|
|
15
|
+
// The pollToken used to ride on the URL as ?token=, which lands it in
|
|
16
|
+
// LB / CDN / Fastify access logs. We now open the WS unauthenticated
|
|
17
|
+
// and send {type:"auth",token} as the first frame — the server closes
|
|
18
|
+
// the socket after 5s if the frame doesn't arrive.
|
|
19
|
+
const ws = new WebSocket(wsUrl);
|
|
20
|
+
ws.onopen = () => {
|
|
21
|
+
try {
|
|
22
|
+
ws.send(JSON.stringify({ type: 'auth', token: pollToken }));
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// onerror / onclose will fire next tick
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
let settled = false;
|
|
29
|
+
const finish = (fn) => {
|
|
30
|
+
if (settled)
|
|
31
|
+
return;
|
|
32
|
+
settled = true;
|
|
33
|
+
ws.close();
|
|
34
|
+
fn();
|
|
35
|
+
};
|
|
36
|
+
options.signal?.addEventListener('abort', () => finish(() => reject(new types_1.CDLError('aborted'))));
|
|
37
|
+
ws.onerror = () => finish(() => reject(fallbackError('socket error')));
|
|
38
|
+
ws.onclose = () => finish(() => reject(fallbackError('socket closed')));
|
|
39
|
+
ws.onmessage = (ev) => {
|
|
40
|
+
let msg;
|
|
41
|
+
try {
|
|
42
|
+
msg = JSON.parse(String(ev.data));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
switch (msg.type) {
|
|
48
|
+
case 'phone_connected':
|
|
49
|
+
options.onPhoneConnected?.();
|
|
50
|
+
break;
|
|
51
|
+
case 'processing_update':
|
|
52
|
+
options.onProcessing?.(msg.progress, msg.current_layer);
|
|
53
|
+
break;
|
|
54
|
+
case 'session_complete':
|
|
55
|
+
finish(() => resolve({
|
|
56
|
+
decision: msg.liveness_decision,
|
|
57
|
+
assuranceLevel: msg.assurance_level,
|
|
58
|
+
token: msg.attestation_token,
|
|
59
|
+
sessionId,
|
|
60
|
+
}));
|
|
61
|
+
break;
|
|
62
|
+
case 'session_failed':
|
|
63
|
+
finish(() => reject(msg.retry_allowed
|
|
64
|
+
? new types_1.CDLRetryableError(msg.reason)
|
|
65
|
+
: new types_1.CDLError(msg.reason ?? 'session_failed')));
|
|
66
|
+
break;
|
|
67
|
+
case 'session_expired':
|
|
68
|
+
finish(() => reject(new types_1.CDLError('session_expired', msg.reason)));
|
|
69
|
+
break;
|
|
70
|
+
case 'error':
|
|
71
|
+
finish(() => reject(new types_1.CDLError(msg.error_code, msg.message)));
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,69 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openliveness/web",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Browser SDK for openliveness — liveness verification where biometric data never leaves the user's phone. Proves there is a real live human in front of the camera without sending video, photos, or face embeddings to a server.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/ramuvenky/openliveness.git",
|
|
9
|
+
"directory": "web-sdk"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/ramuvenky/openliveness#readme",
|
|
12
|
+
"bugs": "https://github.com/ramuvenky/openliveness/issues",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"liveness",
|
|
15
|
+
"liveness-detection",
|
|
16
|
+
"face-liveness",
|
|
17
|
+
"presentation-attack-detection",
|
|
18
|
+
"pad",
|
|
19
|
+
"anti-spoofing",
|
|
20
|
+
"deepfake-detection",
|
|
21
|
+
"injection-attack",
|
|
22
|
+
"kyc",
|
|
23
|
+
"ial2",
|
|
24
|
+
"nist-800-63",
|
|
25
|
+
"biometrics",
|
|
26
|
+
"privacy",
|
|
27
|
+
"gdpr",
|
|
28
|
+
"hardware-attestation",
|
|
29
|
+
"app-attest",
|
|
30
|
+
"play-integrity",
|
|
31
|
+
"cross-device",
|
|
32
|
+
"qr-code",
|
|
33
|
+
"openliveness"
|
|
34
|
+
],
|
|
35
|
+
"main": "dist/index.js",
|
|
36
|
+
"types": "dist/index.d.ts",
|
|
37
|
+
"exports": {
|
|
38
|
+
".": {
|
|
39
|
+
"types": "./dist/index.d.ts",
|
|
40
|
+
"default": "./dist/index.js"
|
|
41
|
+
},
|
|
42
|
+
"./react": {
|
|
43
|
+
"types": "./dist/react/LivenessWidget.d.ts",
|
|
44
|
+
"default": "./dist/react/LivenessWidget.js"
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"files": ["dist"],
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "tsc",
|
|
50
|
+
"test": "vitest run",
|
|
51
|
+
"lint": "tsc -p tsconfig.lint.json"
|
|
52
|
+
},
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"qrcode": "^1.5.0"
|
|
55
|
+
},
|
|
56
|
+
"peerDependencies": {
|
|
57
|
+
"react": "^18.0.0"
|
|
58
|
+
},
|
|
59
|
+
"peerDependenciesMeta": {
|
|
60
|
+
"react": { "optional": true }
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"@types/qrcode": "^1.5.0",
|
|
64
|
+
"@types/react": "^18.2.0",
|
|
65
|
+
"react": "^18.2.0",
|
|
66
|
+
"typescript": "^5.4.0",
|
|
67
|
+
"vitest": "^1.4.0"
|
|
68
|
+
}
|
|
69
|
+
}
|