@tellescope/sdk 1.255.11 → 1.255.13

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.
@@ -0,0 +1,385 @@
1
+ require('source-map-support').install();
2
+
3
+ import crypto from "crypto"
4
+ import { Session } from "../../sdk"
5
+ import {
6
+ async_test,
7
+ log_header,
8
+ assert,
9
+ wait,
10
+ } from "@tellescope/testing"
11
+ import { setup_tests } from "../setup"
12
+
13
+ const host = process.env.API_URL || 'http://localhost:8080' as const
14
+
15
+ const decode_jwt = (token: string): any => {
16
+ try {
17
+ const part = token.split('.')[1]
18
+ const json = Buffer.from(part.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')
19
+ return JSON.parse(json)
20
+ } catch { return null }
21
+ }
22
+
23
+ // Client-side TOTP mirror (RFC 6238, HMAC-SHA1, 6 digits, 30s period) so the test can
24
+ // compute valid codes from the secret returned by begin_TOTP_configuration
25
+ const TOTP_PERIOD_MS = 30_000
26
+ const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
27
+ const base32_decode = (s: string): Buffer => {
28
+ let bits = 0, value = 0
29
+ const bytes: number[] = []
30
+ for (const char of s.toUpperCase().replace(/=+$/, '')) {
31
+ const index = BASE32_ALPHABET.indexOf(char)
32
+ if (index === -1) throw new Error("Invalid base32 character")
33
+ value = (value << 5) | index
34
+ bits += 5
35
+ if (bits >= 8) {
36
+ bytes.push((value >>> (bits - 8)) & 255)
37
+ bits -= 8
38
+ }
39
+ }
40
+ return Buffer.from(bytes)
41
+ }
42
+ const current_timestep = () => Math.floor(Date.now() / TOTP_PERIOD_MS)
43
+ const compute_totp_code = (secret: string, timestep=current_timestep()): string => {
44
+ const counter = Buffer.alloc(8)
45
+ counter.writeBigUInt64BE(BigInt(timestep))
46
+ const hmac = crypto.createHmac('sha1', base32_decode(secret)).update(counter).digest()
47
+ const offset = hmac[hmac.length - 1] & 0xf
48
+ const binary = (
49
+ ((hmac[offset] & 0x7f) << 24)
50
+ | ((hmac[offset + 1] & 0xff) << 16)
51
+ | ((hmac[offset + 2] & 0xff) << 8)
52
+ | (hmac[offset + 3] & 0xff)
53
+ )
54
+ return (binary % 1_000_000).toString().padStart(6, '0')
55
+ }
56
+ // a syntactically-valid 6-digit code guaranteed not to match the current/adjacent timesteps
57
+ const wrong_totp_code = (secret: string) => {
58
+ const step = current_timestep()
59
+ const valid = new Set([step - 1, step, step + 1].map(s => compute_totp_code(secret, s)))
60
+
61
+ let candidate = (parseInt(compute_totp_code(secret, step)) + 1) % 1_000_000
62
+ while (valid.has(candidate.toString().padStart(6, '0'))) {
63
+ candidate = (candidate + 1) % 1_000_000
64
+ }
65
+ return candidate.toString().padStart(6, '0')
66
+ }
67
+
68
+ export const totp_mfa_tests = async ({ sdk, sdkNonAdmin } : { sdk: Session, sdkNonAdmin: Session }) => {
69
+ log_header("TOTP MFA Tests")
70
+
71
+ const NON_ADMIN_EMAIL = process.env.NON_ADMIN_EMAIL
72
+ const NON_ADMIN_PASSWORD = process.env.NON_ADMIN_PASSWORD
73
+ if (!(NON_ADMIN_EMAIL && NON_ADMIN_PASSWORD)) {
74
+ throw new Error("NON_ADMIN_EMAIL and NON_ADMIN_PASSWORD must be set to run totp_mfa_tests")
75
+ }
76
+
77
+ const nonAdminId = sdkNonAdmin.userInfo.id
78
+ const businessId = sdk.userInfo.businessId
79
+
80
+ // the enrollment secret, captured in section A and used through later sections
81
+ let totpSecret = ''
82
+ // last timestep consumed by a successful server-side verification — a new verification
83
+ // in the same 30s window would be rejected as a replay, so wait for the window to roll over
84
+ let lastVerifiedStep = 0
85
+ const wait_for_fresh_totp_step = async () => {
86
+ while (current_timestep() <= lastVerifiedStep) {
87
+ await wait(undefined, 1000)
88
+ }
89
+ }
90
+
91
+ const set_allowed_mfa_methods = async (methods: string[]) => {
92
+ await sdk.api.users.updateOne(nonAdminId, { allowedMFAMethods: methods } as any, { replaceObjectFields: true })
93
+ await wait(undefined, 250)
94
+ }
95
+ // idempotent clear — skips the PATCH (identical updates to a record are limited to 3/30s)
96
+ const clear_allowed_mfa_methods = async () => {
97
+ const u: any = await sdk.api.users.getOne(nonAdminId)
98
+ if (!(u.allowedMFAMethods ?? []).length) return
99
+ await set_allowed_mfa_methods([])
100
+ }
101
+
102
+ // defensive cleanup of state from a previously-aborted run
103
+ const reset_mfa_state = async () => {
104
+ try { await sdk.api.organizations.updateOne(businessId, { enforceMFA: false } as any) } catch {}
105
+ await wait(undefined, 250)
106
+ try { await clear_allowed_mfa_methods() } catch {}
107
+ try {
108
+ const { authToken, user } = await sdkNonAdmin.api.users.configure_MFA({ disable: true })
109
+ await sdkNonAdmin.handle_new_session({ ...user, authToken } as any)
110
+ } catch {}
111
+ await wait(undefined, 250)
112
+ }
113
+
114
+ await reset_mfa_state()
115
+
116
+ try {
117
+ // ============================================================
118
+ // A. Enrollment
119
+ // ============================================================
120
+ log_header("A. TOTP Enrollment")
121
+
122
+ let otpauthURL = ''
123
+ await async_test(
124
+ 'A1. begin_TOTP_configuration returns otpauthURL + secret',
125
+ () => sdkNonAdmin.api.users.begin_TOTP_configuration({}),
126
+ { shouldError: false, onResult: (r: any) => {
127
+ totpSecret = r.secret
128
+ otpauthURL = r.otpauthURL
129
+ return typeof r.secret === 'string' && r.secret.length >= 16 && /^[A-Z2-7]+$/.test(r.secret)
130
+ }}
131
+ )
132
+ assert(otpauthURL.includes(totpSecret), 'otpauthURL missing secret', 'A2. otpauthURL contains secret')
133
+ assert(otpauthURL.startsWith('otpauth://totp/'), 'unexpected otpauth scheme', 'A3. otpauth scheme')
134
+ assert(otpauthURL.includes('issuer=Tellescope'), 'otpauthURL missing issuer', 'A4. otpauthURL issuer')
135
+
136
+ await async_test(
137
+ 'A5. confirm with wrong code -> 401',
138
+ () => sdkNonAdmin.api.users.confirm_TOTP_configuration({ code: wrong_totp_code(totpSecret) }),
139
+ { shouldError: true, onError: (e: any) => e.statusCode === 401 || /invalid/i.test(e.message || '') }
140
+ )
141
+
142
+ await wait(undefined, 600) // confirm rate limit is 1/500ms
143
+ await async_test(
144
+ 'A6. confirm with correct code -> enabled, 10 recovery codes, requiresMFA false',
145
+ () => sdkNonAdmin.api.users.confirm_TOTP_configuration({ code: compute_totp_code(totpSecret) }),
146
+ { shouldError: false, onResult: (r: any) => {
147
+ if (r.authToken) sdkNonAdmin.handle_new_session({ ...r.user, authToken: r.authToken } as any)
148
+ return (
149
+ r.recoveryCodes?.length === 10
150
+ && r.user?.mfa?.authenticator === true
151
+ && decode_jwt(r.authToken)?.requiresMFA === false
152
+ )
153
+ }}
154
+ )
155
+ lastVerifiedStep = current_timestep()
156
+
157
+ await async_test(
158
+ 'A7. user record has mfa.authenticator === true',
159
+ () => sdk.api.users.getOne(nonAdminId),
160
+ { shouldError: false, onResult: (u: any) => u.mfa?.authenticator === true && !u.mfa?.email }
161
+ )
162
+
163
+ await async_test(
164
+ 'A8. begin again while enrolled -> 400',
165
+ () => sdkNonAdmin.api.users.begin_TOTP_configuration({}),
166
+ { shouldError: true, onError: (e: any) => e.statusCode === 400 || /already configured/i.test(e.message || '') }
167
+ )
168
+
169
+ // ============================================================
170
+ // B. Login challenge + replay protection
171
+ // ============================================================
172
+ log_header("B. TOTP Challenge + Replay")
173
+
174
+ const challengeSession = new Session({ host })
175
+ await challengeSession.authenticate(NON_ADMIN_EMAIL, NON_ADMIN_PASSWORD)
176
+ assert(
177
+ !!challengeSession.userInfo.requiresMFA || decode_jwt((challengeSession as any).authToken)?.requiresMFA === true,
178
+ 'expected requiresMFA session after password login with TOTP enabled',
179
+ 'B1. password login yields requiresMFA session'
180
+ )
181
+
182
+ await wait_for_fresh_totp_step()
183
+ const challengeCode = compute_totp_code(totpSecret)
184
+ await async_test(
185
+ 'B2. submit_MFA_challenge with TOTP code -> full session',
186
+ () => challengeSession.api.users.submit_MFA_challenge({ code: challengeCode }),
187
+ { shouldError: false, onResult: (r: any) => decode_jwt(r.authToken)?.requiresMFA === false }
188
+ )
189
+ lastVerifiedStep = current_timestep()
190
+
191
+ // replay: same code from a fresh requiresMFA session must be rejected
192
+ const replaySession = new Session({ host })
193
+ await replaySession.authenticate(NON_ADMIN_EMAIL, NON_ADMIN_PASSWORD)
194
+ await wait(undefined, 600) // submit rate limit is 1/500ms
195
+ await async_test(
196
+ 'B3. replaying the same TOTP code -> 401',
197
+ () => replaySession.api.users.submit_MFA_challenge({ code: challengeCode }),
198
+ { shouldError: true, onError: (e: any) => e.statusCode === 401 || /invalid/i.test(e.message || '') }
199
+ )
200
+
201
+ // ============================================================
202
+ // C. Recovery-code interplay with a second method
203
+ // ============================================================
204
+ log_header("C. Recovery-code interplay")
205
+
206
+ await async_test(
207
+ 'C1. enabling email MFA when TOTP already configured -> recoveryCodes []',
208
+ () => sdkNonAdmin.api.users.configure_MFA({}),
209
+ { shouldError: false, onResult: (r: any) => {
210
+ if (r.authToken) sdkNonAdmin.handle_new_session({ ...r.user, authToken: r.authToken } as any)
211
+ return r.recoveryCodes?.length === 0 && r.user?.mfa?.email === true && r.user?.mfa?.authenticator === true
212
+ }}
213
+ )
214
+
215
+ // ============================================================
216
+ // D. Method-scoped disable + enforceMFA last-method rule
217
+ // ============================================================
218
+ log_header("D. Method-scoped disable")
219
+
220
+ await sdk.api.organizations.updateOne(businessId, { enforceMFA: true } as any)
221
+ await wait(undefined, 250)
222
+
223
+ await async_test(
224
+ 'D1. disable email (other method remains) succeeds under enforceMFA',
225
+ () => sdkNonAdmin.api.users.configure_MFA({ disable: true, method: 'email' }),
226
+ { shouldError: false, onResult: (r: any) => {
227
+ if (r.authToken) sdkNonAdmin.handle_new_session({ ...r.user, authToken: r.authToken } as any)
228
+ return !r.user?.mfa?.email && r.user?.mfa?.authenticator === true
229
+ }}
230
+ )
231
+
232
+ await async_test(
233
+ 'D2. disabling last method under enforceMFA -> 400',
234
+ () => sdkNonAdmin.api.users.configure_MFA({ disable: true, method: 'authenticator' }),
235
+ { shouldError: true, onError: (e: any) => e.statusCode === 400 || /at least one/i.test(e.message || '') }
236
+ )
237
+
238
+ await sdk.api.organizations.updateOne(businessId, { enforceMFA: false } as any)
239
+ await wait(undefined, 250)
240
+
241
+ await async_test(
242
+ 'D3. disable authenticator (enforceMFA off) succeeds',
243
+ () => sdkNonAdmin.api.users.configure_MFA({ disable: true, method: 'authenticator' }),
244
+ { shouldError: false, onResult: (r: any) => {
245
+ if (r.authToken) sdkNonAdmin.handle_new_session({ ...r.user, authToken: r.authToken } as any)
246
+ return !r.user?.mfa?.authenticator && !r.user?.mfa?.email
247
+ }}
248
+ )
249
+
250
+ await async_test(
251
+ 'D4. user record mfa cleared',
252
+ () => sdk.api.users.getOne(nonAdminId),
253
+ { shouldError: false, onResult: (u: any) => !u.mfa?.authenticator && !u.mfa?.email }
254
+ )
255
+
256
+ // TOTP secret deleted with the method: a still-valid-looking code must no longer verify
257
+ await wait_for_fresh_totp_step()
258
+ await wait(undefined, 600) // submit rate limit is 1/500ms
259
+ await async_test(
260
+ 'D5. TOTP submit after disable -> 401 (secret deleted)',
261
+ () => sdkNonAdmin.api.users.submit_MFA_challenge({ code: compute_totp_code(totpSecret) }),
262
+ { shouldError: true, onError: (e: any) => e.statusCode === 401 || /invalid/i.test(e.message || '') }
263
+ )
264
+
265
+ // ============================================================
266
+ // E. allowedMFAMethods restriction gating + grace
267
+ // ============================================================
268
+ log_header("E. allowedMFAMethods gating")
269
+
270
+ await set_allowed_mfa_methods(['authenticator'])
271
+
272
+ await async_test(
273
+ 'E1. restricted to authenticator: enabling email MFA -> 403',
274
+ () => sdkNonAdmin.api.users.configure_MFA({}),
275
+ { shouldError: true, onError: (e: any) => e.statusCode === 403 || /not permitted/i.test(e.message || '') }
276
+ )
277
+
278
+ await async_test(
279
+ 'E2. restricted to authenticator + email unconfigured: email challenge -> 403',
280
+ () => sdkNonAdmin.api.users.generate_MFA_challenge({ method: 'email' }),
281
+ { shouldError: true, onError: (e: any) => e.statusCode === 403 || /not permitted/i.test(e.message || '') }
282
+ )
283
+
284
+ await set_allowed_mfa_methods(['email'])
285
+
286
+ await async_test(
287
+ 'E3. restricted to email: begin_TOTP_configuration -> 403',
288
+ () => sdkNonAdmin.api.users.begin_TOTP_configuration({}),
289
+ { shouldError: true, onError: (e: any) => e.statusCode === 403 || /not permitted/i.test(e.message || '') }
290
+ )
291
+
292
+ await async_test(
293
+ 'E4. restricted to email: enabling email MFA succeeds (first method -> 10 recovery codes)',
294
+ () => sdkNonAdmin.api.users.configure_MFA({}),
295
+ { shouldError: false, onResult: (r: any) => {
296
+ if (r.authToken) sdkNonAdmin.handle_new_session({ ...r.user, authToken: r.authToken } as any)
297
+ return r.recoveryCodes?.length === 10 && r.user?.mfa?.email === true
298
+ }}
299
+ )
300
+
301
+ // grace: email is configured, then the user is restricted to authenticator-only —
302
+ // the already-configured email method must keep working (no lockout)
303
+ await set_allowed_mfa_methods(['authenticator'])
304
+
305
+ await async_test(
306
+ 'E5. grace: configured email challenge still works after restriction',
307
+ () => sdkNonAdmin.api.users.generate_MFA_challenge({ method: 'email' }),
308
+ { shouldError: false, onResult: () => true }
309
+ )
310
+
311
+ // cleanup E: clear restriction + disable email
312
+ await clear_allowed_mfa_methods()
313
+ await async_test(
314
+ 'E6. cleanup: disable email',
315
+ () => sdkNonAdmin.api.users.configure_MFA({ disable: true, method: 'email' }),
316
+ { shouldError: false, onResult: (r: any) => {
317
+ if (r.authToken) sdkNonAdmin.handle_new_session({ ...r.user, authToken: r.authToken } as any)
318
+ return true
319
+ }}
320
+ )
321
+
322
+ // ============================================================
323
+ // F. allowedMFAMethods is admin-only
324
+ // ============================================================
325
+ log_header("F. allowedMFAMethods admin gate")
326
+
327
+ await async_test(
328
+ 'F1. non-admin cannot update allowedMFAMethods',
329
+ () => sdkNonAdmin.api.users.updateOne(nonAdminId, { allowedMFAMethods: ['email'] } as any, { replaceObjectFields: true }),
330
+ { shouldError: true, onError: (e: any) => /admin/i.test(e.message || '') || e.statusCode === 400 || e.statusCode === 403 }
331
+ )
332
+
333
+ await async_test(
334
+ 'F2. admin can update allowedMFAMethods',
335
+ () => sdk.api.users.updateOne(nonAdminId, { allowedMFAMethods: ['email'] } as any, { replaceObjectFields: true }),
336
+ { shouldError: false, onResult: () => true }
337
+ )
338
+ await wait(undefined, 250)
339
+ await async_test(
340
+ 'F3. allowedMFAMethods persisted',
341
+ () => sdk.api.users.getOne(nonAdminId),
342
+ { shouldError: false, onResult: (u: any) => JSON.stringify(u.allowedMFAMethods) === JSON.stringify(['email']) }
343
+ )
344
+ await clear_allowed_mfa_methods()
345
+
346
+ // ============================================================
347
+ // G. Brute-force rate limit (runs last: exhausts the 10/10min submit bucket)
348
+ // ============================================================
349
+ log_header("G. Brute-force rate limit")
350
+
351
+ let saw429 = false
352
+ for (let i = 0; i < 12 && !saw429; i++) {
353
+ await wait(undefined, 600) // stay under the 1/500ms bucket so only the 10/10min bucket trips
354
+ try {
355
+ await sdkNonAdmin.api.users.submit_MFA_challenge({ code: '123456' })
356
+ } catch (e: any) {
357
+ if (e.statusCode === 429 || /rate limit/i.test(e.message || '')) saw429 = true
358
+ }
359
+ }
360
+ assert(saw429, 'expected 429 from repeated bad MFA submissions', 'G1. repeated bad codes hit 10/10min rate limit')
361
+ } finally {
362
+ await reset_mfa_state()
363
+ }
364
+ }
365
+
366
+ // Allow running this test file independently
367
+ if (require.main === module) {
368
+ const sdk = new Session({ host })
369
+ const sdkNonAdmin = new Session({ host })
370
+
371
+ const runTests = async () => {
372
+ await setup_tests(sdk, sdkNonAdmin)
373
+ await totp_mfa_tests({ sdk, sdkNonAdmin })
374
+ }
375
+
376
+ runTests()
377
+ .then(() => {
378
+ console.log("✅ TOTP MFA test suite completed successfully")
379
+ process.exit(0)
380
+ })
381
+ .catch((error) => {
382
+ console.error("❌ TOTP MFA test suite failed:", error)
383
+ process.exit(1)
384
+ })
385
+ }
@@ -133,6 +133,7 @@ import { mdi_case_offerings_tests } from "./api_tests/mdi_case_offerings.test";
133
133
  import { beluga_manual_sync_tests } from "./api_tests/beluga_manual_sync.test";
134
134
  import { mdi_webhooks_tests } from "./api_tests/mdi_webhooks.test";
135
135
  import { account_switcher_tests } from "./api_tests/account_switcher.test";
136
+ import { totp_mfa_tests } from "./api_tests/totp_mfa.test";
136
137
  import { set_fields_order_templates_tests } from "./api_tests/set_fields_order_templates.test";
137
138
  import { date_string_validation_tests } from "./api_tests/date_string_validation.test";
138
139
  import { enduser_session_invalidation_tests } from "./api_tests/enduser_session_invalidation.test";
@@ -15072,6 +15073,7 @@ const ip_address_form_tests = async () => {
15072
15073
  await sanitize_user_html_xss_tests()
15073
15074
  await prototype_pollution_tests()
15074
15075
  await account_switcher_tests({ sdk, sdkNonAdmin })
15076
+ await totp_mfa_tests({ sdk, sdkNonAdmin })
15075
15077
  await enduser_login_tests({ sdk, sdkNonAdmin })
15076
15078
  await outbound_chat_sent_trigger_tests({ sdk })
15077
15079
  await enduser_cross_access_isolation_tests({ sdk, sdkNonAdmin })
Binary file