@vizualkei/sophid-client-sdk
v0.50.1
Published
SophID Web Client SDK - Biometric authentication SDK for webapp integration
Maintainers
Readme
@vizualkei/sophid-client-sdk
Browser-side TypeScript SDK that bridges a web application to the SophID Mobile native app for biometric authentication. It supports two flows:
- Phone browser — direct
sophdplk://deep-link launch into SophID Mobile. - Desktop browser — HTTPS QR-code fallback that the user scans with their phone.
After the operation, the SDK long-polls the SophID server for the signed Biometric Result Token (BRT) and returns it to the caller.
Installation
pnpm add @vizualkei/sophid-client-sdkIn Next.js, transpile the package because it ships ESM + TypeScript sources from a workspace dependency:
// next.config.js
module.exports = {
transpilePackages: ['@vizualkei/sophid-client-sdk', '@vizualkei/common-utils'],
};This package depends on @vizualkei/common-utils for logging and declares react@^18.3.1 as a peer dependency.
Public exports
From @vizualkei/sophid-client-sdk:
| Name | Kind | Description |
| ---------------------------------------------------------------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------- |
| NeumaClientHelper | class | High-level helper that handles BST fetch, mobile flow, and BRT submission |
| neumaClientHelper | object | Singleton wrapper around NeumaClientHelper |
| NeumaClientHelperConfig | type | Init shape for the helper |
| EnrollUserOptions | type | { email?: string \| null; phoneNo?: string \| null } |
| SophIDMobileFactory | class (static create()) | Low-level mobile-bridge factory |
| SophIDError | class | Operation error with discriminated code |
| SophIDMobileInterface | type | Public interface of the low-level client |
| SophIDMobileOptions | type | Init options for the low-level client |
| UserDescriptor, EnrollmentResult, AuthenticationResult, KeyRetrievalResult, PackageVersion | types | Result shapes |
| PendingOperationInfo | type | Metadata for an in-flight deep-link op persisted in sessionStorage (see "Resuming pending operations") |
| HandlePendingOperationOptions, PendingOperationResult | types | High-level callback-based return-flow recovery shapes |
| AppLaunchPlatform, AppLaunchFailureContext | types | Phone-browser launch-failure metadata |
| QrCodeData, QrCodeCleanupFn | types | Desktop QR callback contract |
| NEUMA_MOBILE_PLAY_STORE_URL, NEUMA_MOBILE_TESTFLIGHT_URL | constants | Direct install URLs for Neuma Mobile |
| getNeumaMobileInstallUrl(platform) | function | Returns the install URL for a given AppLaunchPlatform (Google Play on Android, TestFlight on iOS) |
| SOPHID_MOBILE_PLAY_STORE_URL, SOPHID_MOBILE_TESTFLIGHT_URL, getSophIDMobileInstallUrl(platform) | legacy aliases | Backward-compatible aliases for the Neuma install URLs |
There is also an installLinks subpath export for projects that only need the install URL helpers without pulling in the full SDK:
import {
NEUMA_MOBILE_PLAY_STORE_URL,
NEUMA_MOBILE_TESTFLIGHT_URL,
getNeumaMobileInstallUrl,
} from '@vizualkei/sophid-client-sdk/installLinks';Quick start (recommended)
import { neumaClientHelper } from '@vizualkei/sophid-client-sdk';
// Initialize once at app startup
neumaClientHelper.init({
biometricSessionUrl: '/api/biometric-session',
biometricResultUrl: '/api/biometric-results',
fetcher: (input, init) => fetch(input, init),
onQrCode: ({ deepLinkUrl, onCancel }) => showMyQrModal(deepLinkUrl, onCancel),
onAppLaunchFailed: ({ installUrl, message }) => showInstallModal({ installUrl, message }),
});
// Use anywhere
const enrollBrt = await neumaClientHelper.enrollUser(
{ userName: 'Jane Roe', email: '[email protected]', phone: '+886900000000' },
{ email: '[email protected]', phoneNo: '+886900000000' }
);
const authBrt = await neumaClientHelper.authenticateUser();neumaClientHelper.init(...) is idempotent: subsequent calls return the first instance.
NeumaClientHelper
NeumaClientHelper orchestrates the full BST-then-operation-then-BRT flow:
- POST to
biometricSessionUrlto obtain a BST. - Initialize a
SophIDMobileclient with that BST. - Run the requested operation (deep-link or QR).
- POST the resulting BRT to
biometricResultUrl(skipped byauthenticateUserDirect()).
NeumaClientHelperConfig
| Field | Type | Required | Default | Description |
| ---------------------- | ----------------------------------------------------------------------------------------- | -------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------- |
| biometricSessionUrl | string | yes | — | Endpoint that returns { bst } (POST) |
| biometricResultUrl | string | yes | — | Endpoint that receives the BRT (POST) |
| fetcher | (input, init?) => Promise<Response> | yes | — | Custom fetch (inject auth headers here) |
| biometricService | string | no | https://api.neuma.me:443 | SophID server as host, host:port, or https://host[:port]; passed through normalizeBiometricService |
| onQrCode | SophIDMobileOptions['onQrCode'] | no | — | Desktop QR callback |
| onAppLaunchFailed | SophIDMobileOptions['onAppLaunchFailed'] | no | — | Phone-browser deep-link failure callback |
| sdkOptions | Omit<SophIDMobileOptions, 'biometricSessionToken' \| 'onQrCode' \| 'onAppLaunchFailed'> | no | Neuma app naming/install URLs | Extra options forwarded to SophIDMobile.initialize(); override installUrlResolver only for custom app wrappers |
| resultPayloadBuilder | ({ brt, enrollOptions }) => Record<string, unknown> | no | { brt } | Custom shape for the BRT submission body |
biometricService accepts a host, host:port, or https://.... Other schemes, paths, query strings, credentials, and IPv6 literals are rejected.
Methods
All operation methods except retrieveKey/clearUser return the BRT JWT string.
| Method | Signature | Notes |
| ------------------------------------ | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| ensureInitialized() | void | Optional warm-up; primes the underlying SophIDMobile |
| enrollUser(user, enrollOptions?) | (UserDescriptor, EnrollUserOptions?) => Promise<string> | Mints BST → enroll → submit BRT |
| restoreUser() | () => Promise<string> | Mints BST → restore → submit BRT |
| authenticateUser() | () => Promise<string> | Mints BST → authenticate → submit BRT |
| authenticateUserDirect() | () => Promise<string> | Mints BST → authenticate; does not submit the BRT (caller forwards it to a business endpoint) |
| unenrollUser() | () => Promise<string> | Mints BST → unenroll → submit BRT |
| retrieveKey() | () => Promise<null> | Phone-browser only; works offline (no polling). Returns null; user pastes the JSON result back from the mobile app |
| clearUser() | () => Promise<void> | Phone-browser only; works offline (no polling) |
| parseRetrieveKeyResult(jsonString) | (string) => KeyRetrievalResult | Validate and decode the JSON the user pasted from the mobile app |
| handlePendingOperation(options?) | (HandlePendingOperationOptions?) => Promise<PendingOperationResult \| null> | Resume iOS Safari return-flow results, submit recovered BRTs by default, and call app-provided UI/business callbacks |
Singleton wrapper
neumaClientHelper exposes the same surface as a singleton:
neumaClientHelper.init(config); // returns NeumaClientHelper
neumaClientHelper.get(); // throws if not initialized
neumaClientHelper.ensureInitialized();
neumaClientHelper.enrollUser(user, options?);
neumaClientHelper.restoreUser();
neumaClientHelper.authenticateUser();
neumaClientHelper.authenticateUserDirect();
neumaClientHelper.unenrollUser();
neumaClientHelper.retrieveKey();
neumaClientHelper.clearUser();
neumaClientHelper.parseRetrieveKeyResult(jsonString);
neumaClientHelper.handlePendingOperation({
operation: 'authenticate',
onSuccess: result => showResultUi(result),
onError: error => showErrorUi(error),
});
neumaClientHelper.getPendingOperation();
neumaClientHelper.resumePendingResult();
neumaClientHelper.clearPendingResult();Low-level API: SophIDMobile
Use SophIDMobileFactory.create() directly when you need full control of BST fetching and BRT submission.
import { SophIDMobileFactory } from '@vizualkei/sophid-client-sdk';
const client = SophIDMobileFactory.create();
client.initialize({
biometricSessionToken: bstFromYourServer,
timeoutMs: 90_000,
qrAppLinkBase: '/my-app/f',
onQrCode: qrData => showQrModal(qrData),
onAppLaunchFailed: ctx => showInstallGuidance(ctx),
});
const brt = await client.authenticateUser();SophIDMobileOptions
| Field | Type | Default | Description |
| ----------------------- | ------------------------------------------------- | ------------------------------ | ------------------------------------------------- |
| timeoutMs | number | 90000 | Long-poll timeout |
| overlay | { appName?: string } | { appName: 'SophID Mobile' } | App-name string used in AppLaunchFailureContext |
| biometricService | string | https://api.sophid.xyz:443 | SophID server host[:port] or full https URL |
| installUrlResolver | (platform: AppLaunchPlatform) => string \| undefined | — | App-specific install URL resolver used only when phone-browser launch fails |
| qrAppLinkBase | string | ${origin}/f | HTTPS app-link base for desktop QR handoff. Apps mounted below / should pass their mounted handoff route, e.g. /neuma-wellness/f or https://apps.example.com/neuma-wellness/f |
| biometricSessionToken | string | — | BST issued by your server |
| onQrCode | (qrData: QrCodeData) => QrCodeCleanupFn \| void | — | Desktop QR callback |
| onAppLaunchFailed | (context: AppLaunchFailureContext) => void | — | Phone-browser fallback callback |
SophIDMobileInterface methods
| Method | Returns |
| ------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
| initialize(opts) | void |
| getVersion() | Promise<PackageVersion> |
| getEnrolledUser() | Promise<EnrollmentResult \| null> (read from localStorage key sophid_enrolled_user) |
| enrollUser(user) | Promise<string> (BRT) |
| restoreUser() | Promise<string> (BRT) |
| authenticateUser() | Promise<string> (BRT) |
| unenrollUser() | Promise<string> (BRT) |
| retrieveKey() | Promise<null> — phone-browser only |
| clearUser() | Promise<void> — phone-browser only |
| parseRetrieveKeyResult(jsonString) | KeyRetrievalResult |
| getPendingOperation() | PendingOperationInfo \| null (synchronous; reads SDK recovery state) |
| resumePendingResult() | Promise<unknown \| null> (raw biometric server payload, cached payload, or null for no/offline pending) |
| clearPendingResult() | void (clears SDK recovery state after app-level result handling is durable) |
Resuming pending operations
iOS Safari only. The pending-op persistence and the
returnUrlemission that drives it are gated on iOS Safari (navigator.userAgentmatching iOS and not matchingCriOS/FxiOS/EdgiOS/OPiOS/DuckDuckGo/Brave/etc.). On every other browser — Android Chrome, iOS Chrome, iOS Firefox, in-app webviews, desktop QR — the SDK does not write the recovery entries, the deep link does not carryreturnUrl, andgetPendingOperation()normally returnsnull. Those browsers handle the mobile app'sopenURLby opening a new tab, leaving the originating tab (with its in-flight polling) untouched, so resume isn't needed and would be incorrect anyway.
When the SDK fires the sophdplk:// deep-link from the phone-browser flow on iOS Safari it persists pending-operation metadata in sessionStorage under the key __sophid_pending_op__ immediately before navigating, injects sophid_cb, sophid_op, and sophid_ts into the return URL, then long-polls GET /api/biometric-results/{id} for the result. If polling completes before the webapp has durably handled the result, the SDK stores the raw payload under __sophid_completed_result__ for a short recovery window.
For most webapps, use handlePendingOperation() from a page-load effect. It is UI-agnostic: the SDK detects and resumes the returned operation, decodes failed BRT claims into SophIDError, optionally submits successful BRTs to biometricResultUrl, and clears SDK recovery state after your callback completes. Your app decides whether the callback opens a modal, navigates, writes state, shows a toast, or does something else.
useEffect(() => {
let cancelled = false;
void neumaClientHelper
.handlePendingOperation({
operation: 'authenticate',
onStart: () => setLoading(true),
onSuccess: async result => {
// Do durable app-level handling here: persist result state,
// open your modal, navigate, or update your store.
await showAuthResultPopup(result);
},
onError: error => {
showAuthError(error);
},
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);Useful options:
operation: string, string array, or matcher function for SDK operation names such asauthenticate,enroll,restore, orunenroll-user.enrollOptions: metadata included in the recovered BRT submission forenrollflows.submitResult: defaults totrue; setfalsewhen the recovered BRT must be sent to a custom business endpoint instead ofbiometricResultUrl.requireSuccess: defaults totrue; failed BRT claims are routed toonError.onStart,onSuccess,onError: app-provided UI/business callbacks.- Do not skip durable result handling inside
onSuccess/onErrorjust because a component is unmounting; the SDK clears recovery state after the callback resolves.
The lower-level methods remain available for custom integrations:
client.getPendingOperation(): PendingOperationInfo | null;
client.resumePendingResult(): Promise<unknown | null>;
client.clearPendingResult(): void;These methods are also exposed on neumaClientHelper and the neumaClientHelper.init(...) instance.
interface PendingOperationInfo {
callbackId: string; // matches the deep-link `id` and the polling URL path segment
operation: string; // 'enroll' | 'restore' | 'authenticate' | 'unenroll-user' | 'retrieve-key' | 'clear-user'
ts: number; // epoch ms when the deep-link was issued
}Why this exists
On iOS Safari, when the mobile app refocuses the originating browser tab via openURL(returnUrl), the page is not always restored from the back-forward cache. Instead the tab reloads, the React tree remounts, and the in-progress polling promise from the original await neumaClient.authenticateUser() (or the equivalent) is gone — even though the biometric server has the result waiting. Without resume support, the user closes the popup in the mobile app, lands back in the browser, and the webapp UI is stuck in its idle state forever.
resumePendingResult() re-attaches polling to the same callback ID, or returns the cached completed payload if polling already finished in a prior page lifetime. Stale entries (>5 minutes old) are auto-cleared by getPendingOperation() and surfaced as null. A cached completed payload that has already been returned to app code is kept briefly so a second iOS Safari remount during page-level handling can still recover it.
Low-level page-load pattern
useEffect(() => {
const pending = neumaClient.getPendingOperation();
if (!pending || pending.operation !== 'authenticate') return;
let cancelled = false;
setLoading(true); // same UI state as a foreground call
(async () => {
try {
const payload = await neumaClient.resumePendingResult();
if (cancelled || payload === null) return;
// Replicate whatever your foreground success handler does:
// decode the BRT, refresh the session, show the success popup, etc.
await onAuthSuccess(payload);
neumaClient.clearPendingResult();
} catch (err) {
if (cancelled) return;
// SophIDError code === 'USER_CANCELLED' is a no-op; otherwise show the
// same error UI a foreground failure would.
onAuthError(err);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);Notes:
- Prefer
handlePendingOperation()unless you specifically need to own polling, BRT submission, and cleanup yourself. getPendingOperation()is synchronous; safe to call during render or in a layout effect.resumePendingResult()clears pending metadata, but may keep a completed-result payload briefly. CallclearPendingResult()after your page has durably handled the payload (for example after persisting or showing your result popup).- The pending operation name reflects the SDK-internal op (
authenticate,enroll, etc.), not your page-level intent. If the same SDK op (e.g.authenticate) is invoked from multiple page contexts (e.g.authenticateUser()on a sign-in page vsauthenticateUserDirect()on a password-update page), each page should only resume when thepending.operationmatches its own intent. - The desktop QR flow (
invokeMobileAppViaQr) does not persist pending state — the desktop tab stays alive while the phone is doing its biometric step, so resume is unnecessary. - Non-iOS-Safari mobile browsers (Android Chrome, iOS Chrome / Firefox / Edge / Opera / DuckDuckGo / Brave, in-app webviews, …) also do not persist pending state. The mobile app's
openURLopens a new tab on those browsers, the originating tab and its polling promise stay alive, and the foregroundawait neumaClient.authenticateUser()(or equivalent) returns normally when the user manually switches back. CallinggetPendingOperation()on these browsers is safe but always returnsnull. - The offline operations
retrieve-keyandclear-userdo not write a server-side result;resumePendingResult()returnsnullfor them after clearing the entry.
Phone vs desktop
Detection is user-agent based (isDesktopBrowser() returns false if any of android, webos, iphone, ipad, ipod, blackberry, iemobile, opera mini, mobile, tablet, touch is present in the UA).
| Aspect | Phone browser | Desktop browser (with onQrCode) |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| Invocation | sophdplk://operation?operation=...&id=...&bioServer=...&webServer=...&...&returnUrl=... via window.location.href | HTTPS app-link ${qrAppLinkBase}/{opCode}/{callbackId}?b=...&w=...&... rendered as a QR code |
| Polling | GET {biometricService}/api/biometric-results/{callbackId} (5 retries, 2s delay, 90s default budget) | Same long-poll endpoint |
| Launch failure detection | 2-second visibility heuristic; if the page stays visible the SDK fires onAppLaunchFailed | Not applicable |
| retrieveKey | Supported (offline, returns null; user pastes JSON) | Throws SophIDError('This operation is supported only on mobile browsers', 'AUTH_FAILED') |
| clearUser | Supported (offline) | Throws SophIDError('This operation is supported only on mobile browsers', 'AUTH_FAILED') |
| Cancellation | Built-in error mapping (USER_CANCELLED) | qrData.onCancel() rejects the in-flight operation |
The QR app-link op codes are: enroll → e, authenticate → a, clear-user → c, unenroll-user → u, retrieve-key → r, restore → rs. Unknown operations fall back to the first character.
For root-mounted apps, the default QR app-link base is ${window.location.origin}/f.
Apps mounted below a path must configure qrAppLinkBase so scanned QR codes
land back inside that app's handoff route. For example:
client.initialize({
qrAppLinkBase: '/neuma-wellness/f',
onQrCode: ({ deepLinkUrl, onCancel }) => showQrModal(deepLinkUrl, onCancel),
});qrAppLinkBase may be a full HTTPS URL or a root-relative path. Query strings
and fragments are ignored because the SDK appends the handoff operation,
callback ID, and biometric query parameters itself.
Deep-link query parameters
| Param | Description |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| operation | Operation name (enroll, authenticate, restore, unenroll-user, retrieve-key, clear-user). |
| id | Callback ID (random UUID-like string). The mobile app posts its result to {biometricService}/api/biometric-results/{id}; the SDK long-polls the same endpoint. |
| bioServer | Biometric service host[:port] or full https URL. |
| webServer | Web app host (window.location.host). |
| userName, email, phone | Optional user descriptor fields. |
| bst | Biometric session token, when supplied. |
| returnUrl | URL-encoded https:// URL for the originating browser tab. The mobile app launches this URL after the user closes the result popup so the originating tab is foregrounded. The SDK builds it by injecting sophid_cb=<id>, sophid_op=<operation>, and sophid_ts=<issuedAtMs> into the current page URL via history.replaceState(history.state, '', urlWithMarkers) (preserving app state) and then capturing window.location.href. sophid_cb disambiguates the originating tab when multiple tabs are open to the same page; sophid_op and sophid_ts let the SDK recover from a Safari remount even if storage state was already cleared. The SDK omits returnUrl only when the page is not loaded over https:// (e.g. localhost dev). The mobile app validates that returnUrl is https:// and launches it byte-for-byte — callers must not mutate it. |
QR-code flow contract
interface QrCodeData {
deepLinkUrl: string; // HTTPS app link to encode in the QR code
callbackId: string; // Polling callback ID
onCancel: () => void; // Call when the user dismisses the modal
}
type QrCodeCleanupFn = () => void;onQrCode may return a QrCodeCleanupFn that the SDK calls automatically when the operation completes (success, failure, or cancellation).
App-launch failure contract
type AppLaunchPlatform = 'android' | 'ios' | 'unknown';
interface AppLaunchFailureContext {
operation: string; // 'enroll' | 'authenticate' | 'restore' | 'unenroll-user' | 'retrieve-key' | 'clear-user'
platform: AppLaunchPlatform;
appName: string; // from overlay.appName
installUrl?: string; // present for Android (Google Play) and iOS (TestFlight)
message: string; // human-readable suggestion
}SophIDMobile itself does not choose an app-store destination. NeumaClientHelper configures installUrlResolver with getNeumaMobileInstallUrl, so Neuma launch failures receive NEUMA_MOBILE_PLAY_STORE_URL on Android and NEUMA_MOBILE_TESTFLIGHT_URL on iOS. Other branded helper packages should provide their own resolver at this layer. The legacy SOPHID_* exports alias the Neuma URLs for compatibility.
Error model
class SophIDError extends Error {
readonly code:
| 'USER_EXISTS'
| 'AUTH_FAILED'
| 'USER_NOT_FOUND'
| 'INVALID_BIOMETRIC'
| 'USER_CANCELLED'
| 'PARSE_ERROR';
}Mapping per operation when a failure or cancellation is detected:
| Operation | Default error code |
| --------------------------------------------- | -------------------------------------------------------------------- |
| enrollUser | USER_EXISTS |
| restoreUser | AUTH_FAILED |
| authenticateUser / authenticateUserDirect | AUTH_FAILED |
| unenrollUser | USER_NOT_FOUND |
| clearUser | USER_NOT_FOUND |
| retrieveKey (desktop) | AUTH_FAILED |
| parseRetrieveKeyResult | AUTH_FAILED for failure payloads, PARSE_ERROR for malformed JSON |
| any | USER_CANCELLED if the underlying payload signals cancellation |
The SDK considers a payload cancelled when any of cancelled === true, reason ∈ {back, cancel, user_cancelled}, code ∈ {CANCELLED, USER_CANCELLED}, or an error message containing cancel/canceled/cancelled is present.
Type reference
interface UserDescriptor {
readonly userName: string;
readonly email?: string;
readonly phone?: string;
}
interface EnrollmentResult {
readonly userId: string;
readonly enrollmentId: string;
readonly enrolledAt: Date;
readonly userDescriptor: UserDescriptor;
}
interface AuthenticationResult {
readonly userId: string;
readonly userDescriptor: UserDescriptor;
readonly authenticatedAt: Date;
}
interface KeyRetrievalResult {
readonly key: string;
readonly userId: string;
readonly userDescriptor: UserDescriptor;
}
interface PackageVersion {
readonly major: number;
readonly minor: number;
readonly patch: number;
readonly build: number;
}
type EnrollUserOptions = {
email?: string | null;
phoneNo?: string | null;
};End-to-end example
import { neumaClientHelper, SophIDError } from '@vizualkei/sophid-client-sdk';
neumaClientHelper.init({
biometricSessionUrl: '/api/biometric-session',
biometricResultUrl: '/api/biometric-results',
fetcher: (input, init) => fetch(input, init),
resultPayloadBuilder: ({ brt, enrollOptions }) => ({
brt,
email: enrollOptions?.email ?? null,
phoneNo: enrollOptions?.phoneNo ?? null,
}),
onQrCode: ({ deepLinkUrl, onCancel }) => showQrModal(deepLinkUrl, onCancel),
onAppLaunchFailed: ({ installUrl, message }) => showInstallModal({ installUrl, message }),
});
try {
const brt = await neumaClientHelper.authenticateUser();
// BRT was already submitted to /api/biometric-results by the helper
console.log('Authenticated; BRT length =', brt.length);
} catch (err) {
if (err instanceof SophIDError && err.code === 'USER_CANCELLED') {
// user backed out of the flow
} else {
throw err;
}
}For business endpoints that consume the BRT directly (e.g. biometric-gated password change), use authenticateUserDirect() and POST the BRT yourself.
License
Apache-2.0
