@openliveness/web 0.0.0-stage → 0.1.0

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 CHANGED
@@ -1,3 +1,39 @@
1
- # Temporary Holding Version
1
+ # @openliveness/web
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Browser side of openliveness. Shows the QR code and waits for the phone to finish.
4
+
5
+ ```bash
6
+ pnpm add @openliveness/web
7
+ ```
8
+
9
+ ```ts
10
+ import { verify } from '@openliveness/web';
11
+
12
+ const result = await verify({
13
+ relayUrl: 'localhost:3000', // your relay host
14
+ assuranceRequired: 'IAL2',
15
+ onQRReady: (dataUrl) => (img.src = dataUrl),
16
+ onPhoneConnected: () => setStatus('Follow the steps on your phone'),
17
+ });
18
+
19
+ // send result.token to your backend and verify it with @openliveness/verify
20
+ ```
21
+
22
+ **You run the relay yourself.** It's the Node server that brokers the
23
+ browser↔phone handshake — see
24
+ [openliveness/relay](https://github.com/ramuvenky/openliveness/tree/main/relay)
25
+ for the `docker compose` setup. Point `relayUrl` at wherever you deploy it.
26
+
27
+ It uses a WebSocket and falls back to polling (1s, growing 1.5x up to 5s) if the socket fails.
28
+ If the phone reports a failed check, `verify()` rejects with `CDLRetryableError`. Call `verify()` again for a fresh session.
29
+
30
+ React:
31
+
32
+ ```tsx
33
+ import { LivenessWidget } from '@openliveness/web/react';
34
+
35
+ <LivenessWidget relayUrl="relay.openliveness.dev" onComplete={(r) => send(r.token)} />
36
+ ```
37
+
38
+ `relyingParty` defaults to the page hostname. Always re-check the token on your backend,
39
+ the browser result alone is not proof.
@@ -0,0 +1,4 @@
1
+ import { CDLOptions, CDLResult } from './types';
2
+ export declare function verify(options: CDLOptions): Promise<CDLResult>;
3
+ export { CDLError, CDLRetryableError } from './types';
4
+ export type { CDLOptions, CDLResult } from './types';
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 {};
@@ -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
+ }
@@ -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,40 @@
1
1
  {
2
2
  "name": "@openliveness/web",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Browser SDK for openliveness: QR display and session tracking",
5
+ "license": "Apache-2.0",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ },
13
+ "./react": {
14
+ "types": "./dist/react/LivenessWidget.d.ts",
15
+ "default": "./dist/react/LivenessWidget.js"
16
+ }
17
+ },
18
+ "files": ["dist"],
19
+ "scripts": {
20
+ "build": "tsc",
21
+ "test": "vitest run",
22
+ "lint": "tsc -p tsconfig.lint.json"
23
+ },
24
+ "dependencies": {
25
+ "qrcode": "^1.5.0"
26
+ },
27
+ "peerDependencies": {
28
+ "react": "^18.0.0"
29
+ },
30
+ "peerDependenciesMeta": {
31
+ "react": { "optional": true }
32
+ },
33
+ "devDependencies": {
34
+ "@types/qrcode": "^1.5.0",
35
+ "@types/react": "^18.2.0",
36
+ "react": "^18.2.0",
37
+ "typescript": "^5.4.0",
38
+ "vitest": "^1.4.0"
39
+ }
40
+ }