Reference
Web SDK
The @mobilesigner/web-sdk package: exports, methods, lifecycle events and error codes.
@mobilesigner/web-sdk is a real npm workspace package at packages/web-sdk, declared in the root package.json workspaces and consumed by the app as a dependency at version 1.0.0. It is built before every dev run, type-check and production build:
"sdk:build": "npm run build -w @mobilesigner/web-sdk",
"predev": "npm run sdk:build",
"prebuild": "npm run sdk:build",
"typecheck": "npm run sdk:build && tsc --noEmit"
Four source files, no runtime dependencies. It wraps the browser platform and the Quarkus ceremony client; it does not ship its own WebAuthn implementation.
Exports
From src/index.ts:
| Export | Kind |
|---|---|
MobileSignerWebSdk |
class |
MobileSignerError |
error class |
MobileSignerErrorCode |
type |
sha256 |
function |
installBridge, uninstallBridge |
functions |
BRIDGE_VERSION |
constant |
AuthenticationRequest, Capabilities, FileActionKind, RegistrationRequest, SdkEvent, SdkEventListener, SdkEventName, Session, SessionCredential, SignFileRequest, SignFileResult |
types |
BridgeSignRequest, BridgeSignResult, BridgeFailure, InstallBridgeOptions, MobileSignerBridge |
bridge types |
Construction
import { MobileSignerWebSdk } from '@mobilesigner/web-sdk';
const sdk = new MobileSignerWebSdk();
Both options are optional:
| Option | Default | Purpose |
|---|---|---|
apiBase |
'/backend' |
prefix for platform API calls |
fetch |
globalThis.fetch bound to globalThis |
injection point for tests |
The '/backend' default matches the Next.js rewrite that maps /backend/* to the backend's /api/*. Change it only if you are proxying differently.
In React, construct it once:
const sdk = useMemo(() => new MobileSignerWebSdk(), []);
Constructing per render would discard the listener set on every render. All three components that use the SDK — file-detail-view.tsx, officer-chip.tsx and token-status.tsx — follow this.
Methods
Seven in total: subscribe is synchronous, the rest return promises.
subscribe(listener)
const unsubscribe = sdk.subscribe(event => console.log(event.name, event.detail));
Registers a lifecycle listener and returns its own unsubscribe function. Listeners are held in a Set, so the same function reference registers once. Use the returned function as a useEffect cleanup.
getCapabilities()
const caps = await sdk.getCapabilities();
Returns Capabilities:
| Field | Determined by |
|---|---|
secureContext |
window.isSecureContext |
webAuthn |
typeof PublicKeyCredential !== 'undefined' |
frameworkClient |
typeof window.WebAuthn === 'function' |
platformAuthenticator |
PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable() |
All four are false during server rendering, since each is guarded on typeof window !== 'undefined'. Call it from an effect, not during render.
platformAuthenticator distinguishes a built-in authenticator (Touch ID, Windows Hello, an Android screen lock) from a cross-platform one (a USB key). The backend requests cross-platform via quarkus.webauthn.authenticator-attachment, so this flag is informational rather than gating.
The availability check is wrapped in .catch(() => false), because some browsers reject rather than resolve false.
Emits capabilities:checked with detail WebAuthn ready when both webAuthn and frameworkClient are true, otherwise WebAuthn unavailable. Note the detail ignores secureContext, so a page served over plain HTTP on a non-localhost host reports "ready" and then fails at the ceremony.
registerCredential(request)
const session = await sdk.registerCredential({
username: 'demo.user',
displayName: 'Demo Officer'
});
Runs the WebAuthn registration ceremony. username is trimmed; displayName falls back to the trimmed username when absent or blank.
Resolves with the Session read back from GET /session — the SDK does not trust the ceremony's own return value, it asks the server who it thinks you are. That is why the returned session is a reliable signal.
Enrolling replaces any previous credential for that username. See Data model for why, and what it costs.
authenticate(request)
const session = await sdk.authenticate({ username: 'demo.user' });
Runs the assertion ceremony. Same shape as registerCredential, same server round-trip afterwards.
Both delegate to a shared private ceremony() method, which is why their event and error behaviour is identical:
private async ceremony(kind, action): Promise<Session> {
this.emit(`${kind}:started`);
try {
await action(this.frameworkClient());
this.emit(`${kind}:completed`);
return this.getSession();
} catch (error) {
const failure = toMobileSignerError(error);
this.emit('operation:failed', failure.code);
throw failure;
}
}
A successful ceremony therefore emits three events: …:started, …:completed, then session:loaded.
getSession()
const { authenticated, username } = await sdk.getSession();
GET {apiBase}/session. Does not touch the authenticator. Emits session:loaded with the username, or the literal 'anonymous'.
signFile(request)
The primary operation, and the one the workstation uses.
const result = await sdk.signFile<FileDetail>({
fileId: 3,
action: 'APPROVE', // or 'REJECT' | 'RETURN'
note: 'Sanction accorded for the Q2 instalment.',
document: canonicalDocument(file)
});
For APPROVE it computes the SHA-256 digest of document in the browser (emitting document:hashed), then posts {action, note, documentHash} to POST {apiBase}/files/{id}/action. For REJECT and RETURN it sends no digest and requires a note, matching what the backend enforces.
Resolves with the refreshed file plus the measured cost of each stage:
| Field | Meaning |
|---|---|
file |
the full FileDetail returned by the backend, typed by the generic parameter |
action |
the normalised action that was recorded |
documentHash |
the digest for APPROVE, null otherwise |
hashMs |
time spent in crypto.subtle.digest |
requestMs |
time spent in the bind-and-record round trip |
durationMs |
total wall-clock cost |
Timings come from performance.now() where available, so a clock adjustment mid-ceremony cannot corrupt them. The file detail screen renders all three after a signature, which is what makes the sub-three-second claim a measurement rather than an assertion.
Validation happens client-side before the request goes out — a missing digest on APPROVE, a digest that is not 64 hex characters, or a blank note on REJECT/RETURN all throw MobileSignerError with code API_ERROR rather than making a round trip to be rejected.
Emits document:hashed (on approve) and then signature:recorded with the action and rounded duration.
logout()
await sdk.logout();
Fetches /q/webauthn/logout with credentials: 'same-origin' and emits session:ended.
This is the one method that ignores apiBase — the logout endpoint belongs to the Quarkus WebAuthn extension, not the platform API, and is reached through the /q/webauthn/* rewrite which preserves its path. It also does not check response.ok, so a failed logout resolves silently and emits session:ended regardless.
Lifecycle events
Ten event names, typed as a union so a switch over them is exhaustively checked:
| Event | Emitted when | Detail |
|---|---|---|
capabilities:checked |
getCapabilities() completes |
WebAuthn ready or WebAuthn unavailable |
registration:started |
registration ceremony begins | — |
registration:completed |
registration ceremony succeeds | — |
authentication:started |
assertion ceremony begins | — |
authentication:completed |
assertion ceremony succeeds | — |
document:hashed |
SHA-256 digest computed in signFile() |
the digest |
signature:recorded |
signFile() succeeds |
action and rounded duration, e.g. APPROVE · 84 ms |
session:loaded |
getSession() resolves |
username or anonymous |
session:ended |
logout() completes |
— |
operation:failed |
any ceremony throws | the MobileSignerErrorCode |
Every event carries a timestamp:
type SdkEvent = { name: SdkEventName; at: string; detail?: string };
Every event carries both forms of time. at is new Date().toLocaleTimeString(), a locale-formatted display string for the trace panel; timestamp is epoch milliseconds, for anything that needs to sort or correlate. Use timestamp in code and at only for rendering.
Every event fires in normal use. A successful signature emits document:hashed then signature:recorded; a ceremony emits registration:started or authentication:started, the matching :completed, then session:loaded.
The events are the mechanism behind the live SDK trace panel on /files/[id], which exists to make the point that the ceremony is genuinely running rather than being narrated.
Errors
Every rejection is a MobileSignerError with a code field. Five codes:
| Code | Cause |
|---|---|
UNSUPPORTED_BROWSER |
no window, or PublicKeyCredential undefined |
SDK_NOT_LOADED |
window.WebAuthn missing — webauthn.js did not load |
CEREMONY_CANCELLED |
a DOMException named NotAllowedError — user cancelled or timed out |
AUTHENTICATION_REQUIRED |
the API responded 401 |
API_ERROR |
any other non-2xx response, or an unrecognised throwable |
Normalisation happens in one place:
export function toMobileSignerError(error: unknown): MobileSignerError {
if (error instanceof MobileSignerError) return error;
if (error instanceof DOMException && error.name === 'NotAllowedError') {
return new MobileSignerError('CEREMONY_CANCELLED',
'The security-key request was cancelled or timed out.', { cause: error });
}
return new MobileSignerError('API_ERROR',
error instanceof Error ? error.message : 'MobileSigner operation failed', { cause: error });
}
The original throwable is always preserved as cause, so nothing is lost.
CEREMONY_CANCELLED matters for usability: WebAuthn deliberately does not distinguish "the user declined" from "the timeout elapsed" from "no matching credential on this authenticator", to avoid leaking whether a credential exists. All three arrive as NotAllowedError, so the message has to cover all three. It is the code you will hit most often in real use, usually because the officer did not touch the key in time.
SDK_NOT_LOADED is the one that points at a deployment problem rather than a user action — it means the /q/webauthn/* rewrite is not reaching the backend.
Server errors keep the backend's message when there is one:
const body = await response.json().catch(() => ({})) as { message?: string };
const code = response.status === 401 ? 'AUTHENTICATION_REQUIRED' : 'API_ERROR';
throw new MobileSignerError(code, body.message ?? `MobileSigner API request failed (${response.status})`);
Because every backend ErrorResponse is {message}, this surfaces the real validation text — "A valid SHA-256 document hash is required to sign" reaches the officer rather than a generic failure. The HTTP status itself is not retained on the error object, so a caller distinguishes AUTHENTICATION_REQUIRED from API_ERROR and reads the message for anything finer.
sha256(document)
import { sha256 } from '@mobilesigner/web-sdk';
const digest = await sha256('the document text'); // 64 lowercase hex characters
Accepts string | Uint8Array. Strings are UTF-8 encoded with TextEncoder.
export async function sha256(document: string | Uint8Array): Promise<string> {
const source = typeof document === 'string' ? new TextEncoder().encode(document) : document;
const bytes = new Uint8Array(new ArrayBuffer(source.byteLength));
bytes.set(source);
const digest = await crypto.subtle.digest('SHA-256', bytes);
return Array.from(new Uint8Array(digest), value => value.toString(16).padStart(2, '0')).join('');
}
The copy into a fresh ArrayBuffer is deliberate. A Uint8Array can be a view onto a larger or shared buffer, and crypto.subtle.digest hashes the whole buffer rather than the view — so passing a subarray directly would digest the wrong bytes. Copying guarantees the digest covers exactly the view.
Output is lower-case hex, which is what both FileResource and VerifyResource normalise to before comparing.
crypto.subtle requires a secure context. On http://localhost it is available; on any other plain-HTTP origin crypto.subtle is undefined and this throws a TypeError that surfaces as API_ERROR.
The framework client
The SDK never calls navigator.credentials itself:
private frameworkClient(): FrameworkWebAuthnClient {
if (typeof window === 'undefined' || typeof PublicKeyCredential === 'undefined') {
throw new MobileSignerError('UNSUPPORTED_BROWSER', 'This browser does not support WebAuthn.');
}
if (!window.WebAuthn) {
throw new MobileSignerError('SDK_NOT_LOADED', 'The Quarkus WebAuthn ceremony client did not load.');
}
return new window.WebAuthn();
}
window.WebAuthn is defined by /q/webauthn/webauthn.js, served by the Quarkus extension and injected in layout.tsx:
<Script src="/q/webauthn/webauthn.js" strategy="beforeInteractive" />
beforeInteractive is required — the script must exist before any component effect runs, or the first getCapabilities() reports frameworkClient: false.
Delegating rather than reimplementing means challenge encoding, attestation parsing and the CBOR handling always match the backend extension that generated the challenge. The cost is a hard runtime coupling: the SDK cannot be used against a non-Quarkus relying party without replacing frameworkClient(). The FrameworkWebAuthnClient interface is the seam where that substitution would happen.
export interface FrameworkWebAuthnClient {
register(input: { username: string; displayName: string }): Promise<unknown>;
login(input: { username: string }): Promise<unknown>;
}
Both methods return Promise<unknown> because the SDK discards the result and re-reads the session instead.
The window.MobileSigner bridge
A host application — NIC eOffice, a departmental portal, any page inside a WebView — cannot import an npm package. It needs a global. installBridge() provides one:
import { installBridge } from '@mobilesigner/web-sdk';
installBridge({ sdk }); // reuse an existing instance, or omit to construct one
The workstation calls this on the file detail screen, sharing its own SDK instance so bridge-initiated activity shows up in the same trace panel.
From then on a plain script can drive the whole ceremony:
const signer = window.MobileSigner;
if (!(await signer.isAvailable())) return showFallback();
await signer.authenticate('demo.user');
const result = await signer.sign({
fileId: 3,
action: 'APPROVE',
note: 'Sanction accorded.',
document: documentText
});
if (result.ok) {
result.documentHash; // bound to the officer
result.timings; // { hashMs, requestMs, totalMs }
result.file; // refreshed file including the new noting
} else {
result.code; // CEREMONY_CANCELLED, AUTHENTICATION_REQUIRED, …
}
Three deliberate choices:
sign()resolves rather than rejects. Host pages are often plain scripts with notry/catcharound a promise, where a rejection surfaces as an unhandled error. A{ok: false, code, message}result is harder to ignore by accident.installBridge()is idempotent. Calling it twice keeps the first bridge unlessforceis set, so two components mounting the SDK cannot clobber each other's listeners.- The bridge adds no cryptography. Every method delegates to the SDK, which delegates the ceremony to the Quarkus client, so the bridge cannot drift from the relying party that issued the challenge.
isAvailable() is the one to call first: it returns true only when the context is secure, WebAuthn is present and the ceremony client loaded, which is exactly the set of conditions a ceremony needs.
What the SDK does not do
Worth stating plainly, so the surface is not mistaken for a larger one:
- Browser only. There is no Android or iOS package — browser-native WebAuthn covers the mobile case, which is the whole point of the approach. Chrome and Safari both implement it, so a phone needs no native code.
- No PKCS#7 or CMS construction. The SDK produces a SHA-256 digest; the "signature" is the WebAuthn assertion binding an identity to that digest. See Security model.
- No retry, no timeout of its own, no offline queue. A ceremony that fails, fails.