@touchque/node 1.3.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 +84 -0
- package/LICENSE +21 -0
- package/README.md +255 -0
- package/dist/index.d.mts +686 -0
- package/dist/index.d.ts +686 -0
- package/dist/index.js +804 -0
- package/dist/index.mjs +756 -0
- package/package.json +73 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,756 @@
|
|
|
1
|
+
// src/core/HttpClient.ts
|
|
2
|
+
import axios from "axios";
|
|
3
|
+
import * as crypto from "crypto";
|
|
4
|
+
|
|
5
|
+
// src/errors.ts
|
|
6
|
+
var TouchQueError = class extends Error {
|
|
7
|
+
constructor(message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "TouchQueError";
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
var TouchQueAPIError = class extends TouchQueError {
|
|
13
|
+
status;
|
|
14
|
+
code;
|
|
15
|
+
data;
|
|
16
|
+
constructor(status, data) {
|
|
17
|
+
const message = data.message || data.error || `TouchQue API Error: HTTP ${status}`;
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = "TouchQueAPIError";
|
|
20
|
+
this.status = status;
|
|
21
|
+
this.code = data.code;
|
|
22
|
+
this.data = data;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
var TouchQueTimeoutError = class extends TouchQueError {
|
|
26
|
+
requestId;
|
|
27
|
+
constructor(requestId, timeoutMs) {
|
|
28
|
+
super(
|
|
29
|
+
`TouchQue: Request '${requestId}' was not approved within ${timeoutMs / 1e3}s. The user did not respond in time.`
|
|
30
|
+
);
|
|
31
|
+
this.name = "TouchQueTimeoutError";
|
|
32
|
+
this.requestId = requestId;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
var TouchQueRejectedError = class extends TouchQueError {
|
|
36
|
+
requestId;
|
|
37
|
+
constructor(requestId) {
|
|
38
|
+
super(
|
|
39
|
+
`TouchQue: Request '${requestId}' was rejected by the user.`
|
|
40
|
+
);
|
|
41
|
+
this.name = "TouchQueRejectedError";
|
|
42
|
+
this.requestId = requestId;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
var TouchQueWebhookSignatureError = class extends TouchQueError {
|
|
46
|
+
constructor() {
|
|
47
|
+
super(
|
|
48
|
+
"TouchQue: Webhook signature verification failed. This request did not come from TouchQue or the payload was tampered."
|
|
49
|
+
);
|
|
50
|
+
this.name = "TouchQueWebhookSignatureError";
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
var TouchQueConfigError = class extends TouchQueError {
|
|
54
|
+
constructor(message) {
|
|
55
|
+
super(`TouchQue Config Error: ${message}`);
|
|
56
|
+
this.name = "TouchQueConfigError";
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// src/core/HttpClient.ts
|
|
61
|
+
var TEN_MB = 10 * 1024 * 1024;
|
|
62
|
+
function assertSafeBaseUrl(baseUrl) {
|
|
63
|
+
let url;
|
|
64
|
+
try {
|
|
65
|
+
url = new URL(baseUrl);
|
|
66
|
+
} catch {
|
|
67
|
+
throw new TouchQueConfigError(`baseUrl is not a valid URL: ${baseUrl}`);
|
|
68
|
+
}
|
|
69
|
+
if (url.protocol === "https:") return;
|
|
70
|
+
if (url.protocol === "http:") {
|
|
71
|
+
const host = url.hostname.toLowerCase();
|
|
72
|
+
if (host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]") return;
|
|
73
|
+
throw new TouchQueConfigError(
|
|
74
|
+
`Refusing to use a plaintext http:// baseUrl for "${url.hostname}". The API key and request signature would be sent in the clear \u2014 use https://.`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
throw new TouchQueConfigError(`baseUrl must be http(s): ${baseUrl}`);
|
|
78
|
+
}
|
|
79
|
+
function buildQuery(params) {
|
|
80
|
+
if (!params) return "";
|
|
81
|
+
const keys = Object.keys(params).filter((k) => params[k] !== void 0 && params[k] !== null);
|
|
82
|
+
if (keys.length === 0) return "";
|
|
83
|
+
keys.sort();
|
|
84
|
+
const parts = keys.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(String(params[k]))}`);
|
|
85
|
+
return `?${parts.join("&")}`;
|
|
86
|
+
}
|
|
87
|
+
var HttpClient = class {
|
|
88
|
+
client;
|
|
89
|
+
apiKey;
|
|
90
|
+
apiSecret;
|
|
91
|
+
constructor(config) {
|
|
92
|
+
if (!config.apiKey || typeof config.apiKey !== "string") {
|
|
93
|
+
throw new TouchQueConfigError("apiKey is required and must be a string");
|
|
94
|
+
}
|
|
95
|
+
if (!config.apiSecret || typeof config.apiSecret !== "string") {
|
|
96
|
+
throw new TouchQueConfigError("apiSecret is required and must be a string");
|
|
97
|
+
}
|
|
98
|
+
if (!config.apiKey.startsWith("tq_")) {
|
|
99
|
+
throw new TouchQueConfigError(
|
|
100
|
+
'apiKey must start with "tq_". Did you accidentally swap apiKey and apiSecret?'
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
this.apiKey = config.apiKey;
|
|
104
|
+
this.apiSecret = config.apiSecret;
|
|
105
|
+
const baseURL = config.baseUrl || "https://api-authenticator.touchque.com";
|
|
106
|
+
assertSafeBaseUrl(baseURL);
|
|
107
|
+
this.client = axios.create({
|
|
108
|
+
baseURL,
|
|
109
|
+
timeout: config.timeout || 1e4,
|
|
110
|
+
headers: { "Content-Type": "application/json" },
|
|
111
|
+
// An API endpoint should never redirect; a redirect to another host
|
|
112
|
+
// could leak the signed auth headers.
|
|
113
|
+
maxRedirects: 0,
|
|
114
|
+
// Cap the response/request size so a misbehaving or spoofed endpoint
|
|
115
|
+
// can't exhaust memory.
|
|
116
|
+
maxContentLength: TEN_MB,
|
|
117
|
+
maxBodyLength: TEN_MB
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Canonical signature.
|
|
122
|
+
* Format: HMAC(apiSecret, "METHOD:pathWithQuery:timestamp:nonce:bodyHash")
|
|
123
|
+
*
|
|
124
|
+
* `pathWithQuery` includes the sorted query string so GET/DELETE query
|
|
125
|
+
* params are covered by the signature (older SDKs signed the path only;
|
|
126
|
+
* the backend accepts either form).
|
|
127
|
+
*/
|
|
128
|
+
sign(method, pathWithQuery, body, timestamp, nonce) {
|
|
129
|
+
const bodyHash = crypto.createHash("sha256").update(body || "").digest("hex");
|
|
130
|
+
const message = `${method.toUpperCase()}:${pathWithQuery}:${timestamp}:${nonce}:${bodyHash}`;
|
|
131
|
+
return crypto.createHmac("sha256", this.apiSecret).update(message).digest("hex");
|
|
132
|
+
}
|
|
133
|
+
generateNonce() {
|
|
134
|
+
return crypto.randomBytes(16).toString("hex");
|
|
135
|
+
}
|
|
136
|
+
authHeaders(method, pathWithQuery, body) {
|
|
137
|
+
const timestamp = Date.now().toString();
|
|
138
|
+
const nonce = this.generateNonce();
|
|
139
|
+
return {
|
|
140
|
+
"x-api-key": this.apiKey,
|
|
141
|
+
"x-signature": this.sign(method, pathWithQuery, body, timestamp, nonce),
|
|
142
|
+
"x-timestamp": timestamp,
|
|
143
|
+
"x-nonce": nonce
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
async post(path, body = {}) {
|
|
147
|
+
const bodyStr = JSON.stringify(body);
|
|
148
|
+
try {
|
|
149
|
+
const response = await this.client.post(path, bodyStr, {
|
|
150
|
+
headers: this.authHeaders("POST", path, bodyStr)
|
|
151
|
+
});
|
|
152
|
+
return response.data;
|
|
153
|
+
} catch (error) {
|
|
154
|
+
throw this.handleError(error);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
async get(path, params) {
|
|
158
|
+
const query = buildQuery(params);
|
|
159
|
+
try {
|
|
160
|
+
const response = await this.client.get(path + query, {
|
|
161
|
+
headers: this.authHeaders("GET", path + query, "")
|
|
162
|
+
});
|
|
163
|
+
return response.data;
|
|
164
|
+
} catch (error) {
|
|
165
|
+
throw this.handleError(error);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
async delete(path) {
|
|
169
|
+
try {
|
|
170
|
+
const response = await this.client.delete(path, {
|
|
171
|
+
headers: this.authHeaders("DELETE", path, "")
|
|
172
|
+
});
|
|
173
|
+
return response.data;
|
|
174
|
+
} catch (error) {
|
|
175
|
+
throw this.handleError(error);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
handleError(error) {
|
|
179
|
+
if (axios.isAxiosError(error)) {
|
|
180
|
+
const axiosErr = error;
|
|
181
|
+
const status = axiosErr.response?.status || 500;
|
|
182
|
+
const data = axiosErr.response?.data || {};
|
|
183
|
+
return new TouchQueAPIError(status, {
|
|
184
|
+
error: data.error,
|
|
185
|
+
message: data.message,
|
|
186
|
+
code: data.code
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
return new TouchQueAPIError(500, {
|
|
190
|
+
message: error instanceof Error ? error.message : "Unknown error"
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// src/resources/Auth.ts
|
|
196
|
+
var Auth = class {
|
|
197
|
+
constructor(http) {
|
|
198
|
+
this.http = http;
|
|
199
|
+
}
|
|
200
|
+
http;
|
|
201
|
+
/**
|
|
202
|
+
* Generate a new setup secret for a user.
|
|
203
|
+
* Show this secret to the user ONCE (e.g. as a QR code) so they can
|
|
204
|
+
* link their TouchQue app.
|
|
205
|
+
*
|
|
206
|
+
* The secret expires after 60 seconds if not used (scanned by the mobile app).
|
|
207
|
+
* After expiry, calling this method again will automatically rotate the secret.
|
|
208
|
+
*
|
|
209
|
+
* @example
|
|
210
|
+
* const { secret, expiresAt } = await tq.auth.generateSecret({
|
|
211
|
+
* externalUsername: 'user@company.com'
|
|
212
|
+
* });
|
|
213
|
+
* // Show `secret` to user as QR code
|
|
214
|
+
* // Secret expires at `expiresAt` if not scanned
|
|
215
|
+
*/
|
|
216
|
+
async generateSecret(options) {
|
|
217
|
+
return this.http.post("/auth/generate-secret", {
|
|
218
|
+
externalUsername: options.externalUsername
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Reset (regenerate) a user's secret.
|
|
223
|
+
* This invalidates the old secret and generates a new one.
|
|
224
|
+
* Show the new secret to the user.
|
|
225
|
+
*
|
|
226
|
+
* @example
|
|
227
|
+
* const { secret } = await tq.auth.resetSecret({
|
|
228
|
+
* externalUsername: 'user@company.com'
|
|
229
|
+
* });
|
|
230
|
+
* // Show new secret to user
|
|
231
|
+
*/
|
|
232
|
+
async resetSecret(options) {
|
|
233
|
+
return this.http.post("/auth/secret/reset", {
|
|
234
|
+
externalUsername: options.externalUsername
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Validate a secret code.
|
|
239
|
+
* Useful if you want to verify the user entered the correct setup secret.
|
|
240
|
+
* Note: The backend allows this without an API key, but the SDK sends it for consistency.
|
|
241
|
+
*/
|
|
242
|
+
async validateSecret(options) {
|
|
243
|
+
return this.http.post("/auth/secret/validate", {
|
|
244
|
+
secret: options.secret
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Look up a user's current link status — no push notification is sent.
|
|
249
|
+
* Useful for detecting "the mobile app just linked this secret" (`used`
|
|
250
|
+
* flips true, `deviceId` gets set) without sending a LOGIN challenge to
|
|
251
|
+
* their device, e.g. while polling during enrollment.
|
|
252
|
+
*
|
|
253
|
+
* @example
|
|
254
|
+
* const { used, deviceId } = await tq.auth.getUser({
|
|
255
|
+
* externalUsername: 'user@company.com'
|
|
256
|
+
* });
|
|
257
|
+
* if (used && deviceId) {
|
|
258
|
+
* // Device has linked — no approval push needed to confirm this.
|
|
259
|
+
* }
|
|
260
|
+
*/
|
|
261
|
+
async getUser(options) {
|
|
262
|
+
return this.http.get(`/users/${encodeURIComponent(options.externalUsername)}`);
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
// src/resources/Login.ts
|
|
267
|
+
var Login = class {
|
|
268
|
+
constructor(http) {
|
|
269
|
+
this.http = http;
|
|
270
|
+
}
|
|
271
|
+
http;
|
|
272
|
+
/**
|
|
273
|
+
* Request 2FA confirmation from a user's TouchQue app.
|
|
274
|
+
*
|
|
275
|
+
* The user will receive a push notification on their device.
|
|
276
|
+
* They can then approve or reject the request.
|
|
277
|
+
*
|
|
278
|
+
* Built-in `type` values:
|
|
279
|
+
* - `'LOGIN'` — User is logging in
|
|
280
|
+
* - `'DISABLE_2FA'` — User is disabling 2FA
|
|
281
|
+
* - Custom action type IDs or slugs configured in your TouchQue Dashboard
|
|
282
|
+
*
|
|
283
|
+
* @example
|
|
284
|
+
* // Basic login verification
|
|
285
|
+
* const response = await tq.login.request({
|
|
286
|
+
* externalUsername: 'user@company.com',
|
|
287
|
+
* type: 'LOGIN'
|
|
288
|
+
* });
|
|
289
|
+
*
|
|
290
|
+
* @example
|
|
291
|
+
* // Protect a custom action using its Dashboard-configured ID or slug
|
|
292
|
+
* const response = await tq.login.request({
|
|
293
|
+
* externalUsername: 'user@company.com',
|
|
294
|
+
* type: 'action_type_slug_or_id',
|
|
295
|
+
* referenceId: 'txn_abc123' // Your internal transaction ID
|
|
296
|
+
* });
|
|
297
|
+
*/
|
|
298
|
+
async request(options) {
|
|
299
|
+
return this.http.post("/login/request", {
|
|
300
|
+
externalUsername: options.externalUsername,
|
|
301
|
+
type: options.type,
|
|
302
|
+
...options.referenceId && { referenceId: options.referenceId },
|
|
303
|
+
...options.clientIp && { clientIp: options.clientIp },
|
|
304
|
+
...options.userAgent && { userAgent: options.userAgent },
|
|
305
|
+
...options.requireBiometric && { requireBiometric: options.requireBiometric },
|
|
306
|
+
...options.requireNumberMatch && { requireNumberMatch: options.requireNumberMatch }
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Check the current status of a login request.
|
|
311
|
+
*
|
|
312
|
+
* Returns: `'PENDING'` | `'CONFIRMED'` | `'REJECTED'` | `'EXPIRED'`
|
|
313
|
+
*
|
|
314
|
+
* @example
|
|
315
|
+
* const { status } = await tq.login.status('request_id_here');
|
|
316
|
+
* if (status === 'CONFIRMED') {
|
|
317
|
+
* // User approved — proceed with action
|
|
318
|
+
* }
|
|
319
|
+
*/
|
|
320
|
+
async status(requestId) {
|
|
321
|
+
return this.http.get(`/login/status/${encodeURIComponent(requestId)}`);
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Wait for the user to approve or reject a login request.
|
|
325
|
+
*
|
|
326
|
+
* This is a convenience method that polls the status endpoint
|
|
327
|
+
* until the request is approved, rejected, or times out.
|
|
328
|
+
*
|
|
329
|
+
* **This is the recommended way to integrate TouchQue into your routes.**
|
|
330
|
+
*
|
|
331
|
+
* @throws {TouchQueRejectedError} If the user rejects the request
|
|
332
|
+
* @throws {TouchQueTimeoutError} If the request times out (user didn't respond)
|
|
333
|
+
*
|
|
334
|
+
* @example
|
|
335
|
+
* // Simple: Send request and wait for approval in one step
|
|
336
|
+
* try {
|
|
337
|
+
* const loginReq = await tq.login.request({
|
|
338
|
+
* externalUsername: 'user@company.com',
|
|
339
|
+
* type: 'LOGIN'
|
|
340
|
+
* });
|
|
341
|
+
*
|
|
342
|
+
* const result = await tq.login.waitForApproval({
|
|
343
|
+
* requestId: loginReq.requestId,
|
|
344
|
+
* timeout: 30000 // Wait up to 30 seconds
|
|
345
|
+
* });
|
|
346
|
+
*
|
|
347
|
+
* if (result.approved) {
|
|
348
|
+
* // ✅ User approved — proceed
|
|
349
|
+
* proceedWithAction();
|
|
350
|
+
* }
|
|
351
|
+
* } catch (err) {
|
|
352
|
+
* if (err instanceof TouchQueRejectedError) {
|
|
353
|
+
* // ❌ User rejected — cancel the action
|
|
354
|
+
* } else if (err instanceof TouchQueTimeoutError) {
|
|
355
|
+
* // ⏰ User didn't respond in time
|
|
356
|
+
* }
|
|
357
|
+
* }
|
|
358
|
+
*/
|
|
359
|
+
async waitForApproval(options) {
|
|
360
|
+
const timeout = options.timeout || 3e4;
|
|
361
|
+
const pollInterval = options.pollInterval || 400;
|
|
362
|
+
const startTime = Date.now();
|
|
363
|
+
while (Date.now() - startTime < timeout) {
|
|
364
|
+
const { status } = await this.status(options.requestId);
|
|
365
|
+
if (status === "CONFIRMED") {
|
|
366
|
+
return { approved: true, status };
|
|
367
|
+
}
|
|
368
|
+
if (status === "REJECTED") {
|
|
369
|
+
throw new TouchQueRejectedError(options.requestId);
|
|
370
|
+
}
|
|
371
|
+
if (status === "EXPIRED") {
|
|
372
|
+
throw new TouchQueTimeoutError(options.requestId, timeout);
|
|
373
|
+
}
|
|
374
|
+
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
375
|
+
}
|
|
376
|
+
throw new TouchQueTimeoutError(options.requestId, timeout);
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Convenience: Send a 2FA request AND wait for approval in one call.
|
|
380
|
+
*
|
|
381
|
+
* Perfect for protecting any action with a single function call.
|
|
382
|
+
*
|
|
383
|
+
* @throws {TouchQueRejectedError} User rejected
|
|
384
|
+
* @throws {TouchQueTimeoutError} User didn't respond in time
|
|
385
|
+
*
|
|
386
|
+
* @example
|
|
387
|
+
* // Protect an action with 2FA — ONE LINE!
|
|
388
|
+
* const result = await tq.login.verify({
|
|
389
|
+
* externalUsername: 'user@company.com',
|
|
390
|
+
* type: 'action_type_slug_or_id',
|
|
391
|
+
* referenceId: 'action_789'
|
|
392
|
+
* });
|
|
393
|
+
* // If we reach here, user approved ✅
|
|
394
|
+
* executeAction();
|
|
395
|
+
*/
|
|
396
|
+
async verify(options) {
|
|
397
|
+
const { timeout, pollInterval, ...requestOptions } = options;
|
|
398
|
+
const loginReq = await this.request(requestOptions);
|
|
399
|
+
const result = await this.waitForApproval({
|
|
400
|
+
requestId: loginReq.requestId,
|
|
401
|
+
timeout,
|
|
402
|
+
pollInterval
|
|
403
|
+
});
|
|
404
|
+
return {
|
|
405
|
+
...result,
|
|
406
|
+
requestId: loginReq.requestId,
|
|
407
|
+
challengeCode: loginReq.challengeCode
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Approve a pending 2FA request using a Recovery Code.
|
|
412
|
+
* Useful when a user loses their phone and needs to bypass 2FA
|
|
413
|
+
* using one of the backup codes generated during setup.
|
|
414
|
+
*/
|
|
415
|
+
async approveWithRecoveryCode(options) {
|
|
416
|
+
return this.http.post("/login/recovery", {
|
|
417
|
+
requestId: options.requestId,
|
|
418
|
+
code: options.code
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
// src/resources/Webhook.ts
|
|
424
|
+
import * as crypto2 from "crypto";
|
|
425
|
+
function canonicalize(payload) {
|
|
426
|
+
const { signature: _drop, ...rest } = payload;
|
|
427
|
+
const sorted = {};
|
|
428
|
+
for (const key of Object.keys(rest).sort()) {
|
|
429
|
+
sorted[key] = rest[key];
|
|
430
|
+
}
|
|
431
|
+
return JSON.stringify(sorted);
|
|
432
|
+
}
|
|
433
|
+
var Webhook = class {
|
|
434
|
+
apiSecret;
|
|
435
|
+
constructor(apiSecret) {
|
|
436
|
+
this.apiSecret = apiSecret;
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Verify that an incoming webhook request is genuinely from TouchQue and
|
|
440
|
+
* return its parsed payload. Throws `TouchQueWebhookSignatureError` if the
|
|
441
|
+
* signature is invalid, the body is malformed, or the webhook is stale.
|
|
442
|
+
*
|
|
443
|
+
* **Always verify before processing, and de-duplicate on `payload.jti`
|
|
444
|
+
* (or `payload.requestId` + `payload.event`) so a replayed webhook can't
|
|
445
|
+
* re-trigger your logic.**
|
|
446
|
+
*
|
|
447
|
+
* @throws {TouchQueWebhookSignatureError}
|
|
448
|
+
*
|
|
449
|
+
* @example
|
|
450
|
+
* // Express.js — mount with a raw body parser on this route
|
|
451
|
+
* app.post('/webhooks/touchque', express.raw({ type: 'application/json' }), (req, res) => {
|
|
452
|
+
* try {
|
|
453
|
+
* const event = tq.webhook.verify({
|
|
454
|
+
* rawBody: req.body.toString('utf8'),
|
|
455
|
+
* signature: req.headers['x-signature'] as string,
|
|
456
|
+
* });
|
|
457
|
+
* // ... handle event.event, guarded by an idempotency check on event.jti
|
|
458
|
+
* res.sendStatus(200);
|
|
459
|
+
* } catch {
|
|
460
|
+
* res.sendStatus(403); // not from TouchQue, tampered, or replayed
|
|
461
|
+
* }
|
|
462
|
+
* });
|
|
463
|
+
*/
|
|
464
|
+
verify(options) {
|
|
465
|
+
const { rawBody } = options;
|
|
466
|
+
if (!rawBody || typeof rawBody !== "string") {
|
|
467
|
+
throw new TouchQueWebhookSignatureError();
|
|
468
|
+
}
|
|
469
|
+
let payload;
|
|
470
|
+
try {
|
|
471
|
+
const parsed = JSON.parse(rawBody);
|
|
472
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
473
|
+
throw new Error("not an object");
|
|
474
|
+
}
|
|
475
|
+
payload = parsed;
|
|
476
|
+
} catch {
|
|
477
|
+
throw new TouchQueWebhookSignatureError();
|
|
478
|
+
}
|
|
479
|
+
const provided = typeof options.signature === "string" && options.signature ? options.signature : typeof payload.signature === "string" ? payload.signature : "";
|
|
480
|
+
if (!provided) {
|
|
481
|
+
throw new TouchQueWebhookSignatureError();
|
|
482
|
+
}
|
|
483
|
+
const expected = crypto2.createHmac("sha256", this.apiSecret).update(canonicalize(payload)).digest("hex");
|
|
484
|
+
const providedBuf = Buffer.from(provided, "utf8");
|
|
485
|
+
const expectedBuf = Buffer.from(expected, "utf8");
|
|
486
|
+
if (providedBuf.length !== expectedBuf.length || !crypto2.timingSafeEqual(providedBuf, expectedBuf)) {
|
|
487
|
+
throw new TouchQueWebhookSignatureError();
|
|
488
|
+
}
|
|
489
|
+
const tolerance = options.toleranceSeconds ?? 300;
|
|
490
|
+
if (tolerance > 0 && typeof payload.timestamp === "string") {
|
|
491
|
+
const ts = Date.parse(payload.timestamp);
|
|
492
|
+
if (!Number.isNaN(ts) && Math.abs(Date.now() - ts) > tolerance * 1e3) {
|
|
493
|
+
throw new TouchQueWebhookSignatureError();
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return payload;
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Check a webhook signature without throwing. Returns `true` / `false`.
|
|
500
|
+
*
|
|
501
|
+
* @example
|
|
502
|
+
* if (tq.webhook.isValid({ rawBody, signature })) {
|
|
503
|
+
* // process
|
|
504
|
+
* }
|
|
505
|
+
*/
|
|
506
|
+
isValid(options) {
|
|
507
|
+
try {
|
|
508
|
+
this.verify(options);
|
|
509
|
+
return true;
|
|
510
|
+
} catch {
|
|
511
|
+
return false;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
// src/resources/WebAuthn.ts
|
|
517
|
+
var WebAuthn = class {
|
|
518
|
+
constructor(http) {
|
|
519
|
+
this.http = http;
|
|
520
|
+
}
|
|
521
|
+
http;
|
|
522
|
+
/**
|
|
523
|
+
* Step 1 of registering a passkey: get options for `navigator.credentials.create()`.
|
|
524
|
+
*
|
|
525
|
+
* @example
|
|
526
|
+
* const options = await tq.webauthn.registerOptions({ externalUsername: 'user@company.com' });
|
|
527
|
+
* // send `options` to the browser, call navigator.credentials.create({ publicKey: options })
|
|
528
|
+
*/
|
|
529
|
+
async registerOptions(options) {
|
|
530
|
+
return this.http.post("/webauthn/register/options", {
|
|
531
|
+
externalUsername: options.externalUsername,
|
|
532
|
+
...options.discoverable ? { discoverable: true } : {}
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Step 2: verify the browser's registration response and store the credential.
|
|
537
|
+
*
|
|
538
|
+
* @example
|
|
539
|
+
* const { verified, credentialId } = await tq.webauthn.registerVerify({
|
|
540
|
+
* externalUsername: 'user@company.com',
|
|
541
|
+
* response: browserRegistrationResponseJSON,
|
|
542
|
+
* label: 'MacBook Touch ID',
|
|
543
|
+
* });
|
|
544
|
+
*/
|
|
545
|
+
async registerVerify(options) {
|
|
546
|
+
return this.http.post("/webauthn/register/verify", {
|
|
547
|
+
externalUsername: options.externalUsername,
|
|
548
|
+
response: options.response,
|
|
549
|
+
...options.label && { label: options.label }
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Step 1 of approving a pending login with a passkey: get options for
|
|
554
|
+
* `navigator.credentials.get()`, scoped to a specific `requestId` (from
|
|
555
|
+
* `tq.login.request()`).
|
|
556
|
+
*
|
|
557
|
+
* @example
|
|
558
|
+
* const loginReq = await tq.login.request({ externalUsername: 'user@company.com', type: 'LOGIN' });
|
|
559
|
+
* const options = await tq.webauthn.authenticateOptions({ requestId: loginReq.requestId });
|
|
560
|
+
*/
|
|
561
|
+
async authenticateOptions(options) {
|
|
562
|
+
return this.http.post("/webauthn/login/options", {
|
|
563
|
+
requestId: options.requestId
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Step 2: verify the browser's assertion — approves the LoginRequest on success.
|
|
568
|
+
*
|
|
569
|
+
* @example
|
|
570
|
+
* const result = await tq.webauthn.authenticateVerify({
|
|
571
|
+
* requestId: loginReq.requestId,
|
|
572
|
+
* response: browserAuthenticationResponseJSON,
|
|
573
|
+
* });
|
|
574
|
+
*/
|
|
575
|
+
async authenticateVerify(options) {
|
|
576
|
+
return this.http.post("/webauthn/login/verify", {
|
|
577
|
+
requestId: options.requestId,
|
|
578
|
+
response: options.response
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Step 1 of a PASSWORDLESS-PRIMARY login: authenticate a user *from zero*
|
|
583
|
+
* (no password, no prior `tq.login.request()`, no mobile device) with a
|
|
584
|
+
* passkey registered via `registerOptions({ discoverable: true })`.
|
|
585
|
+
*
|
|
586
|
+
* Requires `TenantPolicy.passwordlessLoginEnabled` for the integration.
|
|
587
|
+
* Returns `404 no_passkey_registered` if the user has no passkey — the
|
|
588
|
+
* caller should then fall back to password login.
|
|
589
|
+
*
|
|
590
|
+
* @example
|
|
591
|
+
* const { attemptId, options } = await tq.webauthn.primaryOptions({ externalUsername: 'user@company.com' });
|
|
592
|
+
* // browser: const assertion = await navigator.credentials.get({ publicKey: options });
|
|
593
|
+
*/
|
|
594
|
+
async primaryOptions(options) {
|
|
595
|
+
return this.http.post("/webauthn/authenticate/primary/options", {
|
|
596
|
+
externalUsername: options.externalUsername
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Step 2 of a passwordless-primary login: verify the browser assertion.
|
|
601
|
+
*
|
|
602
|
+
* On `success:true` the returned `requestId` is a CONFIRMED LoginRequest —
|
|
603
|
+
* the login is done. On `success:false` with `requiresStepUp:true`, risk
|
|
604
|
+
* or policy demands a second factor: start the normal `tq.login.request()`
|
|
605
|
+
* / number-match flow instead of trusting this assertion alone.
|
|
606
|
+
*/
|
|
607
|
+
async primaryVerify(options) {
|
|
608
|
+
return this.http.post("/webauthn/authenticate/primary/verify", {
|
|
609
|
+
attemptId: options.attemptId,
|
|
610
|
+
response: options.response
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* List a user's registered WebAuthn credentials (labels/metadata only, no key material).
|
|
615
|
+
*/
|
|
616
|
+
async listCredentials(options) {
|
|
617
|
+
return this.http.get("/webauthn/credentials", {
|
|
618
|
+
externalUsername: options.externalUsername
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Remove a registered credential (e.g. the user lost that device).
|
|
623
|
+
*/
|
|
624
|
+
async deleteCredential(credentialRecordId) {
|
|
625
|
+
return this.http.delete(`/webauthn/credentials/${encodeURIComponent(credentialRecordId)}`);
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
// src/middleware/express.ts
|
|
630
|
+
function requireTouchQue(tqClient, actionType, options = {}) {
|
|
631
|
+
return async (req, res, next) => {
|
|
632
|
+
const getUserId = options.getUserId || ((r) => r.user?.email || r.user?.id || r.user?.username);
|
|
633
|
+
const userId = getUserId(req);
|
|
634
|
+
if (!userId) {
|
|
635
|
+
res.status(401).json({
|
|
636
|
+
error: "UNAUTHORIZED_FOR_2FA",
|
|
637
|
+
message: "Could not find user ID in request. Ensure the user is authenticated before this middleware."
|
|
638
|
+
});
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
const referenceId = options.getReferenceId ? options.getReferenceId(req) : void 0;
|
|
642
|
+
const timeout = options.timeout || 3e4;
|
|
643
|
+
try {
|
|
644
|
+
const result = await tqClient.login.verify({
|
|
645
|
+
externalUsername: userId,
|
|
646
|
+
type: actionType,
|
|
647
|
+
referenceId,
|
|
648
|
+
timeout
|
|
649
|
+
});
|
|
650
|
+
req.touchque = {
|
|
651
|
+
requestId: result.requestId,
|
|
652
|
+
challengeCode: result.challengeCode,
|
|
653
|
+
approved: result.approved
|
|
654
|
+
};
|
|
655
|
+
next();
|
|
656
|
+
} catch (error) {
|
|
657
|
+
if (error instanceof TouchQueRejectedError) {
|
|
658
|
+
res.status(403).json({
|
|
659
|
+
error: "2FA_REJECTED",
|
|
660
|
+
message: "User rejected the 2FA request on their device."
|
|
661
|
+
});
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
if (error instanceof TouchQueTimeoutError) {
|
|
665
|
+
res.status(408).json({
|
|
666
|
+
error: "2FA_TIMEOUT",
|
|
667
|
+
message: "User did not respond to the 2FA request in time."
|
|
668
|
+
});
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
console.error("[TouchQue Middleware Error]", error);
|
|
672
|
+
res.status(503).json({
|
|
673
|
+
error: "2FA_UNAVAILABLE",
|
|
674
|
+
message: "The 2FA verification service is temporarily unavailable."
|
|
675
|
+
});
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// src/index.ts
|
|
682
|
+
var TouchQue = class {
|
|
683
|
+
client;
|
|
684
|
+
auth;
|
|
685
|
+
login;
|
|
686
|
+
/** Webhook signature verification */
|
|
687
|
+
webhook;
|
|
688
|
+
/** WebAuthn/FIDO2 (passkey) registration and login approval */
|
|
689
|
+
webauthn;
|
|
690
|
+
/**
|
|
691
|
+
* Create a new TouchQue SDK client.
|
|
692
|
+
*
|
|
693
|
+
* @param config - Your API credentials from the TouchQue Dashboard
|
|
694
|
+
*
|
|
695
|
+
* @example
|
|
696
|
+
* import { TouchQue } from '@touchque/node';
|
|
697
|
+
*
|
|
698
|
+
* const tq = new TouchQue({
|
|
699
|
+
* apiKey: 'tq_auth_abc123',
|
|
700
|
+
* apiSecret: 'your_api_secret'
|
|
701
|
+
* });
|
|
702
|
+
*
|
|
703
|
+
* // Now use tq.auth, tq.login, tq.webhook
|
|
704
|
+
*/
|
|
705
|
+
constructor(config) {
|
|
706
|
+
const http = new HttpClient(config);
|
|
707
|
+
this.client = http;
|
|
708
|
+
this.auth = new Auth(http);
|
|
709
|
+
this.login = new Login(http);
|
|
710
|
+
this.webhook = new Webhook(config.apiSecret);
|
|
711
|
+
this.webauthn = new WebAuthn(http);
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Wrap and protect any async function with TouchQue 2FA.
|
|
715
|
+
*
|
|
716
|
+
* @param fn The function to protect
|
|
717
|
+
* @param options Configuration for extracting the user identity and 2FA type
|
|
718
|
+
* @returns A new function that requires 2FA approval before executing the original function
|
|
719
|
+
*
|
|
720
|
+
* @example
|
|
721
|
+
* const secureWithdraw = tq.protect(processWithdraw, {
|
|
722
|
+
* type: 'WITHDRAW',
|
|
723
|
+
* getUserIdentifier: (username, amount) => username
|
|
724
|
+
* });
|
|
725
|
+
*
|
|
726
|
+
* await secureWithdraw("user@company.com", 500); // Prompts 2FA automatically
|
|
727
|
+
*/
|
|
728
|
+
protect(fn, options) {
|
|
729
|
+
return async (...args) => {
|
|
730
|
+
const externalUsername = options.getUserIdentifier(...args);
|
|
731
|
+
const referenceId = options.getReferenceId ? options.getReferenceId(...args) : void 0;
|
|
732
|
+
await this.login.verify({
|
|
733
|
+
externalUsername,
|
|
734
|
+
type: options.type,
|
|
735
|
+
referenceId,
|
|
736
|
+
timeout: options.timeout,
|
|
737
|
+
pollInterval: options.pollInterval
|
|
738
|
+
});
|
|
739
|
+
return fn(...args);
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
};
|
|
743
|
+
export {
|
|
744
|
+
Auth,
|
|
745
|
+
Login,
|
|
746
|
+
TouchQue,
|
|
747
|
+
TouchQueAPIError,
|
|
748
|
+
TouchQueConfigError,
|
|
749
|
+
TouchQueError,
|
|
750
|
+
TouchQueRejectedError,
|
|
751
|
+
TouchQueTimeoutError,
|
|
752
|
+
TouchQueWebhookSignatureError,
|
|
753
|
+
WebAuthn,
|
|
754
|
+
Webhook,
|
|
755
|
+
requireTouchQue
|
|
756
|
+
};
|