@bajustone/fortress 1.0.0-rc.1
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 +1011 -0
- package/LICENSE +21 -0
- package/README.md +760 -0
- package/SECURITY.md +197 -0
- package/bin/fortress.ts +667 -0
- package/dist/auth-service-BcyQ2PuZ.d.cts +307 -0
- package/dist/auth-service-BkzZkWfH.d.ts +307 -0
- package/dist/config-B2366s_G.d.ts +665 -0
- package/dist/config-GtlVt6ZD.d.cts +665 -0
- package/dist/convert-routes-BLCQT57f.d.ts +72 -0
- package/dist/convert-routes-BdMvP2_X.d.cts +72 -0
- package/dist/crypto.cjs +61 -0
- package/dist/crypto.d.cts +36 -0
- package/dist/crypto.d.ts +36 -0
- package/dist/crypto.js +35 -0
- package/dist/drift-BT1wtMDJ.d.cts +22 -0
- package/dist/drift-k9LiYpRP.d.ts +22 -0
- package/dist/drizzle/pg.cjs +481 -0
- package/dist/drizzle/pg.d.cts +15 -0
- package/dist/drizzle/pg.d.ts +15 -0
- package/dist/drizzle/pg.js +454 -0
- package/dist/drizzle.cjs +1442 -0
- package/dist/drizzle.d.cts +85 -0
- package/dist/drizzle.d.ts +85 -0
- package/dist/drizzle.js +1411 -0
- package/dist/express.cjs +1357 -0
- package/dist/express.d.cts +333 -0
- package/dist/express.d.ts +333 -0
- package/dist/express.js +1314 -0
- package/dist/fetcher.cjs +77 -0
- package/dist/fetcher.d.cts +7 -0
- package/dist/fetcher.d.ts +7 -0
- package/dist/fetcher.js +41 -0
- package/dist/fortress-BmSvFfqK.d.cts +1089 -0
- package/dist/fortress-uA-_cheR.d.ts +1089 -0
- package/dist/hono.cjs +1530 -0
- package/dist/hono.d.cts +514 -0
- package/dist/hono.d.ts +514 -0
- package/dist/hono.js +1483 -0
- package/dist/index-CxEkfCA0.d.cts +77 -0
- package/dist/index-CxEkfCA0.d.ts +77 -0
- package/dist/index-D054pcb-.d.cts +146 -0
- package/dist/index-jAMbbUAY.d.ts +146 -0
- package/dist/index.cjs +9046 -0
- package/dist/index.d.cts +717 -0
- package/dist/index.d.ts +717 -0
- package/dist/index.js +8935 -0
- package/dist/jwt.cjs +255 -0
- package/dist/jwt.d.cts +90 -0
- package/dist/jwt.d.ts +90 -0
- package/dist/jwt.js +227 -0
- package/dist/otel.cjs +108 -0
- package/dist/otel.d.cts +62 -0
- package/dist/otel.d.ts +62 -0
- package/dist/otel.js +73 -0
- package/dist/plugins/account-lockout.cjs +322 -0
- package/dist/plugins/account-lockout.d.cts +42 -0
- package/dist/plugins/account-lockout.d.ts +42 -0
- package/dist/plugins/account-lockout.js +295 -0
- package/dist/plugins/admin.cjs +2378 -0
- package/dist/plugins/admin.d.cts +72 -0
- package/dist/plugins/admin.d.ts +72 -0
- package/dist/plugins/admin.js +2351 -0
- package/dist/plugins/api-key.cjs +792 -0
- package/dist/plugins/api-key.d.cts +121 -0
- package/dist/plugins/api-key.d.ts +121 -0
- package/dist/plugins/api-key.js +765 -0
- package/dist/plugins/audit-log.cjs +493 -0
- package/dist/plugins/audit-log.d.cts +86 -0
- package/dist/plugins/audit-log.d.ts +86 -0
- package/dist/plugins/audit-log.js +468 -0
- package/dist/plugins/data-isolation.cjs +211 -0
- package/dist/plugins/data-isolation.d.cts +49 -0
- package/dist/plugins/data-isolation.d.ts +49 -0
- package/dist/plugins/data-isolation.js +186 -0
- package/dist/plugins/email-verification.cjs +273 -0
- package/dist/plugins/email-verification.d.cts +44 -0
- package/dist/plugins/email-verification.d.ts +44 -0
- package/dist/plugins/email-verification.js +246 -0
- package/dist/plugins/magic-link.cjs +231 -0
- package/dist/plugins/magic-link.d.cts +39 -0
- package/dist/plugins/magic-link.d.ts +39 -0
- package/dist/plugins/magic-link.js +204 -0
- package/dist/plugins/oauth.cjs +1696 -0
- package/dist/plugins/oauth.d.cts +406 -0
- package/dist/plugins/oauth.d.ts +406 -0
- package/dist/plugins/oauth.js +1669 -0
- package/dist/plugins/openapi.cjs +1185 -0
- package/dist/plugins/openapi.d.cts +6 -0
- package/dist/plugins/openapi.d.ts +6 -0
- package/dist/plugins/openapi.js +1158 -0
- package/dist/plugins/rate-limit/express.cjs +55 -0
- package/dist/plugins/rate-limit/express.d.cts +64 -0
- package/dist/plugins/rate-limit/express.d.ts +64 -0
- package/dist/plugins/rate-limit/express.js +30 -0
- package/dist/plugins/rate-limit/hono.cjs +51 -0
- package/dist/plugins/rate-limit/hono.d.cts +59 -0
- package/dist/plugins/rate-limit/hono.d.ts +59 -0
- package/dist/plugins/rate-limit/hono.js +26 -0
- package/dist/plugins/rate-limit/sveltekit.cjs +49 -0
- package/dist/plugins/rate-limit/sveltekit.d.cts +59 -0
- package/dist/plugins/rate-limit/sveltekit.d.ts +59 -0
- package/dist/plugins/rate-limit/sveltekit.js +24 -0
- package/dist/plugins/rate-limit.cjs +354 -0
- package/dist/plugins/rate-limit.d.cts +140 -0
- package/dist/plugins/rate-limit.d.ts +140 -0
- package/dist/plugins/rate-limit.js +325 -0
- package/dist/plugins/social-login.cjs +905 -0
- package/dist/plugins/social-login.d.cts +114 -0
- package/dist/plugins/social-login.d.ts +114 -0
- package/dist/plugins/social-login.js +880 -0
- package/dist/plugins/tenancy.cjs +780 -0
- package/dist/plugins/tenancy.d.cts +108 -0
- package/dist/plugins/tenancy.d.ts +108 -0
- package/dist/plugins/tenancy.js +753 -0
- package/dist/plugins/two-factor.cjs +555 -0
- package/dist/plugins/two-factor.d.cts +67 -0
- package/dist/plugins/two-factor.d.ts +67 -0
- package/dist/plugins/two-factor.js +526 -0
- package/dist/plugins/webauthn.cjs +786 -0
- package/dist/plugins/webauthn.d.cts +72 -0
- package/dist/plugins/webauthn.d.ts +72 -0
- package/dist/plugins/webauthn.js +766 -0
- package/dist/plugins/webhook.cjs +819 -0
- package/dist/plugins/webhook.d.cts +254 -0
- package/dist/plugins/webhook.d.ts +254 -0
- package/dist/plugins/webhook.js +789 -0
- package/dist/protect-D1v_0upK.d.cts +123 -0
- package/dist/protect-DsxV_-dJ.d.ts +123 -0
- package/dist/sveltekit.cjs +1258 -0
- package/dist/sveltekit.d.cts +420 -0
- package/dist/sveltekit.d.ts +420 -0
- package/dist/sveltekit.js +1207 -0
- package/dist/testing.cjs +4294 -0
- package/dist/testing.d.cts +197 -0
- package/dist/testing.d.ts +197 -0
- package/dist/testing.js +4264 -0
- package/dist/types-et0R8tsC.d.cts +217 -0
- package/dist/types-et0R8tsC.d.ts +217 -0
- package/docs/adapter-typed-helpers.md +192 -0
- package/docs/admin-recipes.md +227 -0
- package/docs/architecture.md +434 -0
- package/docs/ci/github-actions.yml +59 -0
- package/docs/ci.md +109 -0
- package/docs/compatibility.md +115 -0
- package/docs/deployment.md +305 -0
- package/docs/hardening.md +118 -0
- package/docs/host-owned-routes.md +154 -0
- package/docs/migrations/0001-schema-version.md +54 -0
- package/docs/migrations/0002-initial-schema.md +84 -0
- package/docs/migrations/upgrade-guide.md +160 -0
- package/docs/observability.md +394 -0
- package/docs/plugins/account-lockout.md +131 -0
- package/docs/plugins/admin.md +402 -0
- package/docs/plugins/api-key.md +327 -0
- package/docs/plugins/audit-log.md +261 -0
- package/docs/plugins/data-isolation.md +186 -0
- package/docs/plugins/email-verification.md +121 -0
- package/docs/plugins/magic-link.md +123 -0
- package/docs/plugins/oauth.md +544 -0
- package/docs/plugins/openapi.md +250 -0
- package/docs/plugins/rate-limit.md +223 -0
- package/docs/plugins/social-login.md +337 -0
- package/docs/plugins/tenancy.md +122 -0
- package/docs/plugins/two-factor.md +191 -0
- package/docs/plugins/webauthn.md +188 -0
- package/docs/plugins/webhook.md +242 -0
- package/docs/policy-as-code.md +156 -0
- package/docs/route-manifest.md +46 -0
- package/docs/security.md +261 -0
- package/docs/threat-model.md +161 -0
- package/examples/README.md +119 -0
- package/examples/express-app/index.ts +747 -0
- package/examples/hono-app/docker-compose.yml +14 -0
- package/examples/hono-app/index.ts +1009 -0
- package/examples/policy/fortress.policy.json +47 -0
- package/examples/sveltekit-app/README.md +125 -0
- package/examples/sveltekit-app/src/app.d.ts +16 -0
- package/examples/sveltekit-app/src/hooks.server.ts +23 -0
- package/examples/sveltekit-app/src/lib/server/fortress.ts +81 -0
- package/examples/sveltekit-app/src/routes/api/echo/[id]/+server.ts +32 -0
- package/examples/sveltekit-app/src/routes/api/fortress/[...path]/+server.ts +15 -0
- package/examples/sveltekit-app/src/routes/dashboard/+page.server.ts +20 -0
- package/examples/sveltekit-app/src/routes/login/+page.server.ts +48 -0
- package/examples/sveltekit-app/src/routes/oauth/consent/+page.server.ts +69 -0
- package/examples/sveltekit-app/src/routes/oauth/consent/+page.svelte +83 -0
- package/migrations/pg/0001_schema_version.down.sql +2 -0
- package/migrations/pg/0001_schema_version.sql +8 -0
- package/migrations/pg/0002_initial_schema.down.sql +36 -0
- package/migrations/pg/0002_initial_schema.sql +376 -0
- package/migrations/pg/0003_auth_continuation.down.sql +6 -0
- package/migrations/pg/0003_auth_continuation.sql +30 -0
- package/migrations/pg/0004_tenant_default_unique.down.sql +1 -0
- package/migrations/pg/0004_tenant_default_unique.sql +14 -0
- package/migrations/pg/0005_hot_indexes_timestamptz.down.sql +71 -0
- package/migrations/pg/0005_hot_indexes_timestamptz.sql +71 -0
- package/migrations/pg/0006_canonical_email.down.sql +3 -0
- package/migrations/pg/0006_canonical_email.sql +8 -0
- package/migrations/pg/0007_audit_chain_anchor.down.sql +1 -0
- package/migrations/pg/0007_audit_chain_anchor.sql +8 -0
- package/migrations/pg/0008_two_factor_hardening.down.sql +8 -0
- package/migrations/pg/0008_two_factor_hardening.sql +8 -0
- package/migrations/pg/0009_encrypt_totp_secrets.down.sql +2 -0
- package/migrations/pg/0009_encrypt_totp_secrets.sql +5 -0
- package/migrations/pg/0010_bigint_append_only_ids.down.sql +2 -0
- package/migrations/pg/0010_bigint_append_only_ids.sql +23 -0
- package/migrations/sqlite/0001_schema_version.down.sql +2 -0
- package/migrations/sqlite/0001_schema_version.sql +8 -0
- package/migrations/sqlite/0002_initial_schema.down.sql +36 -0
- package/migrations/sqlite/0002_initial_schema.sql +376 -0
- package/migrations/sqlite/0003_auth_continuation.down.sql +27 -0
- package/migrations/sqlite/0003_auth_continuation.sql +45 -0
- package/migrations/sqlite/0004_tenant_default_unique.down.sql +1 -0
- package/migrations/sqlite/0004_tenant_default_unique.sql +13 -0
- package/migrations/sqlite/0005_hot_indexes_timestamptz.down.sql +10 -0
- package/migrations/sqlite/0005_hot_indexes_timestamptz.sql +10 -0
- package/migrations/sqlite/0006_canonical_email.down.sql +3 -0
- package/migrations/sqlite/0006_canonical_email.sql +8 -0
- package/migrations/sqlite/0007_audit_chain_anchor.down.sql +1 -0
- package/migrations/sqlite/0007_audit_chain_anchor.sql +8 -0
- package/migrations/sqlite/0008_two_factor_hardening.down.sql +14 -0
- package/migrations/sqlite/0008_two_factor_hardening.sql +7 -0
- package/migrations/sqlite/0009_encrypt_totp_secrets.down.sql +2 -0
- package/migrations/sqlite/0009_encrypt_totp_secrets.sql +5 -0
- package/migrations/sqlite/0010_bigint_append_only_ids.down.sql +1 -0
- package/migrations/sqlite/0010_bigint_append_only_ids.sql +2 -0
- package/package.json +398 -0
- package/src/adapters/database/index.ts +63 -0
- package/src/adapters/database/types.ts +22 -0
- package/src/changelog-script.test.ts +21 -0
- package/src/cli.test.ts +79 -0
- package/src/core/auth/auth-admin.test.ts +247 -0
- package/src/core/auth/auth-endpoints.ts +558 -0
- package/src/core/auth/auth-observer.test.ts +191 -0
- package/src/core/auth/auth-service.ts +1464 -0
- package/src/core/auth/email.test.ts +17 -0
- package/src/core/auth/email.ts +11 -0
- package/src/core/auth/hooks.test.ts +251 -0
- package/src/core/auth/impersonation.test.ts +179 -0
- package/src/core/auth/jwt.test.ts +184 -0
- package/src/core/auth/jwt.ts +239 -0
- package/src/core/auth/login-timing.test.ts +77 -0
- package/src/core/auth/password-policy.test.ts +253 -0
- package/src/core/auth/password-policy.ts +220 -0
- package/src/core/auth/password.test.ts +42 -0
- package/src/core/auth/password.ts +62 -0
- package/src/core/auth/post-auth-gate.test.ts +258 -0
- package/src/core/auth/post-auth-gate.ts +228 -0
- package/src/core/auth/refresh-token.test.ts +86 -0
- package/src/core/auth/refresh-token.ts +105 -0
- package/src/core/auth/timing-safe.ts +23 -0
- package/src/core/config.ts +212 -0
- package/src/core/endpoint.ts +149 -0
- package/src/core/errors.ts +199 -0
- package/src/core/fetcher-interop.test.ts +106 -0
- package/src/core/fortress.test.ts +354 -0
- package/src/core/fortress.ts +550 -0
- package/src/core/http/call.test.ts +148 -0
- package/src/core/http/call.ts +149 -0
- package/src/core/http/cookie-serialize.test.ts +198 -0
- package/src/core/http/cookie-serialize.ts +107 -0
- package/src/core/http/csrf.test.ts +140 -0
- package/src/core/http/csrf.ts +173 -0
- package/src/core/http/dispatch.ts +652 -0
- package/src/core/http/error-response.test.ts +69 -0
- package/src/core/http/error-response.ts +93 -0
- package/src/core/http/fortress-rbac.test.ts +43 -0
- package/src/core/http/fortress-rbac.ts +127 -0
- package/src/core/http/handle-request.test.ts +441 -0
- package/src/core/http/handle-request.ts +354 -0
- package/src/core/http/match.test.ts +122 -0
- package/src/core/http/match.ts +126 -0
- package/src/core/http/outbound.test.ts +34 -0
- package/src/core/http/outbound.ts +105 -0
- package/src/core/http/plugin-middleware.ts +67 -0
- package/src/core/http/principal.test.ts +345 -0
- package/src/core/http/principal.ts +86 -0
- package/src/core/http/protect.test.ts +220 -0
- package/src/core/http/protect.ts +394 -0
- package/src/core/http/token-extraction.test.ts +59 -0
- package/src/core/http/token-extraction.ts +53 -0
- package/src/core/iam/explain.test.ts +177 -0
- package/src/core/iam/explain.ts +302 -0
- package/src/core/iam/iam-endpoints.ts +779 -0
- package/src/core/iam/iam-service.test.ts +829 -0
- package/src/core/iam/iam-service.ts +1137 -0
- package/src/core/iam/permission-cache.test.ts +115 -0
- package/src/core/iam/permission-cache.ts +71 -0
- package/src/core/iam/permission-check-observer.test.ts +90 -0
- package/src/core/iam/permission-evaluator.test.ts +291 -0
- package/src/core/iam/permission-evaluator.ts +198 -0
- package/src/core/iam/permission-sync.test.ts +166 -0
- package/src/core/iam/permission-sync.ts +192 -0
- package/src/core/iam/resource-sync.test.ts +70 -0
- package/src/core/iam/resource-sync.ts +182 -0
- package/src/core/iam/service-account-http.test.ts +529 -0
- package/src/core/iam/service-account.test.ts +282 -0
- package/src/core/internal-adapter.test.ts +389 -0
- package/src/core/internal-adapter.ts +435 -0
- package/src/core/json-schema.ts +50 -0
- package/src/core/manifest/__snapshots__/route-manifest.test.ts.snap +192 -0
- package/src/core/manifest/drift.ts +103 -0
- package/src/core/manifest/route-manifest.test.ts +142 -0
- package/src/core/manifest/route-manifest.ts +154 -0
- package/src/core/migrate.test.ts +72 -0
- package/src/core/migrations/engine.test.ts +308 -0
- package/src/core/migrations/engine.ts +548 -0
- package/src/core/migrations/migrations.ts +1771 -0
- package/src/core/migrations/pg-migration.integration-test.ts +353 -0
- package/src/core/migrations/upgrade-fixture.test.ts +378 -0
- package/src/core/observability/db-instrumentation.ts +205 -0
- package/src/core/observability/listener-list.test.ts +124 -0
- package/src/core/observability/listener-list.ts +73 -0
- package/src/core/observability/logger.test.ts +43 -0
- package/src/core/observability/logger.ts +49 -0
- package/src/core/observability/spans.test.ts +193 -0
- package/src/core/observability/types.ts +80 -0
- package/src/core/openapi-drift.test.ts +41 -0
- package/src/core/openapi.ts +47 -0
- package/src/core/plugin-methods-map.ts +75 -0
- package/src/core/plugin-runner.test.ts +233 -0
- package/src/core/plugin-runner.ts +276 -0
- package/src/core/plugin.ts +210 -0
- package/src/core/policy/apply.ts +272 -0
- package/src/core/policy/diff.ts +288 -0
- package/src/core/policy/loader.test.ts +63 -0
- package/src/core/policy/loader.ts +214 -0
- package/src/core/policy/policy.test.ts +232 -0
- package/src/core/policy/types.ts +105 -0
- package/src/core/schema-builder.test.ts +660 -0
- package/src/core/schema-builder.ts +881 -0
- package/src/core/standard-schema.ts +56 -0
- package/src/core/types.ts +258 -0
- package/src/core/validation.test.ts +195 -0
- package/src/core/validation.ts +192 -0
- package/src/drizzle/adapter.test.ts +425 -0
- package/src/drizzle/adapter.ts +474 -0
- package/src/drizzle/index.ts +31 -0
- package/src/drizzle/pg/adapter.integration-test.ts +456 -0
- package/src/drizzle/pg/index.ts +13 -0
- package/src/drizzle/pg/pg.integration-test.ts +1539 -0
- package/src/drizzle/pg/schema.ts +539 -0
- package/src/drizzle/pg-error-map.test.ts +218 -0
- package/src/drizzle/pg-error-map.ts +151 -0
- package/src/drizzle/schema.ts +544 -0
- package/src/drizzle/sqlite-serialization.test.ts +106 -0
- package/src/express/express-api-key-user-routes.test.ts +241 -0
- package/src/express/express.test.ts +442 -0
- package/src/express/handle.ts +191 -0
- package/src/express/index.ts +62 -0
- package/src/express/middleware.ts +551 -0
- package/src/express/protect.ts +154 -0
- package/src/express/validated.test.ts +86 -0
- package/src/express/validated.ts +99 -0
- package/src/fetcher/index.ts +81 -0
- package/src/hono/convert-routes.test.ts +220 -0
- package/src/hono/convert-routes.ts +196 -0
- package/src/hono/converters.test.ts +50 -0
- package/src/hono/converters.ts +43 -0
- package/src/hono/handle.ts +79 -0
- package/src/hono/helpers.ts +2 -0
- package/src/hono/hono-api-key-user-routes.test.ts +225 -0
- package/src/hono/hono-handlers.test.ts +861 -0
- package/src/hono/hono.test.ts +454 -0
- package/src/hono/index.ts +101 -0
- package/src/hono/middleware/auth.ts +236 -0
- package/src/hono/middleware/auth.types.test.ts +94 -0
- package/src/hono/middleware/csrf.test.ts +127 -0
- package/src/hono/middleware/csrf.ts +68 -0
- package/src/hono/middleware/error-handler.ts +12 -0
- package/src/hono/middleware/plugin-middleware.ts +35 -0
- package/src/hono/middleware/rbac.ts +131 -0
- package/src/hono/middleware/security-headers.test.ts +88 -0
- package/src/hono/middleware/security-headers.ts +68 -0
- package/src/hono/openapi.ts +237 -0
- package/src/hono/protect.ts +87 -0
- package/src/hono/validated.test.ts +123 -0
- package/src/hono/validated.ts +62 -0
- package/src/index.ts +309 -0
- package/src/integration.test.ts +718 -0
- package/src/internal/changelog.ts +56 -0
- package/src/otel/index.ts +158 -0
- package/src/otel/otel-types.test.ts +35 -0
- package/src/otel/otel.test.ts +235 -0
- package/src/plugins/account-lockout/account-lockout.test.ts +367 -0
- package/src/plugins/account-lockout/index.ts +267 -0
- package/src/plugins/admin/admin-api-key.test.ts +438 -0
- package/src/plugins/admin/index.ts +1612 -0
- package/src/plugins/api-key/api-key.test.ts +684 -0
- package/src/plugins/api-key/core.ts +336 -0
- package/src/plugins/api-key/index.ts +315 -0
- package/src/plugins/audit-log/audit-log.test.ts +749 -0
- package/src/plugins/audit-log/index.ts +680 -0
- package/src/plugins/data-isolation/data-isolation.test.ts +197 -0
- package/src/plugins/data-isolation/index.ts +157 -0
- package/src/plugins/email-verification/email-verification.test.ts +199 -0
- package/src/plugins/email-verification/index.ts +190 -0
- package/src/plugins/magic-link/index.ts +129 -0
- package/src/plugins/magic-link/magic-link.test.ts +156 -0
- package/src/plugins/oauth/id-token.ts +86 -0
- package/src/plugins/oauth/index.ts +2145 -0
- package/src/plugins/oauth/jwks.test.ts +32 -0
- package/src/plugins/oauth/jwks.ts +182 -0
- package/src/plugins/oauth/oauth.test.ts +2220 -0
- package/src/plugins/oauth/pkce.ts +58 -0
- package/src/plugins/openapi/index.ts +298 -0
- package/src/plugins/openapi/openapi.test.ts +425 -0
- package/src/plugins/openapi/spec-builder.ts +287 -0
- package/src/plugins/rate-limit/express.test.ts +89 -0
- package/src/plugins/rate-limit/express.ts +86 -0
- package/src/plugins/rate-limit/hono.test.ts +60 -0
- package/src/plugins/rate-limit/hono.ts +74 -0
- package/src/plugins/rate-limit/index.ts +383 -0
- package/src/plugins/rate-limit/memory-store.ts +61 -0
- package/src/plugins/rate-limit/rate-limit.test.ts +756 -0
- package/src/plugins/rate-limit/sveltekit.test.ts +58 -0
- package/src/plugins/rate-limit/sveltekit.ts +66 -0
- package/src/plugins/social-login/index.ts +715 -0
- package/src/plugins/social-login/providers/apple.ts +34 -0
- package/src/plugins/social-login/providers/discord.ts +26 -0
- package/src/plugins/social-login/providers/github.ts +54 -0
- package/src/plugins/social-login/providers/google.ts +23 -0
- package/src/plugins/social-login/providers/index.ts +22 -0
- package/src/plugins/social-login/providers/microsoft.ts +35 -0
- package/src/plugins/social-login/providers/oidc.ts +37 -0
- package/src/plugins/social-login/providers/providers.test.ts +211 -0
- package/src/plugins/social-login/social-login.test.ts +675 -0
- package/src/plugins/social-login/types.ts +80 -0
- package/src/plugins/tenancy/index.ts +544 -0
- package/src/plugins/tenancy/tenancy.test.ts +323 -0
- package/src/plugins/two-factor/index.ts +569 -0
- package/src/plugins/two-factor/two-factor.test.ts +235 -0
- package/src/plugins/webauthn/index.ts +554 -0
- package/src/plugins/webauthn/webauthn.test.ts +472 -0
- package/src/plugins/webhook/builtin-events.ts +29 -0
- package/src/plugins/webhook/delivery.test.ts +51 -0
- package/src/plugins/webhook/delivery.ts +154 -0
- package/src/plugins/webhook/hooks.ts +89 -0
- package/src/plugins/webhook/index.ts +445 -0
- package/src/plugins/webhook/queue/database.ts +67 -0
- package/src/plugins/webhook/queue/in-memory.test.ts +48 -0
- package/src/plugins/webhook/queue/in-memory.ts +71 -0
- package/src/plugins/webhook/queue/types.ts +51 -0
- package/src/plugins/webhook/registry.test.ts +48 -0
- package/src/plugins/webhook/registry.ts +64 -0
- package/src/plugins/webhook/signing.ts +45 -0
- package/src/plugins/webhook/ssrf.ts +198 -0
- package/src/plugins/webhook/types.ts +138 -0
- package/src/plugins/webhook/webhook.test.ts +324 -0
- package/src/sveltekit/actions.ts +233 -0
- package/src/sveltekit/catch-all.ts +43 -0
- package/src/sveltekit/cookies.ts +136 -0
- package/src/sveltekit/handle.ts +356 -0
- package/src/sveltekit/helpers.ts +69 -0
- package/src/sveltekit/index.ts +74 -0
- package/src/sveltekit/protect.ts +80 -0
- package/src/sveltekit/sveltekit-types.test.ts +31 -0
- package/src/sveltekit/sveltekit.test.ts +799 -0
- package/src/sveltekit/types.ts +134 -0
- package/src/sveltekit/validated.test.ts +117 -0
- package/src/sveltekit/validated.ts +78 -0
- package/src/testing/adapter-conformance.test.ts +408 -0
- package/src/testing/checks.test.ts +124 -0
- package/src/testing/checks.ts +304 -0
- package/src/testing/index.ts +107 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import type { Fortress } from '../../core/fortress';
|
|
2
|
+
import type { LockoutStatus } from './index';
|
|
3
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
4
|
+
import { createFortress } from '../../core/fortress';
|
|
5
|
+
import { assertSuccess } from '../../core/types';
|
|
6
|
+
import { createTestAdapter } from '../../testing';
|
|
7
|
+
import { accountLockout } from './index';
|
|
8
|
+
|
|
9
|
+
const SECRET = 'lockout-test-secret-at-least-32!!';
|
|
10
|
+
|
|
11
|
+
interface LockoutMethods {
|
|
12
|
+
getLockoutStatus: (identifier: string) => Promise<LockoutStatus>;
|
|
13
|
+
resetLockout: (identifier: string) => Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('account-lockout plugin', () => {
|
|
17
|
+
let fortress: Fortress;
|
|
18
|
+
let methods: LockoutMethods;
|
|
19
|
+
const email = 'alice@example.com';
|
|
20
|
+
|
|
21
|
+
beforeEach(async () => {
|
|
22
|
+
fortress = createFortress({
|
|
23
|
+
jwt: { key: SECRET },
|
|
24
|
+
database: createTestAdapter(),
|
|
25
|
+
plugins: [accountLockout({
|
|
26
|
+
maxFailedAttempts: 3,
|
|
27
|
+
lockoutDurationSeconds: 60,
|
|
28
|
+
escalation: true,
|
|
29
|
+
maxLockoutSeconds: 300,
|
|
30
|
+
})],
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
methods = fortress.plugins['account-lockout'] as unknown as LockoutMethods;
|
|
34
|
+
|
|
35
|
+
await fortress.auth.createUser({
|
|
36
|
+
email,
|
|
37
|
+
name: 'Alice',
|
|
38
|
+
password: 'correct-password',
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/** Simulate a failed login attempt by calling the onLoginFailure hook directly. */
|
|
43
|
+
async function simulateLoginFailure(identifier: string): Promise<void> {
|
|
44
|
+
const plugin = fortress.config.plugins!.find(p => p.name === 'account-lockout')!;
|
|
45
|
+
await plugin.hooks!.onLoginFailure!({
|
|
46
|
+
db: fortress.config.database,
|
|
47
|
+
config: fortress.config,
|
|
48
|
+
identifier,
|
|
49
|
+
error: new Error('Invalid credentials'),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('allows login within failure limit', () => {
|
|
54
|
+
it('does not lock account before maxFailedAttempts', async () => {
|
|
55
|
+
await simulateLoginFailure(email);
|
|
56
|
+
await simulateLoginFailure(email);
|
|
57
|
+
|
|
58
|
+
// 2 failures, max is 3 — should not be locked
|
|
59
|
+
const status = await methods.getLockoutStatus(email);
|
|
60
|
+
expect(status.failedAttempts).toBe(2);
|
|
61
|
+
expect(status.isLocked).toBe(false);
|
|
62
|
+
|
|
63
|
+
// Login should still succeed
|
|
64
|
+
const result = await fortress.auth.login(email, 'correct-password');
|
|
65
|
+
assertSuccess(result);
|
|
66
|
+
expect(result.accessToken).toBeTruthy();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('locks account after maxFailedAttempts', () => {
|
|
71
|
+
it('sets lockedUntil when threshold is reached', async () => {
|
|
72
|
+
await simulateLoginFailure(email);
|
|
73
|
+
await simulateLoginFailure(email);
|
|
74
|
+
await simulateLoginFailure(email);
|
|
75
|
+
|
|
76
|
+
const status = await methods.getLockoutStatus(email);
|
|
77
|
+
expect(status.failedAttempts).toBe(3);
|
|
78
|
+
expect(status.isLocked).toBe(true);
|
|
79
|
+
expect(status.lockedUntil).toBeTruthy();
|
|
80
|
+
expect(status.lockoutCount).toBe(1);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('locked account rejects login attempts', () => {
|
|
85
|
+
it('throws unauthorized for locked account on beforeLogin', async () => {
|
|
86
|
+
await simulateLoginFailure(email);
|
|
87
|
+
await simulateLoginFailure(email);
|
|
88
|
+
await simulateLoginFailure(email);
|
|
89
|
+
|
|
90
|
+
await expect(
|
|
91
|
+
fortress.auth.login(email, 'correct-password'),
|
|
92
|
+
).rejects.toThrow('Account temporarily locked. Try again later.');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('lockout expires after duration', () => {
|
|
97
|
+
it('allows login after lockout duration passes', async () => {
|
|
98
|
+
// Use a very short lockout for this test
|
|
99
|
+
const shortFortress = createFortress({
|
|
100
|
+
jwt: { key: SECRET },
|
|
101
|
+
database: createTestAdapter(),
|
|
102
|
+
plugins: [accountLockout({
|
|
103
|
+
maxFailedAttempts: 2,
|
|
104
|
+
lockoutDurationSeconds: -1, // already expired
|
|
105
|
+
escalation: false,
|
|
106
|
+
})],
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await shortFortress.auth.createUser({
|
|
110
|
+
email: 'bob@example.com',
|
|
111
|
+
name: 'Bob',
|
|
112
|
+
password: 'correct-password',
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const shortPlugin = shortFortress.config.plugins!.find(p => p.name === 'account-lockout')!;
|
|
116
|
+
|
|
117
|
+
// Trigger lockout
|
|
118
|
+
await shortPlugin.hooks!.onLoginFailure!({
|
|
119
|
+
db: shortFortress.config.database,
|
|
120
|
+
config: shortFortress.config,
|
|
121
|
+
identifier: 'bob@example.com',
|
|
122
|
+
error: new Error('Invalid credentials'),
|
|
123
|
+
});
|
|
124
|
+
await shortPlugin.hooks!.onLoginFailure!({
|
|
125
|
+
db: shortFortress.config.database,
|
|
126
|
+
config: shortFortress.config,
|
|
127
|
+
identifier: 'bob@example.com',
|
|
128
|
+
error: new Error('Invalid credentials'),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Lockout is set but with negative duration, so lockedUntil is in the past
|
|
132
|
+
const result = await shortFortress.auth.login('bob@example.com', 'correct-password');
|
|
133
|
+
assertSuccess(result);
|
|
134
|
+
expect(result.accessToken).toBeTruthy();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('window-expiry reset, re-lock guard & identifier normalization (#34/#35/M1)', () => {
|
|
139
|
+
/** A fortress whose lockout window is already in the past (-1s) the moment it's set. */
|
|
140
|
+
function expiredLockoutFortress(): { f: Fortress; plugin: NonNullable<Fortress['config']['plugins']>[number]; m: LockoutMethods } {
|
|
141
|
+
const f = createFortress({
|
|
142
|
+
jwt: { key: SECRET },
|
|
143
|
+
database: createTestAdapter(),
|
|
144
|
+
plugins: [accountLockout({ maxFailedAttempts: 2, lockoutDurationSeconds: -1, escalation: false })],
|
|
145
|
+
});
|
|
146
|
+
const plugin = f.config.plugins!.find(p => p.name === 'account-lockout')!;
|
|
147
|
+
const m = f.plugins['account-lockout'] as unknown as LockoutMethods;
|
|
148
|
+
return { f, plugin, m };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
it('resets failedAttempts to zero when a login arrives after the window expired', async () => {
|
|
152
|
+
const { f, plugin, m } = expiredLockoutFortress();
|
|
153
|
+
await f.auth.createUser({ email: 'dave@example.com', name: 'Dave', password: 'correct-password' });
|
|
154
|
+
const fail = () => plugin.hooks!.onLoginFailure!({ db: f.config.database, config: f.config, identifier: 'dave@example.com', error: new Error('Invalid credentials') });
|
|
155
|
+
|
|
156
|
+
await fail();
|
|
157
|
+
await fail(); // reaches the threshold, but lockedUntil is already in the past
|
|
158
|
+
|
|
159
|
+
let status = await m.getLockoutStatus('dave@example.com');
|
|
160
|
+
expect(status.failedAttempts).toBe(2);
|
|
161
|
+
expect(status.isLocked).toBe(false); // window already expired
|
|
162
|
+
|
|
163
|
+
// A login attempt runs beforeLogin, which clears the stale counter.
|
|
164
|
+
await plugin.hooks!.beforeLogin!({ db: f.config.database, config: f.config, email: 'dave@example.com' });
|
|
165
|
+
|
|
166
|
+
status = await m.getLockoutStatus('dave@example.com');
|
|
167
|
+
expect(status.failedAttempts).toBe(0);
|
|
168
|
+
expect(status.lockedUntil).toBeNull();
|
|
169
|
+
expect(status.lastFailedAt).toBeNull();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('cannot be re-locked by a single failure after the window expired (no self-DoS)', async () => {
|
|
173
|
+
const { f, plugin, m } = expiredLockoutFortress();
|
|
174
|
+
await f.auth.createUser({ email: 'erin@example.com', name: 'Erin', password: 'correct-password' });
|
|
175
|
+
const fail = () => plugin.hooks!.onLoginFailure!({ db: f.config.database, config: f.config, identifier: 'erin@example.com', error: new Error('Invalid credentials') });
|
|
176
|
+
|
|
177
|
+
await fail();
|
|
178
|
+
await fail(); // threshold reached, immediately expired
|
|
179
|
+
expect((await m.getLockoutStatus('erin@example.com')).failedAttempts).toBe(2);
|
|
180
|
+
|
|
181
|
+
// One more failure after expiry must reset-then-increment to 1, NOT stack to 3 and re-lock.
|
|
182
|
+
await fail();
|
|
183
|
+
const status = await m.getLockoutStatus('erin@example.com');
|
|
184
|
+
expect(status.failedAttempts).toBe(1);
|
|
185
|
+
expect(status.isLocked).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('keys lockout state by normalized identifier (case / whitespace / NFC)', async () => {
|
|
189
|
+
// Three surface forms of the same identifier all accumulate against one
|
|
190
|
+
// normalized row (the default fortress: max 3) and reach the threshold.
|
|
191
|
+
await simulateLoginFailure('ALICE@example.com');
|
|
192
|
+
await simulateLoginFailure(' alice@example.com ');
|
|
193
|
+
await simulateLoginFailure('Alice@Example.com');
|
|
194
|
+
|
|
195
|
+
const status = await methods.getLockoutStatus('alice@example.com');
|
|
196
|
+
expect(status.failedAttempts).toBe(3);
|
|
197
|
+
expect(status.isLocked).toBe(true);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe('successful login resets failure counter', () => {
|
|
202
|
+
it('clears failedAttempts on successful login via afterLogin hook', async () => {
|
|
203
|
+
await simulateLoginFailure(email);
|
|
204
|
+
await simulateLoginFailure(email);
|
|
205
|
+
|
|
206
|
+
let status = await methods.getLockoutStatus(email);
|
|
207
|
+
expect(status.failedAttempts).toBe(2);
|
|
208
|
+
|
|
209
|
+
// Successful login
|
|
210
|
+
await fortress.auth.login(email, 'correct-password');
|
|
211
|
+
|
|
212
|
+
status = await methods.getLockoutStatus(email);
|
|
213
|
+
expect(status.failedAttempts).toBe(0);
|
|
214
|
+
expect(status.isLocked).toBe(false);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe('escalation doubles lockout duration on repeated lockouts', () => {
|
|
219
|
+
it('increases lockoutCount and duration with each lockout', async () => {
|
|
220
|
+
// First lockout: 3 failures
|
|
221
|
+
await simulateLoginFailure(email);
|
|
222
|
+
await simulateLoginFailure(email);
|
|
223
|
+
await simulateLoginFailure(email);
|
|
224
|
+
|
|
225
|
+
let status = await methods.getLockoutStatus(email);
|
|
226
|
+
expect(status.lockoutCount).toBe(1);
|
|
227
|
+
expect(status.lockedUntil).toBeTruthy();
|
|
228
|
+
|
|
229
|
+
// Admin reset to allow further testing
|
|
230
|
+
await methods.resetLockout(email);
|
|
231
|
+
|
|
232
|
+
// Second lockout: 3 failures again
|
|
233
|
+
await simulateLoginFailure(email);
|
|
234
|
+
await simulateLoginFailure(email);
|
|
235
|
+
await simulateLoginFailure(email);
|
|
236
|
+
|
|
237
|
+
status = await methods.getLockoutStatus(email);
|
|
238
|
+
// resetLockout clears lockoutCount, so this is a fresh lockout
|
|
239
|
+
expect(status.lockoutCount).toBe(1);
|
|
240
|
+
|
|
241
|
+
// Now test escalation without resetting lockoutCount.
|
|
242
|
+
// We need to simulate repeated lockouts without full reset.
|
|
243
|
+
// The onLoginFailure hook increments lockoutCount each time it locks.
|
|
244
|
+
// To test escalation, we manually set things up:
|
|
245
|
+
// After first lockout, lockoutCount = 1. Let lockout expire, then fail again.
|
|
246
|
+
// For this test, we use a fortress with short lockout.
|
|
247
|
+
const escFortress = createFortress({
|
|
248
|
+
jwt: { key: SECRET },
|
|
249
|
+
database: createTestAdapter(),
|
|
250
|
+
plugins: [accountLockout({
|
|
251
|
+
maxFailedAttempts: 1,
|
|
252
|
+
lockoutDurationSeconds: 60,
|
|
253
|
+
escalation: true,
|
|
254
|
+
maxLockoutSeconds: 300,
|
|
255
|
+
})],
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
await escFortress.auth.createUser({
|
|
259
|
+
email: 'carol@example.com',
|
|
260
|
+
name: 'Carol',
|
|
261
|
+
password: 'correct-password',
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const escPlugin = escFortress.config.plugins!.find(p => p.name === 'account-lockout')!;
|
|
265
|
+
const escMethods = escFortress.plugins['account-lockout'] as unknown as LockoutMethods;
|
|
266
|
+
|
|
267
|
+
// First lockout (lockoutCount goes 0 -> 1, duration = 60 * 2^0 = 60s)
|
|
268
|
+
await escPlugin.hooks!.onLoginFailure!({
|
|
269
|
+
db: escFortress.config.database,
|
|
270
|
+
config: escFortress.config,
|
|
271
|
+
identifier: 'carol@example.com',
|
|
272
|
+
error: new Error('Invalid credentials'),
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
let escStatus = await escMethods.getLockoutStatus('carol@example.com');
|
|
276
|
+
expect(escStatus.lockoutCount).toBe(1);
|
|
277
|
+
const firstDuration = new Date(escStatus.lockedUntil!).getTime()
|
|
278
|
+
- new Date(escStatus.lastFailedAt!).getTime();
|
|
279
|
+
|
|
280
|
+
// Manually reset only failedAttempts and lockedUntil (not lockoutCount)
|
|
281
|
+
// to simulate an expired lockout. We do this through the DB directly.
|
|
282
|
+
await escFortress.config.database.update({
|
|
283
|
+
model: 'account_lockout',
|
|
284
|
+
where: [{ field: 'identifier', operator: '=', value: 'carol@example.com' }],
|
|
285
|
+
data: { failedAttempts: 0, lockedUntil: null },
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// Second lockout (lockoutCount goes 1 -> 2, duration = 60 * 2^1 = 120s)
|
|
289
|
+
await escPlugin.hooks!.onLoginFailure!({
|
|
290
|
+
db: escFortress.config.database,
|
|
291
|
+
config: escFortress.config,
|
|
292
|
+
identifier: 'carol@example.com',
|
|
293
|
+
error: new Error('Invalid credentials'),
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
escStatus = await escMethods.getLockoutStatus('carol@example.com');
|
|
297
|
+
expect(escStatus.lockoutCount).toBe(2);
|
|
298
|
+
const secondDuration = new Date(escStatus.lockedUntil!).getTime()
|
|
299
|
+
- new Date(escStatus.lastFailedAt!).getTime();
|
|
300
|
+
|
|
301
|
+
// Second lockout should be roughly double the first
|
|
302
|
+
expect(secondDuration).toBeGreaterThan(firstDuration);
|
|
303
|
+
expect(Math.round(secondDuration / firstDuration)).toBe(2);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
describe('getLockoutStatus', () => {
|
|
308
|
+
it('returns clean status for unknown identifier', async () => {
|
|
309
|
+
const status = await methods.getLockoutStatus('unknown@example.com');
|
|
310
|
+
expect(status.failedAttempts).toBe(0);
|
|
311
|
+
expect(status.lockoutCount).toBe(0);
|
|
312
|
+
expect(status.lockedUntil).toBeNull();
|
|
313
|
+
expect(status.lastFailedAt).toBeNull();
|
|
314
|
+
expect(status.isLocked).toBe(false);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('returns current status for tracked identifier', async () => {
|
|
318
|
+
await simulateLoginFailure(email);
|
|
319
|
+
|
|
320
|
+
const status = await methods.getLockoutStatus(email);
|
|
321
|
+
expect(status.identifier).toBe(email);
|
|
322
|
+
expect(status.failedAttempts).toBe(1);
|
|
323
|
+
expect(status.lastFailedAt).toBeTruthy();
|
|
324
|
+
expect(status.isLocked).toBe(false);
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
describe('resetLockout', () => {
|
|
329
|
+
it('clears all lockout data for an identifier', async () => {
|
|
330
|
+
// Lock the account
|
|
331
|
+
await simulateLoginFailure(email);
|
|
332
|
+
await simulateLoginFailure(email);
|
|
333
|
+
await simulateLoginFailure(email);
|
|
334
|
+
|
|
335
|
+
let status = await methods.getLockoutStatus(email);
|
|
336
|
+
expect(status.isLocked).toBe(true);
|
|
337
|
+
expect(status.lockoutCount).toBe(1);
|
|
338
|
+
|
|
339
|
+
// Admin reset
|
|
340
|
+
await methods.resetLockout(email);
|
|
341
|
+
|
|
342
|
+
status = await methods.getLockoutStatus(email);
|
|
343
|
+
expect(status.failedAttempts).toBe(0);
|
|
344
|
+
expect(status.lockoutCount).toBe(0);
|
|
345
|
+
expect(status.lockedUntil).toBeNull();
|
|
346
|
+
expect(status.lastFailedAt).toBeNull();
|
|
347
|
+
expect(status.isLocked).toBe(false);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('is a no-op for unknown identifier', async () => {
|
|
351
|
+
// Should not throw
|
|
352
|
+
await methods.resetLockout('nonexistent@example.com');
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
describe('lockout for non-existent accounts', () => {
|
|
357
|
+
it('tracks lockouts for identifiers without user accounts', async () => {
|
|
358
|
+
await simulateLoginFailure('phantom@example.com');
|
|
359
|
+
await simulateLoginFailure('phantom@example.com');
|
|
360
|
+
await simulateLoginFailure('phantom@example.com');
|
|
361
|
+
|
|
362
|
+
const status = await methods.getLockoutStatus('phantom@example.com');
|
|
363
|
+
expect(status.isLocked).toBe(true);
|
|
364
|
+
expect(status.failedAttempts).toBe(3);
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
});
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Account lockout plugin for fortress.
|
|
3
|
+
*
|
|
4
|
+
* Tracks failed sign-in attempts per identifier and applies a progressive
|
|
5
|
+
* lockout (each successive lockout extends the cooldown). Hooks into the
|
|
6
|
+
* sign-in flow to reject attempts during a lockout window.
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { FortressPlugin } from '../../core/plugin';
|
|
12
|
+
import { Errors } from '../../core/errors';
|
|
13
|
+
|
|
14
|
+
export interface AccountLockoutConfig {
|
|
15
|
+
/** Max failed attempts before lockout. Default: 5. */
|
|
16
|
+
maxFailedAttempts?: number;
|
|
17
|
+
/** Initial lockout duration in seconds. Default: 900 (15 minutes). */
|
|
18
|
+
lockoutDurationSeconds?: number;
|
|
19
|
+
/** Double lockout duration on repeated lockouts. Default: true. */
|
|
20
|
+
escalation?: boolean;
|
|
21
|
+
/** Maximum lockout duration in seconds. Default: 3600 (1 hour). */
|
|
22
|
+
maxLockoutSeconds?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface LockoutStatus {
|
|
26
|
+
identifier: string;
|
|
27
|
+
failedAttempts: number;
|
|
28
|
+
lockoutCount: number;
|
|
29
|
+
lockedUntil: Date | null;
|
|
30
|
+
lastFailedAt: Date | null;
|
|
31
|
+
isLocked: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface LockoutRecord {
|
|
35
|
+
id: string;
|
|
36
|
+
identifier: string;
|
|
37
|
+
failedAttempts: number;
|
|
38
|
+
lastFailedAt: Date | null;
|
|
39
|
+
lockedUntil: Date | null;
|
|
40
|
+
lockoutCount: number;
|
|
41
|
+
createdAt: Date;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Account lockout plugin factory. Returns a {@link FortressPlugin} that
|
|
46
|
+
* tracks failed sign-in attempts per identifier and applies a progressive
|
|
47
|
+
* lockout (each successive lockout extends the cooldown window).
|
|
48
|
+
*/
|
|
49
|
+
export function accountLockout(config: AccountLockoutConfig = {}): FortressPlugin {
|
|
50
|
+
const maxFailedAttempts = config.maxFailedAttempts ?? 5;
|
|
51
|
+
const lockoutDurationSeconds = config.lockoutDurationSeconds ?? 900;
|
|
52
|
+
const escalation = config.escalation ?? true;
|
|
53
|
+
const maxLockoutSeconds = config.maxLockoutSeconds ?? 3600;
|
|
54
|
+
|
|
55
|
+
function normalizeIdentifier(identifier: string): string {
|
|
56
|
+
return identifier.trim().normalize('NFC').toLowerCase();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function calculateLockoutDuration(lockoutCount: number): number {
|
|
60
|
+
if (!escalation) {
|
|
61
|
+
return lockoutDurationSeconds;
|
|
62
|
+
}
|
|
63
|
+
const duration = lockoutDurationSeconds * (2 ** lockoutCount);
|
|
64
|
+
return Math.min(duration, maxLockoutSeconds);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
name: 'account-lockout',
|
|
69
|
+
|
|
70
|
+
models: [{
|
|
71
|
+
name: 'account_lockout',
|
|
72
|
+
fields: {
|
|
73
|
+
id: { type: 'number', required: true },
|
|
74
|
+
identifier: { type: 'string', required: true, unique: true },
|
|
75
|
+
failedAttempts: { type: 'number', required: true },
|
|
76
|
+
lastFailedAt: { type: 'date' },
|
|
77
|
+
lockedUntil: { type: 'date' },
|
|
78
|
+
lockoutCount: { type: 'number', required: true },
|
|
79
|
+
createdAt: { type: 'date', required: true },
|
|
80
|
+
},
|
|
81
|
+
}],
|
|
82
|
+
|
|
83
|
+
hooks: {
|
|
84
|
+
async beforeLogin(ctx) {
|
|
85
|
+
const identifier = normalizeIdentifier(ctx.email);
|
|
86
|
+
|
|
87
|
+
const record = await ctx.db.findOne<LockoutRecord>({
|
|
88
|
+
model: 'account_lockout',
|
|
89
|
+
where: [{ field: 'identifier', operator: '=', value: identifier }],
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
if (!record) {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const now = new Date();
|
|
97
|
+
if (record.lockedUntil && record.lockedUntil > now) {
|
|
98
|
+
throw Errors.unauthorized('Account temporarily locked. Try again later.');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (record.lockedUntil && record.lockedUntil <= now) {
|
|
102
|
+
await ctx.db.update({
|
|
103
|
+
model: 'account_lockout',
|
|
104
|
+
where: [{ field: 'id', operator: '=', value: record.id }],
|
|
105
|
+
data: { failedAttempts: 0, lockedUntil: null, lastFailedAt: null },
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return undefined;
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
async onLoginFailure(ctx) {
|
|
113
|
+
if (ctx.error.message.includes('temporarily locked'))
|
|
114
|
+
return;
|
|
115
|
+
const identifier = normalizeIdentifier(ctx.identifier);
|
|
116
|
+
|
|
117
|
+
// Compare-and-swap retry loop. DatabaseAdapter has no arithmetic
|
|
118
|
+
// update expression, so we make the read-then-write safe by including
|
|
119
|
+
// the observed counters in the UPDATE predicate. A concurrent failure
|
|
120
|
+
// that wins the race changes those counters and makes our update
|
|
121
|
+
// return null; we then re-read and retry instead of losing a count.
|
|
122
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
123
|
+
const now = new Date();
|
|
124
|
+
let record = await ctx.db.findOne<LockoutRecord>({
|
|
125
|
+
model: 'account_lockout',
|
|
126
|
+
where: [{ field: 'identifier', operator: '=', value: identifier }],
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
if (!record) {
|
|
130
|
+
const newFailedAttempts = 1;
|
|
131
|
+
const updateData: Record<string, unknown> = {
|
|
132
|
+
identifier,
|
|
133
|
+
failedAttempts: newFailedAttempts,
|
|
134
|
+
lastFailedAt: now,
|
|
135
|
+
lockedUntil: null,
|
|
136
|
+
lockoutCount: 0,
|
|
137
|
+
};
|
|
138
|
+
if (newFailedAttempts >= maxFailedAttempts) {
|
|
139
|
+
updateData.lockedUntil = new Date(now.getTime() + calculateLockoutDuration(0) * 1000);
|
|
140
|
+
updateData.lockoutCount = 1;
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
await ctx.db.create<LockoutRecord>({ model: 'account_lockout', data: updateData });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const expired = record.lockedUntil !== null && record.lockedUntil <= now;
|
|
152
|
+
const observedFailedAttempts = record.failedAttempts;
|
|
153
|
+
const observedLockoutCount = record.lockoutCount;
|
|
154
|
+
if (expired) {
|
|
155
|
+
record = { ...record, failedAttempts: 0, lockedUntil: null, lastFailedAt: null };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const newFailedAttempts = record.failedAttempts + 1;
|
|
159
|
+
const updateData: Record<string, unknown> = {
|
|
160
|
+
failedAttempts: newFailedAttempts,
|
|
161
|
+
lastFailedAt: now,
|
|
162
|
+
lockedUntil: expired ? null : record.lockedUntil,
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
if (newFailedAttempts >= maxFailedAttempts) {
|
|
166
|
+
const duration = calculateLockoutDuration(record.lockoutCount);
|
|
167
|
+
updateData.lockedUntil = new Date(now.getTime() + duration * 1000);
|
|
168
|
+
updateData.lockoutCount = record.lockoutCount + 1;
|
|
169
|
+
}
|
|
170
|
+
else if (expired) {
|
|
171
|
+
updateData.lockoutCount = record.lockoutCount;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const updated = await ctx.db.update<LockoutRecord>({
|
|
175
|
+
model: 'account_lockout',
|
|
176
|
+
where: [
|
|
177
|
+
{ field: 'id', operator: '=', value: record.id },
|
|
178
|
+
{ field: 'failedAttempts', operator: '=', value: observedFailedAttempts },
|
|
179
|
+
{ field: 'lockoutCount', operator: '=', value: observedLockoutCount },
|
|
180
|
+
],
|
|
181
|
+
data: updateData,
|
|
182
|
+
});
|
|
183
|
+
if (updated)
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
async afterLogin(ctx, result) {
|
|
189
|
+
const identifier = normalizeIdentifier(ctx.identifier ?? result.user.email);
|
|
190
|
+
|
|
191
|
+
const record = await ctx.db.findOne<LockoutRecord>({
|
|
192
|
+
model: 'account_lockout',
|
|
193
|
+
where: [{ field: 'identifier', operator: '=', value: identifier }],
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
if (record && record.failedAttempts > 0) {
|
|
197
|
+
await ctx.db.update({
|
|
198
|
+
model: 'account_lockout',
|
|
199
|
+
where: [{ field: 'id', operator: '=', value: record.id }],
|
|
200
|
+
data: {
|
|
201
|
+
failedAttempts: 0,
|
|
202
|
+
lockedUntil: null,
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return result;
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
methods: ctx => ({
|
|
212
|
+
async getLockoutStatus(identifier: string): Promise<LockoutStatus> {
|
|
213
|
+
const normalized = normalizeIdentifier(identifier);
|
|
214
|
+
const record = await ctx.db.findOne<LockoutRecord>({
|
|
215
|
+
model: 'account_lockout',
|
|
216
|
+
where: [{ field: 'identifier', operator: '=', value: normalized }],
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
if (!record) {
|
|
220
|
+
return {
|
|
221
|
+
identifier: normalized,
|
|
222
|
+
failedAttempts: 0,
|
|
223
|
+
lockoutCount: 0,
|
|
224
|
+
lockedUntil: null,
|
|
225
|
+
lastFailedAt: null,
|
|
226
|
+
isLocked: false,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const isLocked = record.lockedUntil !== null
|
|
231
|
+
&& record.lockedUntil > new Date();
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
identifier: record.identifier,
|
|
235
|
+
failedAttempts: record.failedAttempts,
|
|
236
|
+
lockoutCount: record.lockoutCount,
|
|
237
|
+
lockedUntil: record.lockedUntil,
|
|
238
|
+
lastFailedAt: record.lastFailedAt,
|
|
239
|
+
isLocked,
|
|
240
|
+
};
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
async resetLockout(identifier: string): Promise<void> {
|
|
244
|
+
const normalized = normalizeIdentifier(identifier);
|
|
245
|
+
const record = await ctx.db.findOne<LockoutRecord>({
|
|
246
|
+
model: 'account_lockout',
|
|
247
|
+
where: [{ field: 'identifier', operator: '=', value: normalized }],
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
if (!record) {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
await ctx.db.update({
|
|
255
|
+
model: 'account_lockout',
|
|
256
|
+
where: [{ field: 'id', operator: '=', value: record.id }],
|
|
257
|
+
data: {
|
|
258
|
+
failedAttempts: 0,
|
|
259
|
+
lockedUntil: null,
|
|
260
|
+
lockoutCount: 0,
|
|
261
|
+
lastFailedAt: null,
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
},
|
|
265
|
+
}),
|
|
266
|
+
};
|
|
267
|
+
}
|