From 58ed8e9fe8df99b319ed474e448839edd2f4a20e Mon Sep 17 00:00:00 2001 From: Adrien Zaganelli Date: Mon, 7 Oct 2024 02:45:47 +0200 Subject: [PATCH] feat(email-verification): rate-limit --- README.md | 6 +- package.json | 3 +- pnpm-lock.yaml | 146 +++++++----------- src/runtime/core/core.ts | 47 +++++- src/runtime/core/errors/SlipAuthError.ts | 51 ++++-- src/runtime/core/errors/SlipAuthErrorsCode.ts | 4 + .../core/rate-limit/SlipAuthRateLimiters.ts | 15 +- src/runtime/core/rate-limit/Throttler.ts | 2 +- tests/rate-limit.test.ts | 119 +++++++++++--- 9 files changed, 254 insertions(+), 139 deletions(-) diff --git a/README.md b/README.md index 2152765..739c92c 100644 --- a/README.md +++ b/README.md @@ -320,11 +320,11 @@ You should have your migrations in the migrations folder. - [x] Email + Password - [x] forgot password - [x] reset password - - [ ] rate-limit login - - [ ] rate-limit email verification + - [x] rate-limit login + - [x] rate-limit email verification - [ ] rate-limit forgot password - [ ] rate-limit reset password - - [ ] rate limit register + - [x] ~~rate limit register~~ (rate-limit ask email verification) - [ ] error message strategy (email already taken, etc) - [ ] oauth accounts linking - [ ] ~~Ihavebeenpwnd plugin~~ diff --git a/package.json b/package.json index 7520779..d0dcae4 100644 --- a/package.json +++ b/package.json @@ -49,8 +49,7 @@ "drizzle-orm": "^0.33.0", "drizzle-schema-checker": "^1.2.0", "node-ipinfo": "^3.5.3", - "oslo": "^1.2.1", - "rate-limiter-flexible": "^5.0.3" + "oslo": "^1.2.1" }, "devDependencies": { "@nuxt/devtools": "^1.5.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 38dce2e..68c78fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,7 +13,7 @@ importers: version: 1.8.3 '@nuxt/kit': specifier: ^3.13.1 - version: 3.13.2(magicast@0.3.5)(rollup@3.29.5) + version: 3.13.2(magicast@0.3.5)(rollup@4.22.4) consola: specifier: ^3.2.3 version: 3.2.3 @@ -32,25 +32,22 @@ importers: oslo: specifier: ^1.2.1 version: 1.2.1 - rate-limiter-flexible: - specifier: ^5.0.3 - version: 5.0.3 devDependencies: '@nuxt/devtools': specifier: ^1.5.2 - version: 1.5.2(rollup@3.29.5)(vite@5.4.7(@types/node@20.16.6)(terser@5.33.0))(vue@3.5.8(typescript@5.6.2)) + version: 1.5.2(rollup@4.22.4)(vite@5.4.7(@types/node@20.16.6)(terser@5.33.0))(vue@3.5.8(typescript@5.6.2)) '@nuxt/eslint-config': specifier: ^0.5.7 version: 0.5.7(eslint@9.11.1(jiti@1.21.6))(typescript@5.6.2) '@nuxt/module-builder': specifier: ^0.8.4 - version: 0.8.4(@nuxt/kit@3.13.2(magicast@0.3.5)(rollup@3.29.5))(nuxi@3.13.2)(typescript@5.6.2)(vue-tsc@2.1.6(typescript@5.6.2)) + version: 0.8.4(@nuxt/kit@3.13.2(magicast@0.3.5)(rollup@4.22.4))(nuxi@3.13.2)(typescript@5.6.2)(vue-tsc@2.1.6(typescript@5.6.2)) '@nuxt/schema': specifier: ^3.13.1 - version: 3.13.2(rollup@3.29.5) + version: 3.13.2(rollup@4.22.4) '@nuxt/test-utils': specifier: ^3.14.2 - version: 3.14.2(h3@1.12.0)(magicast@0.3.5)(nitropack@2.9.7(@libsql/client@0.11.0)(better-sqlite3@11.3.0)(drizzle-orm@0.33.0(@libsql/client@0.11.0)(better-sqlite3@11.3.0))(magicast@0.3.5))(rollup@3.29.5)(vite@5.4.7(@types/node@20.16.6)(terser@5.33.0))(vitest@2.1.2(@types/node@20.16.6)(terser@5.33.0))(vue-router@4.4.5(vue@3.5.8(typescript@5.6.2)))(vue@3.5.8(typescript@5.6.2)) + version: 3.14.2(h3@1.12.0)(magicast@0.3.5)(nitropack@2.9.7(@libsql/client@0.11.0)(better-sqlite3@11.3.0)(drizzle-orm@0.33.0(@libsql/client@0.11.0)(better-sqlite3@11.3.0))(magicast@0.3.5))(rollup@4.22.4)(vite@5.4.7(@types/node@20.16.6)(terser@5.33.0))(vitest@2.1.2(@types/node@20.16.6)(terser@5.33.0))(vue-router@4.4.5(vue@3.5.8(typescript@5.6.2)))(vue@3.5.8(typescript@5.6.2)) '@types/node': specifier: ^20.16.5 version: 20.16.6 @@ -77,10 +74,10 @@ importers: version: 5.5.3 nuxt: specifier: ^3.13.2 - version: 3.13.2(@libsql/client@0.11.0)(@parcel/watcher@2.4.1)(@types/node@20.16.6)(better-sqlite3@11.3.0)(drizzle-orm@0.33.0(@libsql/client@0.11.0)(better-sqlite3@11.3.0))(eslint@9.11.1(jiti@1.21.6))(ioredis@5.4.1)(magicast@0.3.5)(optionator@0.9.4)(rollup@3.29.5)(terser@5.33.0)(typescript@5.6.2)(vite@5.4.7(@types/node@20.16.6)(terser@5.33.0))(vue-tsc@2.1.6(typescript@5.6.2)) + version: 3.13.2(@libsql/client@0.11.0)(@parcel/watcher@2.4.1)(@types/node@20.16.6)(better-sqlite3@11.3.0)(drizzle-orm@0.33.0(@libsql/client@0.11.0)(better-sqlite3@11.3.0))(eslint@9.11.1(jiti@1.21.6))(ioredis@5.4.1)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.22.4)(terser@5.33.0)(typescript@5.6.2)(vite@5.4.7(@types/node@20.16.6)(terser@5.33.0))(vue-tsc@2.1.6(typescript@5.6.2)) nuxt-auth-utils: specifier: ^0.4.2 - version: 0.4.2(magicast@0.3.5)(rollup@3.29.5) + version: 0.4.2(magicast@0.3.5)(rollup@4.22.4) pkg-pr-new: specifier: ^0.0.29 version: 0.0.29 @@ -4146,9 +4143,6 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} - rate-limiter-flexible@5.0.3: - resolution: {integrity: sha512-lWx2y8NBVlTOLPyqs+6y7dxfEpT6YFqKy3MzWbCy95sTTOhOuxufP2QvRyOHpfXpB9OUJPbVLybw3z3AVAS5fA==} - rc9@2.1.2: resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} @@ -6028,10 +6022,10 @@ snapshots: '@nuxt/devalue@2.0.2': {} - '@nuxt/devtools-kit@1.5.2(magicast@0.3.5)(rollup@3.29.5)(vite@5.4.7(@types/node@20.16.6)(terser@5.33.0))': + '@nuxt/devtools-kit@1.5.2(magicast@0.3.5)(rollup@4.22.4)(vite@5.4.7(@types/node@20.16.6)(terser@5.33.0))': dependencies: - '@nuxt/kit': 3.13.2(magicast@0.3.5)(rollup@3.29.5) - '@nuxt/schema': 3.13.2(rollup@3.29.5) + '@nuxt/kit': 3.13.2(magicast@0.3.5)(rollup@4.22.4) + '@nuxt/schema': 3.13.2(rollup@4.22.4) execa: 7.2.0 vite: 5.4.7(@types/node@20.16.6)(terser@5.33.0) transitivePeerDependencies: @@ -6053,12 +6047,12 @@ snapshots: rc9: 2.1.2 semver: 7.6.3 - '@nuxt/devtools@1.5.2(rollup@3.29.5)(vite@5.4.7(@types/node@20.16.6)(terser@5.33.0))(vue@3.5.8(typescript@5.6.2))': + '@nuxt/devtools@1.5.2(rollup@4.22.4)(vite@5.4.7(@types/node@20.16.6)(terser@5.33.0))(vue@3.5.8(typescript@5.6.2))': dependencies: '@antfu/utils': 0.7.10 - '@nuxt/devtools-kit': 1.5.2(magicast@0.3.5)(rollup@3.29.5)(vite@5.4.7(@types/node@20.16.6)(terser@5.33.0)) + '@nuxt/devtools-kit': 1.5.2(magicast@0.3.5)(rollup@4.22.4)(vite@5.4.7(@types/node@20.16.6)(terser@5.33.0)) '@nuxt/devtools-wizard': 1.5.2 - '@nuxt/kit': 3.13.2(magicast@0.3.5)(rollup@3.29.5) + '@nuxt/kit': 3.13.2(magicast@0.3.5)(rollup@4.22.4) '@vue/devtools-core': 7.4.4(vite@5.4.7(@types/node@20.16.6)(terser@5.33.0))(vue@3.5.8(typescript@5.6.2)) '@vue/devtools-kit': 7.4.4 birpc: 0.2.17 @@ -6087,9 +6081,9 @@ snapshots: simple-git: 3.27.0 sirv: 2.0.4 tinyglobby: 0.2.6 - unimport: 3.12.0(rollup@3.29.5) + unimport: 3.12.0(rollup@4.22.4) vite: 5.4.7(@types/node@20.16.6)(terser@5.33.0) - vite-plugin-inspect: 0.8.7(@nuxt/kit@3.13.2(magicast@0.3.5)(rollup@3.29.5))(rollup@3.29.5)(vite@5.4.7(@types/node@20.16.6)(terser@5.33.0)) + vite-plugin-inspect: 0.8.7(@nuxt/kit@3.13.2(magicast@0.3.5)(rollup@4.22.4))(rollup@4.22.4)(vite@5.4.7(@types/node@20.16.6)(terser@5.33.0)) vite-plugin-vue-inspector: 5.1.3(vite@5.4.7(@types/node@20.16.6)(terser@5.33.0)) which: 3.0.1 ws: 8.18.0 @@ -6133,9 +6127,9 @@ snapshots: - supports-color - typescript - '@nuxt/kit@3.13.2(magicast@0.3.5)(rollup@3.29.5)': + '@nuxt/kit@3.13.2(magicast@0.3.5)(rollup@4.22.4)': dependencies: - '@nuxt/schema': 3.13.2(rollup@3.29.5) + '@nuxt/schema': 3.13.2(rollup@4.22.4) c12: 1.11.2(magicast@0.3.5) consola: 3.2.3 defu: 6.1.4 @@ -6153,7 +6147,7 @@ snapshots: semver: 7.6.3 ufo: 1.5.4 unctx: 2.3.1 - unimport: 3.12.0(rollup@3.29.5) + unimport: 3.12.0(rollup@4.22.4) untyped: 1.4.2 transitivePeerDependencies: - magicast @@ -6161,9 +6155,9 @@ snapshots: - supports-color - webpack-sources - '@nuxt/module-builder@0.8.4(@nuxt/kit@3.13.2(magicast@0.3.5)(rollup@3.29.5))(nuxi@3.13.2)(typescript@5.6.2)(vue-tsc@2.1.6(typescript@5.6.2))': + '@nuxt/module-builder@0.8.4(@nuxt/kit@3.13.2(magicast@0.3.5)(rollup@4.22.4))(nuxi@3.13.2)(typescript@5.6.2)(vue-tsc@2.1.6(typescript@5.6.2))': dependencies: - '@nuxt/kit': 3.13.2(magicast@0.3.5)(rollup@3.29.5) + '@nuxt/kit': 3.13.2(magicast@0.3.5)(rollup@4.22.4) citty: 0.1.6 consola: 3.2.3 defu: 6.1.4 @@ -6181,7 +6175,7 @@ snapshots: - vue-tsc - webpack-sources - '@nuxt/schema@3.13.2(rollup@3.29.5)': + '@nuxt/schema@3.13.2(rollup@4.22.4)': dependencies: compatx: 0.1.8 consola: 3.2.3 @@ -6193,16 +6187,16 @@ snapshots: std-env: 3.7.0 ufo: 1.5.4 uncrypto: 0.1.3 - unimport: 3.12.0(rollup@3.29.5) + unimport: 3.12.0(rollup@4.22.4) untyped: 1.4.2 transitivePeerDependencies: - rollup - supports-color - webpack-sources - '@nuxt/telemetry@2.6.0(magicast@0.3.5)(rollup@3.29.5)': + '@nuxt/telemetry@2.6.0(magicast@0.3.5)(rollup@4.22.4)': dependencies: - '@nuxt/kit': 3.13.2(magicast@0.3.5)(rollup@3.29.5) + '@nuxt/kit': 3.13.2(magicast@0.3.5)(rollup@4.22.4) ci-info: 4.0.0 consola: 3.2.3 create-require: 1.1.1 @@ -6226,10 +6220,10 @@ snapshots: - supports-color - webpack-sources - '@nuxt/test-utils@3.14.2(h3@1.12.0)(magicast@0.3.5)(nitropack@2.9.7(@libsql/client@0.11.0)(better-sqlite3@11.3.0)(drizzle-orm@0.33.0(@libsql/client@0.11.0)(better-sqlite3@11.3.0))(magicast@0.3.5))(rollup@3.29.5)(vite@5.4.7(@types/node@20.16.6)(terser@5.33.0))(vitest@2.1.2(@types/node@20.16.6)(terser@5.33.0))(vue-router@4.4.5(vue@3.5.8(typescript@5.6.2)))(vue@3.5.8(typescript@5.6.2))': + '@nuxt/test-utils@3.14.2(h3@1.12.0)(magicast@0.3.5)(nitropack@2.9.7(@libsql/client@0.11.0)(better-sqlite3@11.3.0)(drizzle-orm@0.33.0(@libsql/client@0.11.0)(better-sqlite3@11.3.0))(magicast@0.3.5))(rollup@4.22.4)(vite@5.4.7(@types/node@20.16.6)(terser@5.33.0))(vitest@2.1.2(@types/node@20.16.6)(terser@5.33.0))(vue-router@4.4.5(vue@3.5.8(typescript@5.6.2)))(vue@3.5.8(typescript@5.6.2))': dependencies: - '@nuxt/kit': 3.13.2(magicast@0.3.5)(rollup@3.29.5) - '@nuxt/schema': 3.13.2(rollup@3.29.5) + '@nuxt/kit': 3.13.2(magicast@0.3.5)(rollup@4.22.4) + '@nuxt/schema': 3.13.2(rollup@4.22.4) c12: 1.11.2(magicast@0.3.5) consola: 3.2.3 defu: 6.1.4 @@ -6253,7 +6247,7 @@ snapshots: unenv: 1.10.0 unplugin: 1.14.1 vite: 5.4.7(@types/node@20.16.6)(terser@5.33.0) - vitest-environment-nuxt: 1.0.1(h3@1.12.0)(magicast@0.3.5)(nitropack@2.9.7(@libsql/client@0.11.0)(better-sqlite3@11.3.0)(drizzle-orm@0.33.0(@libsql/client@0.11.0)(better-sqlite3@11.3.0))(magicast@0.3.5))(rollup@3.29.5)(vite@5.4.7(@types/node@20.16.6)(terser@5.33.0))(vitest@2.1.2(@types/node@20.16.6)(terser@5.33.0))(vue-router@4.4.5(vue@3.5.8(typescript@5.6.2)))(vue@3.5.8(typescript@5.6.2)) + vitest-environment-nuxt: 1.0.1(h3@1.12.0)(magicast@0.3.5)(nitropack@2.9.7(@libsql/client@0.11.0)(better-sqlite3@11.3.0)(drizzle-orm@0.33.0(@libsql/client@0.11.0)(better-sqlite3@11.3.0))(magicast@0.3.5))(rollup@4.22.4)(vite@5.4.7(@types/node@20.16.6)(terser@5.33.0))(vitest@2.1.2(@types/node@20.16.6)(terser@5.33.0))(vue-router@4.4.5(vue@3.5.8(typescript@5.6.2)))(vue@3.5.8(typescript@5.6.2)) vue: 3.5.8(typescript@5.6.2) vue-router: 4.4.5(vue@3.5.8(typescript@5.6.2)) optionalDependencies: @@ -6264,10 +6258,10 @@ snapshots: - supports-color - webpack-sources - '@nuxt/vite-builder@3.13.2(@types/node@20.16.6)(eslint@9.11.1(jiti@1.21.6))(magicast@0.3.5)(optionator@0.9.4)(rollup@3.29.5)(terser@5.33.0)(typescript@5.6.2)(vue-tsc@2.1.6(typescript@5.6.2))(vue@3.5.8(typescript@5.6.2))': + '@nuxt/vite-builder@3.13.2(@types/node@20.16.6)(eslint@9.11.1(jiti@1.21.6))(magicast@0.3.5)(optionator@0.9.4)(rollup@4.22.4)(terser@5.33.0)(typescript@5.6.2)(vue-tsc@2.1.6(typescript@5.6.2))(vue@3.5.8(typescript@5.6.2))': dependencies: - '@nuxt/kit': 3.13.2(magicast@0.3.5)(rollup@3.29.5) - '@rollup/plugin-replace': 5.0.7(rollup@3.29.5) + '@nuxt/kit': 3.13.2(magicast@0.3.5)(rollup@4.22.4) + '@rollup/plugin-replace': 5.0.7(rollup@4.22.4) '@vitejs/plugin-vue': 5.1.4(vite@5.4.7(@types/node@20.16.6)(terser@5.33.0))(vue@3.5.8(typescript@5.6.2)) '@vitejs/plugin-vue-jsx': 4.0.1(vite@5.4.7(@types/node@20.16.6)(terser@5.33.0))(vue@3.5.8(typescript@5.6.2)) autoprefixer: 10.4.20(postcss@8.4.47) @@ -6289,7 +6283,7 @@ snapshots: perfect-debounce: 1.0.0 pkg-types: 1.2.0 postcss: 8.4.47 - rollup-plugin-visualizer: 5.12.0(rollup@3.29.5) + rollup-plugin-visualizer: 5.12.0(rollup@4.22.4) std-env: 3.7.0 strip-literal: 2.1.0 ufo: 1.5.4 @@ -6906,10 +6900,10 @@ snapshots: path-browserify: 1.0.1 vscode-uri: 3.0.8 - '@vue-macros/common@1.14.0(rollup@3.29.5)(vue@3.5.8(typescript@5.6.2))': + '@vue-macros/common@1.14.0(rollup@4.22.4)(vue@3.5.8(typescript@5.6.2))': dependencies: '@babel/types': 7.25.6 - '@rollup/pluginutils': 5.1.2(rollup@3.29.5) + '@rollup/pluginutils': 5.1.2(rollup@4.22.4) '@vue/compiler-sfc': 3.5.8 ast-kit: 1.2.0 local-pkg: 0.5.0 @@ -8333,9 +8327,9 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 - impound@0.1.0(rollup@3.29.5): + impound@0.1.0(rollup@4.22.4): dependencies: - '@rollup/pluginutils': 5.1.2(rollup@3.29.5) + '@rollup/pluginutils': 5.1.2(rollup@4.22.4) mlly: 1.7.1 pathe: 1.1.2 unenv: 1.10.0 @@ -8923,10 +8917,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - nuxt-auth-utils@0.4.2(magicast@0.3.5)(rollup@3.29.5): + nuxt-auth-utils@0.4.2(magicast@0.3.5)(rollup@4.22.4): dependencies: '@adonisjs/hash': 9.0.5 - '@nuxt/kit': 3.13.2(magicast@0.3.5)(rollup@3.29.5) + '@nuxt/kit': 3.13.2(magicast@0.3.5)(rollup@4.22.4) defu: 6.1.4 hookable: 5.5.3 ofetch: 1.4.0 @@ -8942,14 +8936,14 @@ snapshots: - supports-color - webpack-sources - nuxt@3.13.2(@libsql/client@0.11.0)(@parcel/watcher@2.4.1)(@types/node@20.16.6)(better-sqlite3@11.3.0)(drizzle-orm@0.33.0(@libsql/client@0.11.0)(better-sqlite3@11.3.0))(eslint@9.11.1(jiti@1.21.6))(ioredis@5.4.1)(magicast@0.3.5)(optionator@0.9.4)(rollup@3.29.5)(terser@5.33.0)(typescript@5.6.2)(vite@5.4.7(@types/node@20.16.6)(terser@5.33.0))(vue-tsc@2.1.6(typescript@5.6.2)): + nuxt@3.13.2(@libsql/client@0.11.0)(@parcel/watcher@2.4.1)(@types/node@20.16.6)(better-sqlite3@11.3.0)(drizzle-orm@0.33.0(@libsql/client@0.11.0)(better-sqlite3@11.3.0))(eslint@9.11.1(jiti@1.21.6))(ioredis@5.4.1)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.22.4)(terser@5.33.0)(typescript@5.6.2)(vite@5.4.7(@types/node@20.16.6)(terser@5.33.0))(vue-tsc@2.1.6(typescript@5.6.2)): dependencies: '@nuxt/devalue': 2.0.2 - '@nuxt/devtools': 1.5.2(rollup@3.29.5)(vite@5.4.7(@types/node@20.16.6)(terser@5.33.0))(vue@3.5.8(typescript@5.6.2)) - '@nuxt/kit': 3.13.2(magicast@0.3.5)(rollup@3.29.5) - '@nuxt/schema': 3.13.2(rollup@3.29.5) - '@nuxt/telemetry': 2.6.0(magicast@0.3.5)(rollup@3.29.5) - '@nuxt/vite-builder': 3.13.2(@types/node@20.16.6)(eslint@9.11.1(jiti@1.21.6))(magicast@0.3.5)(optionator@0.9.4)(rollup@3.29.5)(terser@5.33.0)(typescript@5.6.2)(vue-tsc@2.1.6(typescript@5.6.2))(vue@3.5.8(typescript@5.6.2)) + '@nuxt/devtools': 1.5.2(rollup@4.22.4)(vite@5.4.7(@types/node@20.16.6)(terser@5.33.0))(vue@3.5.8(typescript@5.6.2)) + '@nuxt/kit': 3.13.2(magicast@0.3.5)(rollup@4.22.4) + '@nuxt/schema': 3.13.2(rollup@4.22.4) + '@nuxt/telemetry': 2.6.0(magicast@0.3.5)(rollup@4.22.4) + '@nuxt/vite-builder': 3.13.2(@types/node@20.16.6)(eslint@9.11.1(jiti@1.21.6))(magicast@0.3.5)(optionator@0.9.4)(rollup@4.22.4)(terser@5.33.0)(typescript@5.6.2)(vue-tsc@2.1.6(typescript@5.6.2))(vue@3.5.8(typescript@5.6.2)) '@unhead/dom': 1.11.6 '@unhead/shared': 1.11.6 '@unhead/ssr': 1.11.6 @@ -8972,7 +8966,7 @@ snapshots: h3: 1.12.0 hookable: 5.5.3 ignore: 5.3.2 - impound: 0.1.0(rollup@3.29.5) + impound: 0.1.0(rollup@4.22.4) jiti: 1.21.6 klona: 2.0.6 knitwork: 1.1.0 @@ -8999,9 +8993,9 @@ snapshots: unctx: 2.3.1 unenv: 1.10.0 unhead: 1.11.6 - unimport: 3.12.0(rollup@3.29.5) + unimport: 3.12.0(rollup@4.22.4) unplugin: 1.14.1 - unplugin-vue-router: 0.10.8(rollup@3.29.5)(vue-router@4.4.5(vue@3.5.8(typescript@5.6.2)))(vue@3.5.8(typescript@5.6.2)) + unplugin-vue-router: 0.10.8(rollup@4.22.4)(vue-router@4.4.5(vue@3.5.8(typescript@5.6.2)))(vue@3.5.8(typescript@5.6.2)) unstorage: 1.12.0(ioredis@5.4.1) untyped: 1.4.2 vue: 3.5.8(typescript@5.6.2) @@ -9464,8 +9458,6 @@ snapshots: range-parser@1.2.1: {} - rate-limiter-flexible@5.0.3: {} - rc9@2.1.2: dependencies: defu: 6.1.4 @@ -9574,15 +9566,6 @@ snapshots: optionalDependencies: '@babel/code-frame': 7.24.7 - rollup-plugin-visualizer@5.12.0(rollup@3.29.5): - dependencies: - open: 8.4.2 - picomatch: 2.3.1 - source-map: 0.7.4 - yargs: 17.7.2 - optionalDependencies: - rollup: 3.29.5 - rollup-plugin-visualizer@5.12.0(rollup@4.22.4): dependencies: open: 8.4.2 @@ -10061,25 +10044,6 @@ snapshots: unicorn-magic@0.1.0: {} - unimport@3.12.0(rollup@3.29.5): - dependencies: - '@rollup/pluginutils': 5.1.2(rollup@3.29.5) - acorn: 8.12.1 - escape-string-regexp: 5.0.0 - estree-walker: 3.0.3 - fast-glob: 3.3.2 - local-pkg: 0.5.0 - magic-string: 0.30.11 - mlly: 1.7.1 - pathe: 1.1.2 - pkg-types: 1.2.0 - scule: 1.3.0 - strip-literal: 2.1.0 - unplugin: 1.14.1 - transitivePeerDependencies: - - rollup - - webpack-sources - unimport@3.12.0(rollup@4.22.4): dependencies: '@rollup/pluginutils': 5.1.2(rollup@4.22.4) @@ -10103,11 +10067,11 @@ snapshots: universalify@2.0.1: {} - unplugin-vue-router@0.10.8(rollup@3.29.5)(vue-router@4.4.5(vue@3.5.8(typescript@5.6.2)))(vue@3.5.8(typescript@5.6.2)): + unplugin-vue-router@0.10.8(rollup@4.22.4)(vue-router@4.4.5(vue@3.5.8(typescript@5.6.2)))(vue@3.5.8(typescript@5.6.2)): dependencies: '@babel/types': 7.25.6 - '@rollup/pluginutils': 5.1.2(rollup@3.29.5) - '@vue-macros/common': 1.14.0(rollup@3.29.5)(vue@3.5.8(typescript@5.6.2)) + '@rollup/pluginutils': 5.1.2(rollup@4.22.4) + '@vue-macros/common': 1.14.0(rollup@4.22.4)(vue@3.5.8(typescript@5.6.2)) ast-walker-scope: 0.6.2 chokidar: 3.6.0 fast-glob: 3.3.2 @@ -10263,10 +10227,10 @@ snapshots: typescript: 5.6.2 vue-tsc: 2.1.6(typescript@5.6.2) - vite-plugin-inspect@0.8.7(@nuxt/kit@3.13.2(magicast@0.3.5)(rollup@3.29.5))(rollup@3.29.5)(vite@5.4.7(@types/node@20.16.6)(terser@5.33.0)): + vite-plugin-inspect@0.8.7(@nuxt/kit@3.13.2(magicast@0.3.5)(rollup@4.22.4))(rollup@4.22.4)(vite@5.4.7(@types/node@20.16.6)(terser@5.33.0)): dependencies: '@antfu/utils': 0.7.10 - '@rollup/pluginutils': 5.1.2(rollup@3.29.5) + '@rollup/pluginutils': 5.1.2(rollup@4.22.4) debug: 4.3.7 error-stack-parser-es: 0.1.5 fs-extra: 11.2.0 @@ -10276,7 +10240,7 @@ snapshots: sirv: 2.0.4 vite: 5.4.7(@types/node@20.16.6)(terser@5.33.0) optionalDependencies: - '@nuxt/kit': 3.13.2(magicast@0.3.5)(rollup@3.29.5) + '@nuxt/kit': 3.13.2(magicast@0.3.5)(rollup@4.22.4) transitivePeerDependencies: - rollup - supports-color @@ -10306,9 +10270,9 @@ snapshots: fsevents: 2.3.3 terser: 5.33.0 - vitest-environment-nuxt@1.0.1(h3@1.12.0)(magicast@0.3.5)(nitropack@2.9.7(@libsql/client@0.11.0)(better-sqlite3@11.3.0)(drizzle-orm@0.33.0(@libsql/client@0.11.0)(better-sqlite3@11.3.0))(magicast@0.3.5))(rollup@3.29.5)(vite@5.4.7(@types/node@20.16.6)(terser@5.33.0))(vitest@2.1.2(@types/node@20.16.6)(terser@5.33.0))(vue-router@4.4.5(vue@3.5.8(typescript@5.6.2)))(vue@3.5.8(typescript@5.6.2)): + vitest-environment-nuxt@1.0.1(h3@1.12.0)(magicast@0.3.5)(nitropack@2.9.7(@libsql/client@0.11.0)(better-sqlite3@11.3.0)(drizzle-orm@0.33.0(@libsql/client@0.11.0)(better-sqlite3@11.3.0))(magicast@0.3.5))(rollup@4.22.4)(vite@5.4.7(@types/node@20.16.6)(terser@5.33.0))(vitest@2.1.2(@types/node@20.16.6)(terser@5.33.0))(vue-router@4.4.5(vue@3.5.8(typescript@5.6.2)))(vue@3.5.8(typescript@5.6.2)): dependencies: - '@nuxt/test-utils': 3.14.2(h3@1.12.0)(magicast@0.3.5)(nitropack@2.9.7(@libsql/client@0.11.0)(better-sqlite3@11.3.0)(drizzle-orm@0.33.0(@libsql/client@0.11.0)(better-sqlite3@11.3.0))(magicast@0.3.5))(rollup@3.29.5)(vite@5.4.7(@types/node@20.16.6)(terser@5.33.0))(vitest@2.1.2(@types/node@20.16.6)(terser@5.33.0))(vue-router@4.4.5(vue@3.5.8(typescript@5.6.2)))(vue@3.5.8(typescript@5.6.2)) + '@nuxt/test-utils': 3.14.2(h3@1.12.0)(magicast@0.3.5)(nitropack@2.9.7(@libsql/client@0.11.0)(better-sqlite3@11.3.0)(drizzle-orm@0.33.0(@libsql/client@0.11.0)(better-sqlite3@11.3.0))(magicast@0.3.5))(rollup@4.22.4)(vite@5.4.7(@types/node@20.16.6)(terser@5.33.0))(vitest@2.1.2(@types/node@20.16.6)(terser@5.33.0))(vue-router@4.4.5(vue@3.5.8(typescript@5.6.2)))(vue@3.5.8(typescript@5.6.2)) transitivePeerDependencies: - '@cucumber/cucumber' - '@jest/globals' diff --git a/src/runtime/core/core.ts b/src/runtime/core/core.ts index a0a2cd4..4efedb6 100644 --- a/src/runtime/core/core.ts +++ b/src/runtime/core/core.ts @@ -10,7 +10,7 @@ import { EmailVerificationCodesRepository } from "./repositories/EmailVerificati import { ResetPasswordTokensRepository } from "./repositories/ResetPasswordTokensRepository"; import type { SlipAuthPublicSession } from "../types"; import { defaultIdGenerationMethod, isValidEmail, defaultEmailVerificationCodeGenerationMethod, defaultHashPasswordMethod, defaultVerifyPasswordMethod, defaultResetPasswordTokenIdMethod, defaultResetPasswordTokenHashMethod } from "./email-and-password-utils"; -import { InvalidEmailOrPasswordError, InvalidEmailToResetPasswordError, InvalidPasswordToResetError, InvalidUserIdToResetPasswordError, RateLimitLoginError, ResetPasswordTokenExpiredError, UnhandledError } from "./errors/SlipAuthError.js"; +import { EmailVerificationCodeExpiredError, EmailVerificationFailedError, InvalidEmailOrPasswordError, InvalidEmailToResetPasswordError, InvalidPasswordToResetError, InvalidUserIdToResetPasswordError, RateLimitAskEmailVerificationError, RateLimitLoginError, RateLimitVerifyEmailVerificationError, ResetPasswordTokenExpiredError, UnhandledError } from "./errors/SlipAuthError.js"; import type { Database } from "db0"; import { createDate, isWithinExpirationDate, TimeSpan } from "oslo"; import type { H3Event } from "h3"; @@ -248,7 +248,19 @@ export class SlipAuthCore { throw new Error("could not find oauth user"); } + /** + * Make sure to set the Referrer Policy tag to strict-origin (or equivalent) for any path that includes tokens to protect the tokens from referer leakage. + */ public async askEmailVerificationCode(event: H3Event, { user }: { user: SlipAuthUser }): Promise { + // rate limit any function that leads to send email + const [isNotRateLimited, rateLimitResult] = await this.#rateLimiters.askEmailVerification.check(user.id); + if (!isNotRateLimited) { + throw new RateLimitAskEmailVerificationError({ + msBeforeNext: (rateLimitResult.updatedAt + rateLimitResult.timeout * 1000) - Date.now(), + }); + } + + await this.#rateLimiters.askEmailVerification.increment(user.id); await this.#repos.emailVerificationCodes.deleteAllByUserId(user.id); await this.#repos.emailVerificationCodes.insert({ userId: user.id, @@ -259,28 +271,40 @@ export class SlipAuthCore { } // TODO: use transactions - // TODO: rate limit - public async verifyEmailVerificationCode(h3Event: H3Event, params: { user: SlipAuthUser, code: string }): Promise { + public async verifyEmailVerificationCode(h3Event: H3Event, params: { user: SlipAuthUser, code: string }): Promise { + // TODO add where clause with code + // TODO add where clause with email ? const databaseCode = await this.#repos.emailVerificationCodes.findByUserId({ userId: params.user.id }); if (!databaseCode || databaseCode.code !== params.code) { - return false; + throw new EmailVerificationFailedError(); } - this.#repos.emailVerificationCodes.deleteById(databaseCode.id); + // rate limit any function that leads to send email + const [isNotRateLimited, rateLimitResult] = await this.#rateLimiters.verifyEmailVerification.check(databaseCode.user_id); + if (!isNotRateLimited) { + throw new RateLimitVerifyEmailVerificationError({ + msBeforeNext: (rateLimitResult.updatedAt + rateLimitResult.timeout * 1000) - Date.now(), + }); + } + + this.#repos.emailVerificationCodes.deleteById(databaseCode.id); const expirationDate = databaseCode.expires_at instanceof Date ? databaseCode.expires_at : new Date(databaseCode.expires_at); const offset = expirationDate.getTimezoneOffset() * 60000; // Get local time zone offset in milliseconds const localExpirationDate = new Date(expirationDate.getTime() - offset); // Adjust for local time zone if (!isWithinExpirationDate(localExpirationDate)) { - return false; + await this.#rateLimiters.verifyEmailVerification.increment(databaseCode.user_id); + throw new EmailVerificationCodeExpiredError(); } + if (databaseCode.email !== params.user.email) { - return false; + await this.#rateLimiters.verifyEmailVerification.increment(databaseCode.user_id); + throw new EmailVerificationFailedError(); } await this.#repos.users.updateEmailVerifiedByUserId({ userId: databaseCode.user_id, value: true }); - // should recreate session if true + // All sessions should be invalidated when the email is verified (and create a new one for the current user so they stay signed in). return true; } @@ -381,6 +405,13 @@ export class SlipAuthCore { setLoginRateLimiter: (fn: () => Storage) => { this.#rateLimiters.login.storage = fn(); }, + + setAskEmailRateLimiter: (fn: () => Storage) => { + this.#rateLimiters.askEmailVerification.storage = fn(); + }, + setVerifyEmailRateLimiter: (fn: () => Storage) => { + this.#rateLimiters.verifyEmailVerification.storage = fn(); + }, }; public getUser({ userId }: { userId: string }) { diff --git a/src/runtime/core/errors/SlipAuthError.ts b/src/runtime/core/errors/SlipAuthError.ts index 621effa..308bedb 100644 --- a/src/runtime/core/errors/SlipAuthError.ts +++ b/src/runtime/core/errors/SlipAuthError.ts @@ -1,7 +1,8 @@ import { SlipAuthErrorsCode } from "./SlipAuthErrorsCode"; export class SlipAuthError extends Error { - slipError!: SlipAuthErrorsCode; + slipErrorCode!: SlipAuthErrorsCode; + slipErrorName!: string; }; export class SlipAuthRateLimiterError extends SlipAuthError { @@ -16,13 +17,13 @@ export class SlipAuthRateLimiterError extends SlipAuthError { } export class UnhandledError extends SlipAuthError { - override name = "InvalidEmailOrPasswordError"; - override slipError = SlipAuthErrorsCode.Unhandled; + override slipErrorName = "UnhandledError"; + override slipErrorCode = SlipAuthErrorsCode.Unhandled; } export class InvalidEmailOrPasswordError extends SlipAuthError { - override slipError = SlipAuthErrorsCode.InvalidEmailOrPassword; - override name = "InvalidEmailOrPasswordError"; + override slipErrorCode = SlipAuthErrorsCode.InvalidEmailOrPassword; + override slipErrorName = "InvalidEmailOrPasswordError"; // eslint-disable-next-line no-unused-private-class-members #debugReason: string; @@ -33,25 +34,45 @@ export class InvalidEmailOrPasswordError extends SlipAuthError { } export class InvalidEmailToResetPasswordError extends SlipAuthError { - override name = "InvalidEmailToResetPasswordError"; - override slipError = SlipAuthErrorsCode.InvalidEmailToResetPassword; + override slipErrorName = "InvalidEmailToResetPasswordError"; + override slipErrorCode = SlipAuthErrorsCode.InvalidEmailToResetPassword; +} + +export class EmailVerificationFailedError extends SlipAuthError { + override slipErrorName = "EmailVerificationFailedError"; + override slipErrorCode = SlipAuthErrorsCode.EmailVerificationFailedError; +} + +export class EmailVerificationCodeExpiredError extends SlipAuthError { + override slipErrorName = "EmailVerificationCodeExpiredError"; + override slipErrorCode = SlipAuthErrorsCode.EmailVerificationCodeExpired; } export class InvalidUserIdToResetPasswordError extends SlipAuthError { - override name = "InvalidUserIdToResetPasswordError"; - override slipError = SlipAuthErrorsCode.InvalidUserIdToResetPassword; + override slipErrorName = "InvalidUserIdToResetPasswordError"; + override slipErrorCode = SlipAuthErrorsCode.InvalidUserIdToResetPassword; } export class InvalidPasswordToResetError extends SlipAuthError { - override name = "InvalidPasswordToResetError"; - override slipError = SlipAuthErrorsCode.InvalidPasswordToReset; + override slipErrorName = "InvalidPasswordToResetError"; + override slipErrorCode = SlipAuthErrorsCode.InvalidPasswordToReset; } export class ResetPasswordTokenExpiredError extends SlipAuthError { - override name = "ResetPasswordTokenExpiredError"; - override slipError = SlipAuthErrorsCode.ResetPasswordTokenExpired; + override slipErrorName = "ResetPasswordTokenExpiredError"; + override slipErrorCode = SlipAuthErrorsCode.ResetPasswordTokenExpired; } export class RateLimitLoginError extends SlipAuthRateLimiterError { - override name = "RateLimitLoginError"; - override slipError = SlipAuthErrorsCode.RateLimitLogin; + override slipErrorName = "RateLimitLoginError"; + override slipErrorCode = SlipAuthErrorsCode.RateLimitLogin; +} + +export class RateLimitAskEmailVerificationError extends SlipAuthRateLimiterError { + override slipErrorName = "RateLimitAskEmailVerificationError"; + override slipErrorCode = SlipAuthErrorsCode.RateLimitAskEmailVerification; +} + +export class RateLimitVerifyEmailVerificationError extends SlipAuthRateLimiterError { + override slipErrorName = "RateLimitVerifyEmailVerificationError"; + override slipErrorCode = SlipAuthErrorsCode.RateLimitVerifyEmailVerification; } diff --git a/src/runtime/core/errors/SlipAuthErrorsCode.ts b/src/runtime/core/errors/SlipAuthErrorsCode.ts index 99eebac..834c053 100644 --- a/src/runtime/core/errors/SlipAuthErrorsCode.ts +++ b/src/runtime/core/errors/SlipAuthErrorsCode.ts @@ -1,9 +1,13 @@ export enum SlipAuthErrorsCode { Unhandled = "Unhandled", InvalidEmailOrPassword = "InvalidEmailOrPassword", + EmailVerificationFailedError = "EmailVerificationFailed", + EmailVerificationCodeExpired = "EmailVerificationCodeExpired", InvalidEmailToResetPassword = "InvalidEmailToResetPassword", InvalidUserIdToResetPassword = "InvalidUserIdToResetPassword", InvalidPasswordToReset = "InvalidPasswordToReset", ResetPasswordTokenExpired = "ResetPasswordTokenExpired", RateLimitLogin = "RateLimitLogin", + RateLimitAskEmailVerification = "RateLimitAskEmailVerification", + RateLimitVerifyEmailVerification = "RateLimitVerifyEmailVerification", } diff --git a/src/runtime/core/rate-limit/SlipAuthRateLimiters.ts b/src/runtime/core/rate-limit/SlipAuthRateLimiters.ts index 5bd1d27..dc6850c 100644 --- a/src/runtime/core/rate-limit/SlipAuthRateLimiters.ts +++ b/src/runtime/core/rate-limit/SlipAuthRateLimiters.ts @@ -1,12 +1,25 @@ import { Throttler, createThrottlerStorage } from "./Throttler"; +import { prefixStorage } from "unstorage"; export class SlipAuthRateLimiters { login: Throttler; + askEmailVerification: Throttler; + verifyEmailVerification: Throttler; constructor() { this.login = new Throttler({ timeoutSeconds: [0, 1, 2, 4, 8, 16, 30, 60, 180, 300], - storage: createThrottlerStorage(), + storage: prefixStorage(createThrottlerStorage(), "slip:rate:login"), + }); + + this.askEmailVerification = new Throttler({ + timeoutSeconds: [0, 2, 4, 8, 32, 60, 180, 240, 480, 720], + storage: prefixStorage(createThrottlerStorage(), "slip:rate:ask-email-verification"), + }); + + this.verifyEmailVerification = new Throttler({ + timeoutSeconds: [0, 1, 2, 4, 8, 16, 30, 60, 180, 300], + storage: prefixStorage(createThrottlerStorage(), "slip:rate:verify-email-verification"), }); } } diff --git a/src/runtime/core/rate-limit/Throttler.ts b/src/runtime/core/rate-limit/Throttler.ts index cb9b7d2..4c549f1 100644 --- a/src/runtime/core/rate-limit/Throttler.ts +++ b/src/runtime/core/rate-limit/Throttler.ts @@ -37,7 +37,7 @@ export class Throttler { return [true]; } else { - return [false, counter]; + return [false, { updatedAt: counter.updatedAt, timeout: this.timeoutSeconds[counter.timeout] }]; } } diff --git a/tests/rate-limit.test.ts b/tests/rate-limit.test.ts index 4b55fbe..374cda3 100644 --- a/tests/rate-limit.test.ts +++ b/tests/rate-limit.test.ts @@ -1,13 +1,11 @@ -import { describe, it, expect, vi, beforeEach, afterAll } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import sqlite from "db0/connectors/better-sqlite3"; import { createDatabase } from "db0"; import { SlipAuthCore } from "../src/runtime/core/core"; import { autoSetupTestsDatabase, createH3Event, testTablesNames } from "./test-helpers"; -import { InvalidEmailOrPasswordError, RateLimitLoginError } from "../src/runtime/core/errors/SlipAuthError"; +import { EmailVerificationCodeExpired, EmailVerificationCodeExpiredError, EmailVerificationFailedError, InvalidEmailOrPasswordError, RateLimitAskEmailVerificationError, RateLimitLoginError, RateLimitVerifyEmailVerificationError } from "../src/runtime/core/errors/SlipAuthError"; import { createThrottlerStorage } from "../src/runtime/core/rate-limit/Throttler"; -const testStorage = createThrottlerStorage(); - const db = createDatabase(sqlite({ name: "rate-limit.test", })); @@ -31,21 +29,10 @@ const mocks = vi.hoisted(() => { }; }); -function wait(ms: number) { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -describe.sequential("rate limit", () => { - afterAll( - async () => { - // await testStorage.clear(); - await testStorage.dispose(); - }); - +describe("rate limit", () => { beforeEach(async () => { mocks.userCreatedCount = 0; mocks.sessionCreatedCount = 0; - await testStorage.clear(); auth = new SlipAuthCore( db, @@ -65,8 +52,6 @@ describe.sequential("rate limit", () => { return `session-id-${mocks.sessionCreatedCount}`; }); - auth.setters.setLoginRateLimiter(() => testStorage); - function sanitizePassword(str: string) { return str.replaceAll("$", "") + "$"; } @@ -84,6 +69,13 @@ describe.sequential("rate limit", () => { }); describe("login", () => { + const loginTestStorage = createThrottlerStorage(); + + beforeEach(async () => { + await loginTestStorage.clear(); + auth.setters.setLoginRateLimiter(() => loginTestStorage); + }); + it("should allow 2 failed tries", async () => { await auth.register(createH3Event(), defaultInsert); const doAttempt = () => auth.login(createH3Event(), { @@ -107,6 +99,7 @@ describe.sequential("rate limit", () => { const t1 = doAttempt(); await expect(t1).rejects.toBeInstanceOf(InvalidEmailOrPasswordError); + // will not rate-limit because timeout is 0 const t2 = doAttempt(); await expect(t2).rejects.toBeInstanceOf(InvalidEmailOrPasswordError); @@ -147,4 +140,94 @@ describe.sequential("rate limit", () => { expect(t6).toMatchObject({ data: { msBeforeNext: 1500 } }); }); }); + + describe("ask email verification", () => { + const askEmailVerificationTestStorage = createThrottlerStorage(); + + beforeEach(async () => { + await askEmailVerificationTestStorage.clear(); + auth.setters.setAskEmailRateLimiter(() => askEmailVerificationTestStorage); + }); + + it("should rate-limit", async () => { + const [userId] = await auth.register(createH3Event(), defaultInsert); + const user = (await auth.getUser({ userId }))!; + const doAttempt = () => auth.askEmailVerificationCode(createH3Event(), { user }); + + vi.useFakeTimers(); + + const t1 = await doAttempt(); + expect(t1).not.toBeInstanceOf(Error); + + const t2 = doAttempt(); + await expect(t2).rejects.toBeInstanceOf(RateLimitAskEmailVerificationError); + + vi.advanceTimersByTime(2000); + + const t3 = await doAttempt(); + expect(t3).not.toBeInstanceOf(Error); + + const t4 = await doAttempt().catch(e => JSON.parse(JSON.stringify(e))); + expect(t4).toMatchObject({ + data: { + msBeforeNext: 4000, + }, + }); + }); + }); + + describe("verify email verification", () => { + const verifyEmailVerificationTestStorage = createThrottlerStorage(); + const askEmailVerificationTestStorage = createThrottlerStorage(); + + beforeEach(async () => { + await verifyEmailVerificationTestStorage.clear(); + await askEmailVerificationTestStorage.clear(); + auth.setters.setAskEmailRateLimiter(() => askEmailVerificationTestStorage); + auth.setters.setVerifyEmailRateLimiter(() => verifyEmailVerificationTestStorage); + }); + + it("should rate-limit", async () => { + auth.setters.setCreateRandomEmailVerificationCode(() => "123456"); + vi.useFakeTimers(); + + const [userId] = await auth.register(createH3Event(), defaultInsert); + const user = (await auth.getUser({ userId }))!; + const doAttempt = async () => { + await askEmailVerificationTestStorage.clear(); + await auth.askEmailVerificationCode(createH3Event(), { user }); + await askEmailVerificationTestStorage.clear(); + return auth.verifyEmailVerificationCode(createH3Event(), { + user: { + ...user, + email: "wrong-email", + }, + code: "123456", + }); + }; + + const t1 = doAttempt(); + await expect(t1).rejects.toBeInstanceOf(EmailVerificationFailedError); + + // will not rate-limit because timeout is 0 + const t2 = doAttempt(); + await expect(t2).rejects.toBeInstanceOf(EmailVerificationFailedError); + + const t3 = doAttempt(); + await expect(t3).rejects.toBeInstanceOf(RateLimitVerifyEmailVerificationError); + + vi.advanceTimersByTime(1000); + + // will not rate-limit timeout is expired + const t4 = doAttempt(); + await expect(t4).rejects.toBeInstanceOf(EmailVerificationFailedError); + + const t5 = await doAttempt().catch(e => JSON.parse(JSON.stringify(e))); + expect(t5).toMatchObject({ + data: { + msBeforeNext: 2000, + }, + }); + }); + }); });