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