@touchque/web 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/CHANGELOG.md ADDED
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file. The
4
+ format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
+
6
+ ## [0.1.0] — 2026-08-27
7
+
8
+ Initial release.
9
+
10
+ ### Added
11
+ - `createTouchQueWeb({ baseUrl, paths?, credentials?, headers?, fetch? })` —
12
+ a browser client that runs WebAuthn ceremonies and posts the JSON to the
13
+ partner's own relay backend (never TouchQue directly).
14
+ - `passkeys.register()`, `passkeys.authenticate({ email })`,
15
+ `passkeys.approveLogin({ requestId })`, `passkeys.list()`,
16
+ `passkeys.remove(id)`.
17
+ - `authenticate()` classifies the flow-control outcomes (`ok` /
18
+ `requiresStepUp` / `pending2fa`) and returns the relay's untouched body as
19
+ `raw`.
20
+ - Typed errors: `PasskeyDismissedError`, `PasskeyNotRegisteredError`,
21
+ `PasskeyDisabledError`, `TouchQueWebAPIError`, `TouchQueWebError`.
22
+ - `isPasskeySupported()` / `isPlatformAuthenticatorAvailable()`.
23
+ - Behavioral biometrics widget: `tq.behavioral.attach(...)` /
24
+ `attachBehavioral(...)` (container-scoped mouse / click / keystroke-timing
25
+ telemetry). Also shipped as a standalone `<script>` build
26
+ (`dist/touchque-behavioral.global.js`, `window.TouchQueBehavioral`),
27
+ superseding the private `@touchque/behavioral-widget` package.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TouchQue
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,191 @@
1
+ # @touchque/web
2
+
3
+ Browser SDK for [TouchQue Authenticator](https://touchque.com) — passkey
4
+ sign-in / registration and behavioral telemetry.
5
+
6
+ It runs the WebAuthn ceremonies in the browser and posts the collected JSON
7
+ to **your own backend**, which relays it to TouchQue with its API key. The
8
+ browser never holds the API key and never calls TouchQue's `/webauthn/*`
9
+ endpoints directly.
10
+
11
+ ```bash
12
+ npm install @touchque/web
13
+ ```
14
+
15
+ ## The relay pattern
16
+
17
+ `@touchque/web` talks only to your backend (`baseUrl`). Your backend forwards
18
+ each call to the TouchQue Authenticator API using the server SDK
19
+ [`@touchque/node`](https://www.npmjs.com/package/@touchque/node) (or a signed
20
+ HTTP call). You expose these routes — the paths are configurable, these are
21
+ the defaults:
22
+
23
+ | SDK method | Default browser path (`baseUrl` + …) | Relays to (`@touchque/node`) |
24
+ |---|---|---|
25
+ | `passkeys.register()` step 1 | `POST /passkey/register/options` | `tq.webauthn.registerOptions({ externalUsername, discoverable: true })` |
26
+ | `passkeys.register()` step 2 | `POST /passkey/register/verify` | `tq.webauthn.registerVerify({ externalUsername, response, label })` |
27
+ | `passkeys.authenticate()` step 1 | `POST /passkey/authenticate/options` | `tq.webauthn.primaryOptions({ externalUsername: body.email })` |
28
+ | `passkeys.authenticate()` step 2 | `POST /passkey/authenticate/verify` | `tq.webauthn.primaryVerify({ attemptId, response })` |
29
+ | `passkeys.approveLogin()` step 1 | `POST /passkey/login/options` | `tq.webauthn.authenticateOptions({ requestId })` |
30
+ | `passkeys.approveLogin()` step 2 | `POST /passkey/login/verify` | `tq.webauthn.authenticateVerify({ requestId, response })` |
31
+ | `passkeys.list()` | `GET /passkey/credentials` | `tq.webauthn.listCredentials({ externalUsername })` |
32
+ | `passkeys.remove(id)` | `DELETE /passkey/credentials/:id` | `tq.webauthn.deleteCredential(id)` |
33
+
34
+ Your backend is responsible for authenticating the browser (a session cookie,
35
+ a bearer token, …) and for deriving `externalUsername` from that session — the
36
+ SDK never sends it for `register` / `list` / `remove`.
37
+
38
+ ### Minimal Express relay
39
+
40
+ ```js
41
+ const express = require('express');
42
+ const { TouchQue } = require('@touchque/node');
43
+
44
+ const tq = new TouchQue({ apiKey: process.env.TQ_API_KEY, apiSecret: process.env.TQ_API_SECRET });
45
+ const app = express();
46
+ app.use(express.json());
47
+
48
+ // your own auth middleware sets req.user
49
+ app.post('/passkey/authenticate/options', async (req, res) => {
50
+ try {
51
+ res.json(await tq.webauthn.primaryOptions({ externalUsername: req.body.email }));
52
+ } catch (e) {
53
+ res.status(e.status || 502).json({ error: e.data?.error || 'failed' });
54
+ }
55
+ });
56
+ app.post('/passkey/authenticate/verify', async (req, res) => {
57
+ const r = await tq.webauthn.primaryVerify({ attemptId: req.body.attemptId, response: req.body.response });
58
+ if (r.success) {
59
+ // issue your session here, then:
60
+ res.json({ status: 'success', redirect_url: '/dashboard' });
61
+ } else if (r.requiresStepUp) {
62
+ res.json({ requiresStepUp: true });
63
+ } else {
64
+ res.status(401).json({ error: 'verification_failed' });
65
+ }
66
+ });
67
+
68
+ app.post('/passkey/register/options', requireLogin, async (req, res) => {
69
+ res.json(await tq.webauthn.registerOptions({ externalUsername: req.user.email, discoverable: true }));
70
+ });
71
+ app.post('/passkey/register/verify', requireLogin, async (req, res) => {
72
+ res.json(await tq.webauthn.registerVerify({
73
+ externalUsername: req.user.email, response: req.body.response, label: req.body.label,
74
+ }));
75
+ });
76
+ app.get('/passkey/credentials', requireLogin, async (req, res) => {
77
+ res.json(await tq.webauthn.listCredentials({ externalUsername: req.user.email }));
78
+ });
79
+ app.delete('/passkey/credentials/:id', requireLogin, async (req, res) => {
80
+ res.json(await tq.webauthn.deleteCredential(req.params.id));
81
+ });
82
+ ```
83
+
84
+ ## Browser usage
85
+
86
+ ```ts
87
+ import { createTouchQueWeb, PasskeyNotRegisteredError } from '@touchque/web';
88
+
89
+ const tq = createTouchQueWeb({
90
+ baseUrl: 'https://api.example.com',
91
+ // send cookies if your relay authenticates the browser with a session cookie
92
+ credentials: 'include',
93
+ });
94
+ ```
95
+
96
+ ### Passwordless sign-in
97
+
98
+ ```ts
99
+ try {
100
+ const result = await tq.passkeys.authenticate({
101
+ email,
102
+ context: { client_id, redirect_uri }, // merged into both relay calls
103
+ });
104
+
105
+ if (result.ok) {
106
+ // signed in — result.raw is your relay's untouched response
107
+ window.location.href = result.raw.redirect_url as string;
108
+ } else if (result.requiresStepUp) {
109
+ startPushOrNumberMatchFlow();
110
+ } else if (result.pending2fa) {
111
+ continuePending2faFlow(result.raw);
112
+ }
113
+ } catch (err) {
114
+ if (err instanceof PasskeyNotRegisteredError) {
115
+ showPasswordForm(); // this account has no passkey
116
+ }
117
+ // ... see the error table below
118
+ }
119
+ ```
120
+
121
+ ### Register a passkey (signed-in user)
122
+
123
+ ```ts
124
+ await tq.passkeys.register({ label: 'MacBook Touch ID' });
125
+ const passkeys = await tq.passkeys.list();
126
+ await tq.passkeys.remove(passkeys[0].id);
127
+ ```
128
+
129
+ ### Approve a pending login with a passkey (second factor)
130
+
131
+ ```ts
132
+ await tq.passkeys.approveLogin({ requestId });
133
+ ```
134
+
135
+ ### Feature detection
136
+
137
+ ```ts
138
+ import { isPasskeySupported, isPlatformAuthenticatorAvailable } from '@touchque/web';
139
+
140
+ if (!isPasskeySupported()) hidePasskeyButton();
141
+ if (await isPlatformAuthenticatorAvailable()) preferPlatformUx();
142
+ ```
143
+
144
+ ## Behavioral biometrics widget
145
+
146
+ This is the one call that goes **directly** from the browser to TouchQue,
147
+ gated by a per-request `telemetryToken` your backend mints server-side
148
+ (`POST /login/request` with `TenantPolicy.behavioralBiometricsEnabled`).
149
+
150
+ ```ts
151
+ const widget = tq.behavioral.attach('#tq-2fa-box', {
152
+ telemetryToken: SERVER.telemetryToken,
153
+ requestId: SERVER.requestId,
154
+ apiBaseUrl: 'https://api-authenticator.touchque.com',
155
+ });
156
+
157
+ // call periodically and/or right before the challenge resolves
158
+ await widget.submit();
159
+ // on unmount
160
+ widget.stop();
161
+ ```
162
+
163
+ Or, without a bundler, via `<script>`:
164
+
165
+ ```html
166
+ <script src="https://unpkg.com/@touchque/web/dist/touchque-behavioral.global.js"></script>
167
+ <script>
168
+ const widget = TouchQueBehavioral.attach('#tq-2fa-box', { telemetryToken, requestId, apiBaseUrl });
169
+ </script>
170
+ ```
171
+
172
+ The widget only observes the container element (mouse movement, click count,
173
+ inter-keystroke timing) — never `window`/`document`, and never a key or
174
+ character. A failed telemetry POST is swallowed and never surfaces on your
175
+ 2FA flow.
176
+
177
+ ## Errors
178
+
179
+ | Class | When | Typical handling |
180
+ |---|---|---|
181
+ | `PasskeyDismissedError` | User closed the system prompt (`NotAllowedError` / `AbortError`) | Let them retry or use a password |
182
+ | `PasskeyNotRegisteredError` | Relay returned 404 / `no_passkey_registered` | Fall back to the password form |
183
+ | `PasskeyDisabledError` | Relay returned 403 / `passwordless_login_disabled` | Fall back to the password form |
184
+ | `TouchQueWebAPIError` | Any other non-2xx from the relay (`.status`, `.body`) | Show a generic error |
185
+ | `TouchQueWebError` | Base class / an unexpected ceremony failure | Show a generic error |
186
+
187
+ `requiresStepUp` and `pending2fa` are **not** errors — they come back as
188
+ fields on the `authenticate()` result.
189
+
190
+ ## License
191
+ MIT
@@ -0,0 +1,228 @@
1
+ /**
2
+ * The relay endpoint paths on the *partner's own backend*. The browser SDK
3
+ * never talks to TouchQue directly for passkey ceremonies — the partner
4
+ * backend holds the API key and relays to TouchQue.
5
+ *
6
+ * Every path is appended to `baseUrl`. Override any subset; the rest keep
7
+ * their defaults.
8
+ */
9
+ interface TouchQuePaths {
10
+ /** POST — returns `PublicKeyCredentialCreationOptionsJSON` (at the top level). */
11
+ registerOptions: string;
12
+ /** POST — body `{ response, ...extra }`; returns `{ verified, credentialId }`. */
13
+ registerVerify: string;
14
+ /** POST — body `{ email, ...context }`; returns `{ attemptId, options }`. */
15
+ authenticateOptions: string;
16
+ /** POST — body `{ attemptId, response, ...context }`; returns the relay's passthrough. */
17
+ authenticateVerify: string;
18
+ /** POST — body `{ requestId }`; returns `PublicKeyCredentialRequestOptionsJSON`. */
19
+ approveOptions: string;
20
+ /** POST — body `{ requestId, response }`; returns `{ success }`. */
21
+ approveVerify: string;
22
+ /** GET — returns `{ credentials: PasskeySummary[] }`. */
23
+ list: string;
24
+ /** DELETE `${remove}/${id}` — returns `{ deleted: true }`. */
25
+ remove: string;
26
+ }
27
+ interface TouchQueWebConfig {
28
+ /** Base URL of the partner's own relay backend, e.g. `https://api.example.com`. */
29
+ baseUrl: string;
30
+ /** Override any subset of the relay paths. */
31
+ paths?: Partial<TouchQuePaths>;
32
+ /**
33
+ * Whether the SDK's relay requests send credentials (cookies). Use
34
+ * `'include'` when the relay authenticates the browser with a cookie
35
+ * session. Default: `'same-origin'`.
36
+ */
37
+ credentials?: RequestCredentials;
38
+ /** Extra headers to attach to every relay request (e.g. a CSRF token). */
39
+ headers?: Record<string, string>;
40
+ /** Injectable fetch implementation. Defaults to the global `fetch`. */
41
+ fetch?: typeof fetch;
42
+ }
43
+ interface PasskeySummary {
44
+ id: string;
45
+ credentialId: string;
46
+ deviceType: string | null;
47
+ backedUp: boolean;
48
+ label: string | null;
49
+ createdAt: string;
50
+ lastUsedAt: string | null;
51
+ }
52
+ interface RegisterResult {
53
+ verified: boolean;
54
+ credentialId: string;
55
+ [key: string]: unknown;
56
+ }
57
+ /**
58
+ * Result of `passkeys.authenticate()`. The SDK does not interpret a
59
+ * successful passwordless login for you — `raw` is whatever the relay
60
+ * returned (a redirect URL, a session, etc.). The SDK only classifies the
61
+ * three flow-control outcomes.
62
+ */
63
+ interface AuthenticateResult {
64
+ /** `true` when the assertion verified and no step-up is required. */
65
+ ok: boolean;
66
+ /**
67
+ * `true` when risk/policy demands a second factor. The caller should start
68
+ * the normal push / number-match flow instead of trusting the assertion.
69
+ */
70
+ requiresStepUp: boolean;
71
+ /** `true` when the relay wants the caller to continue a pending 2FA flow. */
72
+ pending2fa: boolean;
73
+ /** The relay's untouched response body. */
74
+ raw: Record<string, unknown>;
75
+ }
76
+ interface ApproveResult {
77
+ success: boolean;
78
+ [key: string]: unknown;
79
+ }
80
+
81
+ /**
82
+ * Thin fetch wrapper for the partner relay backend. Joins `baseUrl` + path,
83
+ * sends/parses JSON, and normalizes the relay's error responses into the
84
+ * SDK's error classes.
85
+ */
86
+ declare class RelayHttp {
87
+ private readonly baseUrl;
88
+ private readonly credentials;
89
+ private readonly headers;
90
+ private readonly fetchImpl;
91
+ constructor(config: TouchQueWebConfig);
92
+ post<T>(path: string, body?: unknown): Promise<T>;
93
+ get<T>(path: string): Promise<T>;
94
+ del<T>(path: string): Promise<T>;
95
+ private request;
96
+ }
97
+
98
+ declare class Passkeys {
99
+ private readonly http;
100
+ private readonly paths;
101
+ constructor(http: RelayHttp, paths: TouchQuePaths);
102
+ /**
103
+ * Register a passkey for the currently signed-in user.
104
+ *
105
+ * `extra` is merged into the verify request body — use it for a device
106
+ * label, for example: `register({ label: 'MacBook Touch ID' })`.
107
+ */
108
+ register(extra?: Record<string, unknown>): Promise<RegisterResult>;
109
+ /**
110
+ * Passwordless-primary sign-in: authenticate a user from zero with a
111
+ * passkey. `context` is merged into both relay calls (pass OAuth params,
112
+ * a redirect URI, etc.).
113
+ *
114
+ * Inspect the result: `ok` = signed in; `requiresStepUp` = start a push /
115
+ * number-match flow; `pending2fa` = continue a pending 2FA flow. `raw` is
116
+ * the relay's untouched response.
117
+ */
118
+ authenticate(input: {
119
+ email: string;
120
+ context?: Record<string, unknown>;
121
+ }): Promise<AuthenticateResult>;
122
+ /**
123
+ * Approve an already-pending login request (second-factor flow) with a
124
+ * passkey, instead of the mobile push.
125
+ */
126
+ approveLogin(input: {
127
+ requestId: string;
128
+ }): Promise<ApproveResult>;
129
+ /** List the signed-in user's registered passkeys (metadata only). */
130
+ list(): Promise<PasskeySummary[]>;
131
+ /** Remove one of the signed-in user's passkeys by its record id. */
132
+ remove(id: string): Promise<{
133
+ deleted: boolean;
134
+ }>;
135
+ }
136
+ /** `true` if the current browser exposes the WebAuthn API at all. */
137
+ declare function isPasskeySupported(): boolean;
138
+ /** `true` if a built-in platform authenticator (Touch ID / Windows Hello / …) is usable. */
139
+ declare function isPlatformAuthenticatorAvailable(): Promise<boolean>;
140
+
141
+ interface BehavioralMetrics {
142
+ mouseDistance: number;
143
+ mouseJitter: number;
144
+ clicks: number;
145
+ keystrokes: number;
146
+ keystrokeSpeedAvg: number;
147
+ }
148
+
149
+ interface AttachBehavioralOptions {
150
+ /** Per-request token minted by the partner backend. Required. */
151
+ telemetryToken: string;
152
+ /** The pending login request id this telemetry belongs to. Required. */
153
+ requestId: string;
154
+ /** TouchQue API base URL, e.g. `https://api-authenticator.touchque.com`. */
155
+ apiBaseUrl: string;
156
+ /** Opt in to canvas/device fingerprinting (bigger privacy footprint). */
157
+ collectDeviceFingerprint?: boolean;
158
+ /** Sampling interval (ms) for the internal tracker. Default 1000. */
159
+ sampleIntervalMs?: number;
160
+ /** Injectable fetch implementation. Defaults to the global `fetch`. */
161
+ fetch?: typeof fetch;
162
+ }
163
+ interface BehavioralHandle {
164
+ /** Stop tracking and remove the container listeners. */
165
+ stop(): void;
166
+ /** Zero the accumulated metrics without detaching. */
167
+ reset(): void;
168
+ /** Send the current metrics to TouchQue. Best-effort, never throws. */
169
+ submit(): Promise<void>;
170
+ }
171
+ declare function attachBehavioral(target: string | Element, options: AttachBehavioralOptions): BehavioralHandle;
172
+
173
+ /** Base class for every error thrown by this SDK. */
174
+ declare class TouchQueWebError extends Error {
175
+ constructor(message: string);
176
+ }
177
+ /**
178
+ * The WebAuthn ceremony did not complete because the user dismissed the
179
+ * system prompt (or the browser aborted it). Maps `NotAllowedError` /
180
+ * `AbortError` from `navigator.credentials.*`.
181
+ */
182
+ declare class PasskeyDismissedError extends TouchQueWebError {
183
+ constructor(message?: string);
184
+ }
185
+ /**
186
+ * The account has no passkey registered. The relay backend returned 404 (or
187
+ * `error: "no_passkey_registered"`). The caller should fall back to another
188
+ * sign-in method (e.g. password).
189
+ */
190
+ declare class PasskeyNotRegisteredError extends TouchQueWebError {
191
+ constructor(message?: string);
192
+ }
193
+ /**
194
+ * Passwordless sign-in is not enabled for this tenant/integration. The relay
195
+ * backend returned 403 (or `error: "passwordless_login_disabled"`).
196
+ */
197
+ declare class PasskeyDisabledError extends TouchQueWebError {
198
+ constructor(message?: string);
199
+ }
200
+ /**
201
+ * The relay backend returned a non-2xx response that is not one of the
202
+ * specific cases above. `status` is the HTTP status, `body` is the parsed
203
+ * response body (if any).
204
+ */
205
+ declare class TouchQueWebAPIError extends TouchQueWebError {
206
+ readonly status: number;
207
+ readonly body: unknown;
208
+ constructor(status: number, body: unknown, message?: string);
209
+ }
210
+
211
+ interface TouchQueWeb {
212
+ passkeys: Passkeys;
213
+ behavioral: {
214
+ /**
215
+ * Attach the behavioral biometrics widget to a container. Unlike the
216
+ * passkey calls, this posts directly to TouchQue (`apiBaseUrl`), gated
217
+ * by the per-request `telemetryToken`.
218
+ */
219
+ attach(target: string | Element, options: AttachBehavioralOptions): BehavioralHandle;
220
+ };
221
+ /** `true` if the browser exposes the WebAuthn API. */
222
+ isPasskeySupported(): boolean;
223
+ /** `true` if a built-in platform authenticator is usable. */
224
+ isPlatformAuthenticatorAvailable(): Promise<boolean>;
225
+ }
226
+ declare function createTouchQueWeb(config: TouchQueWebConfig): TouchQueWeb;
227
+
228
+ export { type ApproveResult, type AttachBehavioralOptions, type AuthenticateResult, type BehavioralHandle, type BehavioralMetrics, PasskeyDisabledError, PasskeyDismissedError, PasskeyNotRegisteredError, type PasskeySummary, type RegisterResult, type TouchQuePaths, type TouchQueWeb, TouchQueWebAPIError, type TouchQueWebConfig, TouchQueWebError, attachBehavioral, createTouchQueWeb, isPasskeySupported, isPlatformAuthenticatorAvailable };