@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.
- package/lib/cjs/sdk.d.ts +4 -0
- package/lib/cjs/sdk.d.ts.map +1 -1
- package/lib/cjs/sdk.js +2 -0
- package/lib/cjs/sdk.js.map +1 -1
- package/lib/cjs/tests/api_tests/totp_mfa.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/totp_mfa.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/totp_mfa.test.js +518 -0
- package/lib/cjs/tests/api_tests/totp_mfa.test.js.map +1 -0
- package/lib/cjs/tests/tests.d.ts.map +1 -1
- package/lib/cjs/tests/tests.js +149 -145
- package/lib/cjs/tests/tests.js.map +1 -1
- package/lib/esm/sdk.d.ts +4 -0
- package/lib/esm/sdk.d.ts.map +1 -1
- package/lib/esm/sdk.js +2 -0
- package/lib/esm/sdk.js.map +1 -1
- package/lib/esm/tests/api_tests/totp_mfa.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/totp_mfa.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/totp_mfa.test.js +511 -0
- package/lib/esm/tests/api_tests/totp_mfa.test.js.map +1 -0
- package/lib/esm/tests/tests.d.ts.map +1 -1
- package/lib/esm/tests/tests.js +149 -145
- package/lib/esm/tests/tests.js.map +1 -1
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +8 -8
- package/src/sdk.ts +8 -0
- package/src/tests/api_tests/totp_mfa.test.ts +385 -0
- package/src/tests/tests.ts +2 -0
- package/test_generated.pdf +0 -0
|
@@ -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
|
+
}
|
package/src/tests/tests.ts
CHANGED
|
@@ -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 })
|
package/test_generated.pdf
CHANGED
|
Binary file
|