@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/dist/index.mjs ADDED
@@ -0,0 +1,383 @@
1
+ import { browserSupportsWebAuthn, platformAuthenticatorIsAvailable, startRegistration, startAuthentication } from '@simplewebauthn/browser';
2
+
3
+ // src/errors.ts
4
+ var TouchQueWebError = class extends Error {
5
+ constructor(message) {
6
+ super(message);
7
+ this.name = "TouchQueWebError";
8
+ }
9
+ };
10
+ var PasskeyDismissedError = class extends TouchQueWebError {
11
+ constructor(message = "The passkey prompt was dismissed before it completed.") {
12
+ super(message);
13
+ this.name = "PasskeyDismissedError";
14
+ }
15
+ };
16
+ var PasskeyNotRegisteredError = class extends TouchQueWebError {
17
+ constructor(message = "No passkey is registered for this account.") {
18
+ super(message);
19
+ this.name = "PasskeyNotRegisteredError";
20
+ }
21
+ };
22
+ var PasskeyDisabledError = class extends TouchQueWebError {
23
+ constructor(message = "Passwordless sign-in is not enabled for this account.") {
24
+ super(message);
25
+ this.name = "PasskeyDisabledError";
26
+ }
27
+ };
28
+ var TouchQueWebAPIError = class extends TouchQueWebError {
29
+ status;
30
+ body;
31
+ constructor(status, body, message) {
32
+ super(message || `Relay request failed with HTTP ${status}`);
33
+ this.name = "TouchQueWebAPIError";
34
+ this.status = status;
35
+ this.body = body;
36
+ }
37
+ };
38
+
39
+ // src/http.ts
40
+ var RelayHttp = class {
41
+ baseUrl;
42
+ credentials;
43
+ headers;
44
+ fetchImpl;
45
+ constructor(config) {
46
+ if (!config.baseUrl) {
47
+ throw new Error("createTouchQueWeb: `baseUrl` is required.");
48
+ }
49
+ this.baseUrl = config.baseUrl.replace(/\/+$/, "");
50
+ this.credentials = config.credentials ?? "same-origin";
51
+ this.headers = config.headers ?? {};
52
+ const f = config.fetch ?? (typeof fetch !== "undefined" ? fetch : void 0);
53
+ if (!f) {
54
+ throw new Error("createTouchQueWeb: no `fetch` available \u2014 pass `fetch` in the config.");
55
+ }
56
+ this.fetchImpl = f.bind(globalThis);
57
+ }
58
+ post(path, body) {
59
+ return this.request("POST", path, body);
60
+ }
61
+ get(path) {
62
+ return this.request("GET", path);
63
+ }
64
+ del(path) {
65
+ return this.request("DELETE", path);
66
+ }
67
+ async request(method, path, body) {
68
+ const res = await this.fetchImpl(this.baseUrl + path, {
69
+ method,
70
+ credentials: this.credentials,
71
+ headers: {
72
+ Accept: "application/json",
73
+ ...body !== void 0 ? { "Content-Type": "application/json" } : {},
74
+ ...this.headers
75
+ },
76
+ body: body !== void 0 ? JSON.stringify(body) : void 0
77
+ });
78
+ const text = await res.text();
79
+ let parsed = void 0;
80
+ if (text) {
81
+ try {
82
+ parsed = JSON.parse(text);
83
+ } catch {
84
+ parsed = text;
85
+ }
86
+ }
87
+ if (!res.ok) {
88
+ const errCode = parsed && typeof parsed === "object" && "error" in parsed ? String(parsed.error) : void 0;
89
+ if (errCode === "no_passkey_registered" || res.status === 404 && !errCode) {
90
+ throw new PasskeyNotRegisteredError();
91
+ }
92
+ if (errCode === "passwordless_login_disabled" || res.status === 403 && !errCode) {
93
+ throw new PasskeyDisabledError();
94
+ }
95
+ const message = parsed && typeof parsed === "object" && "message" in parsed ? String(parsed.message) : errCode;
96
+ throw new TouchQueWebAPIError(res.status, parsed, message);
97
+ }
98
+ return parsed;
99
+ }
100
+ };
101
+ function isDismissal(err) {
102
+ const name = err?.name;
103
+ return name === "NotAllowedError" || name === "AbortError";
104
+ }
105
+ async function ceremony(run) {
106
+ try {
107
+ return await run();
108
+ } catch (err) {
109
+ if (isDismissal(err)) throw new PasskeyDismissedError();
110
+ if (err instanceof TouchQueWebError) throw err;
111
+ throw new TouchQueWebError(
112
+ `WebAuthn ceremony failed: ${err?.message ?? String(err)}`
113
+ );
114
+ }
115
+ }
116
+ function unwrapOptions(body) {
117
+ if (body && typeof body === "object" && "options" in body) {
118
+ const b = body;
119
+ return { attemptId: b.attemptId, options: b.options };
120
+ }
121
+ return { options: body ?? {} };
122
+ }
123
+ var Passkeys = class {
124
+ constructor(http, paths) {
125
+ this.http = http;
126
+ this.paths = paths;
127
+ }
128
+ http;
129
+ paths;
130
+ /**
131
+ * Register a passkey for the currently signed-in user.
132
+ *
133
+ * `extra` is merged into the verify request body — use it for a device
134
+ * label, for example: `register({ label: 'MacBook Touch ID' })`.
135
+ */
136
+ async register(extra = {}) {
137
+ const optionsBody = await this.http.post(this.paths.registerOptions, {});
138
+ const { options } = unwrapOptions(optionsBody);
139
+ const response = await ceremony(() => startRegistration({ optionsJSON: options }));
140
+ return this.http.post(this.paths.registerVerify, { response, ...extra });
141
+ }
142
+ /**
143
+ * Passwordless-primary sign-in: authenticate a user from zero with a
144
+ * passkey. `context` is merged into both relay calls (pass OAuth params,
145
+ * a redirect URI, etc.).
146
+ *
147
+ * Inspect the result: `ok` = signed in; `requiresStepUp` = start a push /
148
+ * number-match flow; `pending2fa` = continue a pending 2FA flow. `raw` is
149
+ * the relay's untouched response.
150
+ */
151
+ async authenticate(input) {
152
+ const context = input.context ?? {};
153
+ const optionsBody = await this.http.post(this.paths.authenticateOptions, {
154
+ email: input.email,
155
+ ...context
156
+ });
157
+ const { attemptId, options } = unwrapOptions(optionsBody);
158
+ const response = await ceremony(() => startAuthentication({ optionsJSON: options }));
159
+ const raw = await this.http.post(this.paths.authenticateVerify, {
160
+ ...attemptId !== void 0 ? { attemptId } : {},
161
+ response,
162
+ ...context
163
+ });
164
+ const requiresStepUp = raw.requiresStepUp === true;
165
+ const pending2fa = raw.status === "pending_2fa";
166
+ const ok = !requiresStepUp && !pending2fa && (raw.success === true || raw.status === "success");
167
+ return { ok, requiresStepUp, pending2fa, raw };
168
+ }
169
+ /**
170
+ * Approve an already-pending login request (second-factor flow) with a
171
+ * passkey, instead of the mobile push.
172
+ */
173
+ async approveLogin(input) {
174
+ const optionsBody = await this.http.post(this.paths.approveOptions, {
175
+ requestId: input.requestId
176
+ });
177
+ const { options } = unwrapOptions(optionsBody);
178
+ const response = await ceremony(() => startAuthentication({ optionsJSON: options }));
179
+ return this.http.post(this.paths.approveVerify, {
180
+ requestId: input.requestId,
181
+ response
182
+ });
183
+ }
184
+ /** List the signed-in user's registered passkeys (metadata only). */
185
+ async list() {
186
+ const body = await this.http.get(this.paths.list);
187
+ return body.credentials ?? [];
188
+ }
189
+ /** Remove one of the signed-in user's passkeys by its record id. */
190
+ async remove(id) {
191
+ return this.http.del(
192
+ `${this.paths.remove}/${encodeURIComponent(id)}`
193
+ );
194
+ }
195
+ };
196
+ function isPasskeySupported() {
197
+ return browserSupportsWebAuthn();
198
+ }
199
+ function isPlatformAuthenticatorAvailable() {
200
+ return platformAuthenticatorIsAvailable();
201
+ }
202
+
203
+ // src/behavioral/tracker.ts
204
+ function createBehavioralTracker(containerEl, opts = {}) {
205
+ const sampleIntervalMs = opts.sampleIntervalMs || 1e3;
206
+ const keystrokeMaxGapMs = opts.keystrokeMaxGapMs || 2e3;
207
+ let running = false;
208
+ let distance = 0;
209
+ let jitterCount = 0;
210
+ let clickCount = 0;
211
+ let lastPos = { x: 0, y: 0 };
212
+ let lastKeyTime = null;
213
+ let keyIntervals = [];
214
+ let intervalHandle = null;
215
+ function handleMouseMove(e) {
216
+ const { clientX, clientY } = e;
217
+ if (lastPos.x !== 0 && lastPos.y !== 0) {
218
+ const dx = clientX - lastPos.x;
219
+ const dy = clientY - lastPos.y;
220
+ distance += Math.sqrt(dx * dx + dy * dy);
221
+ if (dx !== 0 && dy !== 0 && Math.abs(dx / dy) > 0.1) {
222
+ jitterCount += 1;
223
+ }
224
+ }
225
+ lastPos = { x: clientX, y: clientY };
226
+ }
227
+ function handleClick() {
228
+ clickCount += 1;
229
+ }
230
+ function handleKeyDown() {
231
+ const now = Date.now();
232
+ if (lastKeyTime !== null) {
233
+ const diff = now - lastKeyTime;
234
+ if (diff < keystrokeMaxGapMs) {
235
+ keyIntervals.push(diff);
236
+ }
237
+ }
238
+ lastKeyTime = now;
239
+ }
240
+ function getMetrics() {
241
+ const avgSpeed = keyIntervals.length ? keyIntervals.reduce((a, b) => a + b, 0) / keyIntervals.length : 0;
242
+ return {
243
+ mouseDistance: Math.floor(distance),
244
+ mouseJitter: jitterCount,
245
+ clicks: clickCount,
246
+ keystrokes: keyIntervals.length,
247
+ keystrokeSpeedAvg: Math.floor(avgSpeed)
248
+ };
249
+ }
250
+ function start() {
251
+ if (running || !containerEl) return;
252
+ running = true;
253
+ containerEl.addEventListener("mousemove", handleMouseMove);
254
+ containerEl.addEventListener("click", handleClick);
255
+ containerEl.addEventListener("keydown", handleKeyDown);
256
+ intervalHandle = setInterval(() => {
257
+ if (typeof opts.onSample === "function") opts.onSample(getMetrics());
258
+ }, sampleIntervalMs);
259
+ }
260
+ function stop() {
261
+ if (!running) return;
262
+ running = false;
263
+ containerEl.removeEventListener("mousemove", handleMouseMove);
264
+ containerEl.removeEventListener("click", handleClick);
265
+ containerEl.removeEventListener("keydown", handleKeyDown);
266
+ if (intervalHandle) clearInterval(intervalHandle);
267
+ intervalHandle = null;
268
+ }
269
+ function reset() {
270
+ distance = 0;
271
+ jitterCount = 0;
272
+ clickCount = 0;
273
+ keyIntervals = [];
274
+ lastPos = { x: 0, y: 0 };
275
+ lastKeyTime = null;
276
+ }
277
+ return { start, stop, reset, getMetrics };
278
+ }
279
+
280
+ // src/behavioral/fingerprint.ts
281
+ function generateDeviceFingerprint() {
282
+ try {
283
+ const canvas = document.createElement("canvas");
284
+ const ctx = canvas.getContext("2d");
285
+ if (!ctx) return "no-canvas";
286
+ ctx.textBaseline = "top";
287
+ ctx.font = "14px 'Arial'";
288
+ ctx.textBaseline = "alphabetic";
289
+ ctx.fillStyle = "#f60";
290
+ ctx.fillRect(125, 1, 62, 20);
291
+ ctx.fillStyle = "#069";
292
+ ctx.fillText("TouchQue Behavioral Widget Fingerprint", 2, 15);
293
+ ctx.fillStyle = "rgba(102, 204, 0, 0.7)";
294
+ ctx.fillText("TouchQue Behavioral Widget Fingerprint", 4, 17);
295
+ const canvasData = canvas.toDataURL();
296
+ const screenData = `${window.screen.width}x${window.screen.height}-${window.screen.colorDepth}`;
297
+ const browserData = `${navigator.userAgent}-${navigator.language}-${navigator.platform}-${navigator.hardwareConcurrency || 1}`;
298
+ const raw = canvasData + screenData + browserData;
299
+ let hash = 5381;
300
+ for (let i = 0; i < raw.length; i++) {
301
+ hash = (hash << 5) + hash + raw.charCodeAt(i);
302
+ }
303
+ return `tq_fp_${Math.abs(hash).toString(16)}`;
304
+ } catch {
305
+ return "fp_error";
306
+ }
307
+ }
308
+
309
+ // src/behavioral/index.ts
310
+ function resolveContainer(target) {
311
+ if (typeof target === "string") return document.querySelector(target);
312
+ return target || null;
313
+ }
314
+ function attachBehavioral(target, options) {
315
+ const containerEl = resolveContainer(target);
316
+ if (!containerEl) {
317
+ throw new Error("attachBehavioral: container not found");
318
+ }
319
+ if (!options.telemetryToken || !options.requestId) {
320
+ throw new Error("attachBehavioral: telemetryToken and requestId are required");
321
+ }
322
+ const apiBaseUrl = (options.apiBaseUrl || "").replace(/\/+$/, "");
323
+ const fetchImpl = options.fetch ?? (typeof fetch !== "undefined" ? fetch : void 0);
324
+ if (!fetchImpl) {
325
+ throw new Error("attachBehavioral: no `fetch` available \u2014 pass `fetch` in the options.");
326
+ }
327
+ const doFetch = fetchImpl.bind(globalThis);
328
+ const tracker = createBehavioralTracker(containerEl, {
329
+ sampleIntervalMs: options.sampleIntervalMs
330
+ });
331
+ let fingerprint = null;
332
+ if (options.collectDeviceFingerprint === true) {
333
+ fingerprint = generateDeviceFingerprint();
334
+ }
335
+ tracker.start();
336
+ async function submit() {
337
+ const metrics = tracker.getMetrics();
338
+ const body = fingerprint ? { ...metrics, deviceFingerprint: fingerprint } : metrics;
339
+ try {
340
+ await doFetch(`${apiBaseUrl}/login/${encodeURIComponent(options.requestId)}/telemetry`, {
341
+ method: "POST",
342
+ headers: {
343
+ "Content-Type": "application/json",
344
+ Authorization: `Bearer ${options.telemetryToken}`
345
+ },
346
+ body: JSON.stringify(body)
347
+ });
348
+ } catch {
349
+ }
350
+ }
351
+ return {
352
+ stop: () => tracker.stop(),
353
+ reset: () => tracker.reset(),
354
+ submit
355
+ };
356
+ }
357
+
358
+ // src/index.ts
359
+ var DEFAULT_PATHS = {
360
+ registerOptions: "/passkey/register/options",
361
+ registerVerify: "/passkey/register/verify",
362
+ authenticateOptions: "/passkey/authenticate/options",
363
+ authenticateVerify: "/passkey/authenticate/verify",
364
+ approveOptions: "/passkey/login/options",
365
+ approveVerify: "/passkey/login/verify",
366
+ list: "/passkey/credentials",
367
+ remove: "/passkey/credentials"
368
+ };
369
+ function createTouchQueWeb(config) {
370
+ const http = new RelayHttp(config);
371
+ const paths = { ...DEFAULT_PATHS, ...config.paths ?? {} };
372
+ const passkeys = new Passkeys(http, paths);
373
+ return {
374
+ passkeys,
375
+ behavioral: {
376
+ attach: (target, options) => attachBehavioral(target, { fetch: config.fetch, ...options })
377
+ },
378
+ isPasskeySupported,
379
+ isPlatformAuthenticatorAvailable
380
+ };
381
+ }
382
+
383
+ export { PasskeyDisabledError, PasskeyDismissedError, PasskeyNotRegisteredError, TouchQueWebAPIError, TouchQueWebError, attachBehavioral, createTouchQueWeb, isPasskeySupported, isPlatformAuthenticatorAvailable };
@@ -0,0 +1 @@
1
+ var TouchQueBehavioral=(function(exports){'use strict';function M(t,e={}){let u=e.sampleIntervalMs||1e3,h=e.keystrokeMaxGapMs||2e3,a=false,o=0,r=0,n=0,i={x:0,y:0},s=null,l=[],f=null;function g(v){let{clientX:c,clientY:d}=v;if(i.x!==0&&i.y!==0){let p=c-i.x,m=d-i.y;o+=Math.sqrt(p*p+m*m),p!==0&&m!==0&&Math.abs(p/m)>.1&&(r+=1);}i={x:c,y:d};}function y(){n+=1;}function k(){let v=Date.now();if(s!==null){let c=v-s;c<h&&l.push(c);}s=v;}function B(){let v=l.length?l.reduce((c,d)=>c+d,0)/l.length:0;return {mouseDistance:Math.floor(o),mouseJitter:r,clicks:n,keystrokes:l.length,keystrokeSpeedAvg:Math.floor(v)}}function w(){a||!t||(a=true,t.addEventListener("mousemove",g),t.addEventListener("click",y),t.addEventListener("keydown",k),f=setInterval(()=>{typeof e.onSample=="function"&&e.onSample(B());},u));}function T(){a&&(a=false,t.removeEventListener("mousemove",g),t.removeEventListener("click",y),t.removeEventListener("keydown",k),f&&clearInterval(f),f=null);}function I(){o=0,r=0,n=0,l=[],i={x:0,y:0},s=null;}return {start:w,stop:T,reset:I,getMetrics:B}}function x(){try{let t=document.createElement("canvas"),e=t.getContext("2d");if(!e)return "no-canvas";e.textBaseline="top",e.font="14px 'Arial'",e.textBaseline="alphabetic",e.fillStyle="#f60",e.fillRect(125,1,62,20),e.fillStyle="#069",e.fillText("TouchQue Behavioral Widget Fingerprint",2,15),e.fillStyle="rgba(102, 204, 0, 0.7)",e.fillText("TouchQue Behavioral Widget Fingerprint",4,17);let u=t.toDataURL(),h=`${window.screen.width}x${window.screen.height}-${window.screen.colorDepth}`,a=`${navigator.userAgent}-${navigator.language}-${navigator.platform}-${navigator.hardwareConcurrency||1}`,o=u+h+a,r=5381;for(let n=0;n<o.length;n++)r=(r<<5)+r+o.charCodeAt(n);return `tq_fp_${Math.abs(r).toString(16)}`}catch{return "fp_error"}}function D(t){return typeof t=="string"?document.querySelector(t):t||null}function b(t,e){let u=D(t);if(!u)throw new Error("attachBehavioral: container not found");if(!e.telemetryToken||!e.requestId)throw new Error("attachBehavioral: telemetryToken and requestId are required");let h=(e.apiBaseUrl||"").replace(/\/+$/,""),a=e.fetch??(typeof fetch<"u"?fetch:void 0);if(!a)throw new Error("attachBehavioral: no `fetch` available \u2014 pass `fetch` in the options.");let o=a.bind(globalThis),r=M(u,{sampleIntervalMs:e.sampleIntervalMs}),n=null;e.collectDeviceFingerprint===true&&(n=x()),r.start();async function i(){let s=r.getMetrics(),l=n?{...s,deviceFingerprint:n}:s;try{await o(`${h}/login/${encodeURIComponent(e.requestId)}/telemetry`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${e.telemetryToken}`},body:JSON.stringify(l)});}catch{}}return {stop:()=>r.stop(),reset:()=>r.reset(),submit:i}}var F=b;exports.attach=F;return exports;})({});
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@touchque/web",
3
+ "version": "0.1.0",
4
+ "description": "Browser SDK for TouchQue Authenticator — passkey ceremonies and behavioral telemetry",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/Touchque/touchque-sdks",
8
+ "directory": "touchque-web"
9
+ },
10
+ "homepage": "https://touchque.com",
11
+ "license": "MIT",
12
+ "author": "TouchQue",
13
+ "main": "./dist/index.js",
14
+ "module": "./dist/index.mjs",
15
+ "types": "./dist/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "import": {
19
+ "types": "./dist/index.d.mts",
20
+ "default": "./dist/index.mjs"
21
+ },
22
+ "require": {
23
+ "types": "./dist/index.d.ts",
24
+ "default": "./dist/index.js"
25
+ }
26
+ }
27
+ },
28
+ "files": [
29
+ "dist",
30
+ "CHANGELOG.md"
31
+ ],
32
+ "sideEffects": false,
33
+ "engines": {
34
+ "node": ">=18"
35
+ },
36
+ "scripts": {
37
+ "build": "tsup",
38
+ "dev": "tsup --watch",
39
+ "test": "vitest run",
40
+ "prepublishOnly": "npm run build && npm test"
41
+ },
42
+ "keywords": [
43
+ "touchque",
44
+ "passkey",
45
+ "webauthn",
46
+ "fido2",
47
+ "passwordless",
48
+ "browser",
49
+ "2fa",
50
+ "authentication"
51
+ ],
52
+ "dependencies": {
53
+ "@simplewebauthn/browser": "^13.3.0"
54
+ },
55
+ "devDependencies": {
56
+ "@touchque/node": "file:../touchque-node",
57
+ "jsdom": "^25.0.1",
58
+ "tsup": "^8.0.2",
59
+ "typescript": "^5.4.5",
60
+ "vitest": "^2.1.4"
61
+ },
62
+ "publishConfig": {
63
+ "access": "public"
64
+ }
65
+ }