@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,220 @@
|
|
|
1
|
+
import { Errors } from '../errors';
|
|
2
|
+
import { createOutboundClient } from '../http/outbound';
|
|
3
|
+
import { normalizePasswordInput } from './password';
|
|
4
|
+
|
|
5
|
+
export type PasswordBreachFailureMode = 'open' | 'closed';
|
|
6
|
+
|
|
7
|
+
export interface PasswordBreachDegradedEvent {
|
|
8
|
+
failureMode: PasswordBreachFailureMode;
|
|
9
|
+
status?: number;
|
|
10
|
+
error?: unknown;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Called whenever the HIBP control is unavailable, regardless of failure mode. */
|
|
14
|
+
export type PasswordPolicyObserver = (event: PasswordBreachDegradedEvent) => void;
|
|
15
|
+
|
|
16
|
+
export interface PasswordPolicyConfig {
|
|
17
|
+
/** Minimum password length. Default: 15 (NIST 800-63B). */
|
|
18
|
+
minLength?: number;
|
|
19
|
+
/** Maximum password length. Default: 128 (NIST 800-63B). */
|
|
20
|
+
maxLength?: number;
|
|
21
|
+
/** Check password against HIBP breached password database via k-anonymity API. Default: false. */
|
|
22
|
+
checkBreached?: boolean;
|
|
23
|
+
/** Cache TTL for HIBP range responses in milliseconds. Default: 86400000 (24h). */
|
|
24
|
+
breachedCacheTtlMs?: number;
|
|
25
|
+
/** Maximum cached HIBP range responses. Default: 1000. Set to 0 to disable caching. */
|
|
26
|
+
breachedCacheMaxEntries?: number;
|
|
27
|
+
/** Whether HIBP unavailability permits password writes. Default: 'open'. */
|
|
28
|
+
breachedFailureMode?: PasswordBreachFailureMode;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type HibpCache = Map<string, { data: string; expiresAt: number }>;
|
|
32
|
+
|
|
33
|
+
export interface PasswordBreachCheckOptions {
|
|
34
|
+
cacheTtlMs?: number;
|
|
35
|
+
cacheMaxEntries?: number;
|
|
36
|
+
failureMode?: PasswordBreachFailureMode;
|
|
37
|
+
observer?: PasswordPolicyObserver;
|
|
38
|
+
/** Internal per-Fortress-instance cache supplied by validatePassword. */
|
|
39
|
+
cache?: HibpCache;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const DEFAULT_MIN_LENGTH = 15;
|
|
43
|
+
const DEFAULT_MAX_LENGTH = 128;
|
|
44
|
+
const DEFAULT_CACHE_TTL_MS = 86_400_000; // 24 hours
|
|
45
|
+
const DEFAULT_CACHE_MAX_ENTRIES = 1_000;
|
|
46
|
+
// Tighter timeout than the default outbound budget: the breach check runs in
|
|
47
|
+
// the password-validation hot path, so a slow HIBP must not stall writes.
|
|
48
|
+
const HIBP_TIMEOUT_MS = 6_000;
|
|
49
|
+
const hibpOutboundClient = createOutboundClient(HIBP_TIMEOUT_MS);
|
|
50
|
+
|
|
51
|
+
// Direct isPasswordBreached callers share this convenience cache. Fortress
|
|
52
|
+
// instances use a cache keyed by their own PasswordPolicyConfig object so one
|
|
53
|
+
// tenant's bound/eviction policy cannot mutate a co-resident instance's cache.
|
|
54
|
+
const directHibpCache: HibpCache = new Map();
|
|
55
|
+
const policyCaches = new WeakMap<PasswordPolicyConfig, HibpCache>();
|
|
56
|
+
const knownCaches = new Set<HibpCache>([directHibpCache]);
|
|
57
|
+
|
|
58
|
+
function cacheForPolicy(config: PasswordPolicyConfig): HibpCache {
|
|
59
|
+
let cache = policyCaches.get(config);
|
|
60
|
+
if (!cache) {
|
|
61
|
+
cache = new Map();
|
|
62
|
+
policyCaches.set(config, cache);
|
|
63
|
+
knownCaches.add(cache);
|
|
64
|
+
}
|
|
65
|
+
return cache;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Validate a password against the configured policy.
|
|
70
|
+
* Throws `Errors.badRequest()` if the password does not meet requirements.
|
|
71
|
+
*/
|
|
72
|
+
export async function validatePassword(
|
|
73
|
+
password: string,
|
|
74
|
+
config: PasswordPolicyConfig = {},
|
|
75
|
+
observer?: PasswordPolicyObserver,
|
|
76
|
+
): Promise<void> {
|
|
77
|
+
const normalizedPassword = normalizePasswordInput(password);
|
|
78
|
+
const minLength = config.minLength ?? DEFAULT_MIN_LENGTH;
|
|
79
|
+
const maxLength = config.maxLength ?? DEFAULT_MAX_LENGTH;
|
|
80
|
+
if (!Number.isInteger(minLength) || minLength <= 0)
|
|
81
|
+
throw Errors.badRequest('passwordPolicy.minLength must be a positive integer');
|
|
82
|
+
if (!Number.isInteger(maxLength) || maxLength <= 0)
|
|
83
|
+
throw Errors.badRequest('passwordPolicy.maxLength must be a positive integer');
|
|
84
|
+
if (minLength > maxLength)
|
|
85
|
+
throw Errors.badRequest('passwordPolicy.minLength cannot exceed maxLength');
|
|
86
|
+
|
|
87
|
+
if (normalizedPassword.length < minLength) {
|
|
88
|
+
throw Errors.badRequest(`Password must be at least ${minLength} characters`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (normalizedPassword.length > maxLength) {
|
|
92
|
+
throw Errors.badRequest(`Password must be at most ${maxLength} characters`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (config.checkBreached) {
|
|
96
|
+
const breached = await isPasswordBreached(normalizedPassword, {
|
|
97
|
+
cacheTtlMs: config.breachedCacheTtlMs,
|
|
98
|
+
cacheMaxEntries: config.breachedCacheMaxEntries,
|
|
99
|
+
failureMode: config.breachedFailureMode,
|
|
100
|
+
observer,
|
|
101
|
+
cache: cacheForPolicy(config),
|
|
102
|
+
});
|
|
103
|
+
if (breached) {
|
|
104
|
+
throw Errors.badRequest('This password has appeared in a data breach and cannot be used');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function rememberRange(cache: HibpCache, prefix: string, data: string, expiresAt: number, maxEntries: number): void {
|
|
110
|
+
if (maxEntries <= 0)
|
|
111
|
+
return;
|
|
112
|
+
|
|
113
|
+
cache.delete(prefix);
|
|
114
|
+
while (cache.size >= maxEntries) {
|
|
115
|
+
const oldest = cache.keys().next().value as string | undefined;
|
|
116
|
+
if (oldest === undefined)
|
|
117
|
+
break;
|
|
118
|
+
cache.delete(oldest);
|
|
119
|
+
}
|
|
120
|
+
cache.set(prefix, { data, expiresAt });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function handleDegradedCheck(
|
|
124
|
+
options: PasswordBreachCheckOptions,
|
|
125
|
+
details: { status?: number; error?: unknown },
|
|
126
|
+
): false {
|
|
127
|
+
const failureMode = options.failureMode ?? 'open';
|
|
128
|
+
options.observer?.({ failureMode, ...details });
|
|
129
|
+
|
|
130
|
+
// Direct callers may not provide an observer. Keep degraded controls visible
|
|
131
|
+
// rather than silently failing open/closed.
|
|
132
|
+
if (!options.observer) {
|
|
133
|
+
const reason = details.status !== undefined
|
|
134
|
+
? `HTTP ${details.status}`
|
|
135
|
+
: details.error instanceof Error ? details.error.message : String(details.error);
|
|
136
|
+
console.warn(`[fortress/password-policy] HIBP range API unavailable (${reason}); failing ${failureMode}.`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (failureMode === 'closed') {
|
|
140
|
+
throw Errors.serviceUnavailable('Password breach check unavailable', { cause: details.error });
|
|
141
|
+
}
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Check if a password appears in the Have I Been Pwned breached password database
|
|
147
|
+
* using the k-anonymity API (only the first 5 characters of the SHA-1 hash are sent).
|
|
148
|
+
*/
|
|
149
|
+
export async function isPasswordBreached(
|
|
150
|
+
password: string,
|
|
151
|
+
options: PasswordBreachCheckOptions = {},
|
|
152
|
+
): Promise<boolean> {
|
|
153
|
+
const cacheTtlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
|
|
154
|
+
const cacheMaxEntries = options.cacheMaxEntries ?? DEFAULT_CACHE_MAX_ENTRIES;
|
|
155
|
+
const hibpCache = options.cache ?? directHibpCache;
|
|
156
|
+
if (!Number.isFinite(cacheTtlMs) || cacheTtlMs < 0)
|
|
157
|
+
throw Errors.badRequest('passwordPolicy.breachedCacheTtlMs must be a non-negative number');
|
|
158
|
+
if (!Number.isInteger(cacheMaxEntries) || cacheMaxEntries < 0)
|
|
159
|
+
throw Errors.badRequest('passwordPolicy.breachedCacheMaxEntries must be a non-negative integer');
|
|
160
|
+
if (options.failureMode !== undefined && options.failureMode !== 'open' && options.failureMode !== 'closed')
|
|
161
|
+
throw Errors.badRequest('passwordPolicy.breachedFailureMode must be \'open\' or \'closed\'');
|
|
162
|
+
|
|
163
|
+
// SHA-1 hash of the canonical password form.
|
|
164
|
+
const encoded = new TextEncoder().encode(normalizePasswordInput(password));
|
|
165
|
+
const hashBuffer = await crypto.subtle.digest('SHA-1', encoded);
|
|
166
|
+
const hashHex = Array.from(new Uint8Array(hashBuffer))
|
|
167
|
+
.map(b => b.toString(16).padStart(2, '0'))
|
|
168
|
+
.join('')
|
|
169
|
+
.toUpperCase();
|
|
170
|
+
|
|
171
|
+
const prefix = hashHex.slice(0, 5);
|
|
172
|
+
const suffix = hashHex.slice(5);
|
|
173
|
+
|
|
174
|
+
// Enforce the active caller's bound before reading as well as writing. This
|
|
175
|
+
// makes `0` a real cache bypass and lets deployments safely lower a bound.
|
|
176
|
+
while (hibpCache.size > cacheMaxEntries) {
|
|
177
|
+
const oldest = hibpCache.keys().next().value as string | undefined;
|
|
178
|
+
if (oldest === undefined)
|
|
179
|
+
break;
|
|
180
|
+
hibpCache.delete(oldest);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const cached = cacheMaxEntries > 0 ? hibpCache.get(prefix) : undefined;
|
|
184
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
185
|
+
// Touch the entry so Map insertion order remains LRU order.
|
|
186
|
+
hibpCache.delete(prefix);
|
|
187
|
+
hibpCache.set(prefix, cached);
|
|
188
|
+
return cached.data.includes(suffix);
|
|
189
|
+
}
|
|
190
|
+
if (cached)
|
|
191
|
+
hibpCache.delete(prefix);
|
|
192
|
+
|
|
193
|
+
let response: Response;
|
|
194
|
+
try {
|
|
195
|
+
response = await hibpOutboundClient.get(`https://api.pwnedpasswords.com/range/${prefix}`, {
|
|
196
|
+
headers: { 'Add-Padding': 'true' },
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
return handleDegradedCheck(options, { error });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (!response.ok)
|
|
204
|
+
return handleDegradedCheck(options, { status: response.status });
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
const data = await response.text();
|
|
208
|
+
rememberRange(hibpCache, prefix, data, Date.now() + cacheTtlMs, cacheMaxEntries);
|
|
209
|
+
return data.includes(suffix);
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
return handleDegradedCheck(options, { error });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Clear the HIBP cache. Useful for testing. */
|
|
217
|
+
export function clearHibpCache(): void {
|
|
218
|
+
for (const cache of knownCaches)
|
|
219
|
+
cache.clear();
|
|
220
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { createDefaultHasher } from './password';
|
|
4
|
+
|
|
5
|
+
describe('defaultHasher', () => {
|
|
6
|
+
const hasher = createDefaultHasher();
|
|
7
|
+
|
|
8
|
+
it('hashes a password and returns an encoded string', async () => {
|
|
9
|
+
const hash = await hasher.hash('my-password');
|
|
10
|
+
expect(hash).toBeTruthy();
|
|
11
|
+
expect(hash).toContain('$argon2id$');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('produces different hashes for the same password (random salt)', async () => {
|
|
15
|
+
const hash1 = await hasher.hash('same-password');
|
|
16
|
+
const hash2 = await hasher.hash('same-password');
|
|
17
|
+
expect(hash1).not.toBe(hash2);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('verifies a correct password', async () => {
|
|
21
|
+
const hash = await hasher.hash('correct-password');
|
|
22
|
+
const result = await hasher.verify(hash, 'correct-password');
|
|
23
|
+
expect(result).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('normalizes password input with NFKC before hashing and verifying', async () => {
|
|
27
|
+
const hash = await hasher.hash('Password123!');
|
|
28
|
+
const result = await hasher.verify(hash, 'Password123!');
|
|
29
|
+
expect(result).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('rejects an incorrect password', async () => {
|
|
33
|
+
const hash = await hasher.hash('correct-password');
|
|
34
|
+
const result = await hasher.verify(hash, 'wrong-password');
|
|
35
|
+
expect(result).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('returns false for malformed hash instead of throwing', async () => {
|
|
39
|
+
const result = await hasher.verify('not-a-valid-hash', 'any-password');
|
|
40
|
+
expect(result).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Password hashing primitives for fortress.
|
|
3
|
+
*
|
|
4
|
+
* Exports {@link createDefaultHasher}, a WASM-based Argon2id `PasswordHasher`
|
|
5
|
+
* that works across every runtime fortress targets (Bun, Deno, Node, Cloudflare
|
|
6
|
+
* Workers, Vercel Edge). Consumers can drop in a faster native hasher
|
|
7
|
+
* (`@node-rs/argon2`, `Bun.password`) by implementing the `PasswordHasher`
|
|
8
|
+
* contract and passing it to `createFortress({ passwordHasher })`.
|
|
9
|
+
*
|
|
10
|
+
* @module
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { argon2id, argon2Verify } from 'hash-wasm';
|
|
14
|
+
|
|
15
|
+
export type { PasswordHasher } from '../config';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Canonicalize password input before policy checks, breach checks, hashing,
|
|
19
|
+
* and verification. NFKC collapses visually/confusably equivalent Unicode
|
|
20
|
+
* forms (for example full-width ASCII) so users are not locked out by input
|
|
21
|
+
* method differences.
|
|
22
|
+
*/
|
|
23
|
+
export function normalizePasswordInput(password: string): string {
|
|
24
|
+
return password.normalize('NFKC');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Default PasswordHasher using WASM-based Argon2id.
|
|
29
|
+
* Works across all runtimes (Bun, Deno, Node, edge).
|
|
30
|
+
* Consumers can swap for @node-rs/argon2 or Bun.password for native speed.
|
|
31
|
+
*/
|
|
32
|
+
export function createDefaultHasher(): {
|
|
33
|
+
hash: (password: string) => Promise<string>;
|
|
34
|
+
verify: (hash: string, password: string) => Promise<boolean>;
|
|
35
|
+
} {
|
|
36
|
+
return {
|
|
37
|
+
async hash(password: string): Promise<string> {
|
|
38
|
+
const normalizedPassword = normalizePasswordInput(password);
|
|
39
|
+
const salt = new Uint8Array(16);
|
|
40
|
+
crypto.getRandomValues(salt);
|
|
41
|
+
|
|
42
|
+
return argon2id({
|
|
43
|
+
password: normalizedPassword,
|
|
44
|
+
salt,
|
|
45
|
+
parallelism: 1,
|
|
46
|
+
iterations: 3,
|
|
47
|
+
memorySize: 65536, // 64 MB
|
|
48
|
+
hashLength: 32,
|
|
49
|
+
outputType: 'encoded',
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
async verify(hash: string, password: string): Promise<boolean> {
|
|
54
|
+
try {
|
|
55
|
+
return await argon2Verify({ hash, password: normalizePasswordInput(password) });
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import type { FortressPlugin } from '../plugin';
|
|
2
|
+
import type { AuthEvent } from './auth-service';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { createTestAdapter } from '../../testing';
|
|
5
|
+
import { Errors } from '../errors';
|
|
6
|
+
import { createFortress } from '../fortress';
|
|
7
|
+
|
|
8
|
+
const SECRET = 'post-auth-gate-test-secret-32chars';
|
|
9
|
+
|
|
10
|
+
function factorPlugin(
|
|
11
|
+
name: string,
|
|
12
|
+
reason: 'two-factor' | 'webauthn',
|
|
13
|
+
expectedCompletion: string,
|
|
14
|
+
onVerify?: () => void,
|
|
15
|
+
policy?: { maxAttempts?: number; cooldownSeconds?: number },
|
|
16
|
+
): FortressPlugin {
|
|
17
|
+
return {
|
|
18
|
+
name,
|
|
19
|
+
hooks: {
|
|
20
|
+
postAuthGate: {
|
|
21
|
+
reason,
|
|
22
|
+
...policy,
|
|
23
|
+
async evaluate() {
|
|
24
|
+
return { pluginData: { factor: reason } };
|
|
25
|
+
},
|
|
26
|
+
async verify(_ctx, completion) {
|
|
27
|
+
onVerify?.();
|
|
28
|
+
if (completion !== expectedCompletion)
|
|
29
|
+
throw Errors.unauthorized('Invalid factor proof');
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
methods: ctx => ({
|
|
34
|
+
async verify(continuationToken: string, completion: string) {
|
|
35
|
+
return ctx.auth!.completePendingAuth(continuationToken, completion);
|
|
36
|
+
},
|
|
37
|
+
}),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function seedUser(fortress: ReturnType<typeof createFortress>): Promise<void> {
|
|
42
|
+
await fortress.auth.createUser({
|
|
43
|
+
email: 'gated@example.com',
|
|
44
|
+
name: 'Gated User',
|
|
45
|
+
password: 'password-123456',
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('post-auth gate', () => {
|
|
50
|
+
it('holds before token issuance and completes exactly once', async () => {
|
|
51
|
+
const database = createTestAdapter();
|
|
52
|
+
let afterLoginCalls = 0;
|
|
53
|
+
const fortress = createFortress({
|
|
54
|
+
jwt: { key: SECRET },
|
|
55
|
+
database,
|
|
56
|
+
plugins: [
|
|
57
|
+
{
|
|
58
|
+
name: 'observer',
|
|
59
|
+
hooks: {
|
|
60
|
+
afterLogin: async (_ctx, result) => {
|
|
61
|
+
afterLoginCalls++;
|
|
62
|
+
return result;
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
factorPlugin('factor', 'two-factor', '123456', undefined, { cooldownSeconds: 0 }),
|
|
67
|
+
],
|
|
68
|
+
});
|
|
69
|
+
await seedUser(fortress);
|
|
70
|
+
const events: AuthEvent[] = [];
|
|
71
|
+
fortress.auth.addAuthObserver(event => void events.push(event));
|
|
72
|
+
|
|
73
|
+
const pending = await fortress.auth.login('gated@example.com', 'password-123456');
|
|
74
|
+
expect(pending).toMatchObject({
|
|
75
|
+
status: 'pending',
|
|
76
|
+
pending: { reason: 'two-factor' },
|
|
77
|
+
pluginData: { factor: 'two-factor' },
|
|
78
|
+
});
|
|
79
|
+
expect(await database.count({ model: 'refresh_token' })).toBe(0);
|
|
80
|
+
expect(await database.count({ model: 'auth_continuation' })).toBe(1);
|
|
81
|
+
const storedContinuation = await database.findOne<{ tokenHash: string }>({
|
|
82
|
+
model: 'auth_continuation',
|
|
83
|
+
where: [{ field: 'userId', operator: '=', value: pending.user.id }],
|
|
84
|
+
});
|
|
85
|
+
expect(events.map(event => event.eventType)).toContain('LOGIN_PENDING');
|
|
86
|
+
expect(events.map(event => event.eventType)).not.toContain('LOGIN_SUCCESS');
|
|
87
|
+
expect(afterLoginCalls).toBe(0);
|
|
88
|
+
|
|
89
|
+
if (pending.status !== 'pending' || !pending.pending)
|
|
90
|
+
throw new Error('Expected pending auth challenge');
|
|
91
|
+
expect(storedContinuation?.tokenHash).not.toBe(pending.pending.continuationToken);
|
|
92
|
+
|
|
93
|
+
await expect(
|
|
94
|
+
fortress.auth.completePendingAuth(pending.pending.continuationToken, 'wrong'),
|
|
95
|
+
).rejects.toThrow('Invalid factor proof');
|
|
96
|
+
expect(events.some(event => event.eventType === 'MFA_VERIFY_FAILURE' && event.method === '2fa')).toBe(true);
|
|
97
|
+
expect(await database.count({
|
|
98
|
+
model: 'auth_continuation',
|
|
99
|
+
where: [{ field: 'consumedAt', operator: 'isNull', value: null }],
|
|
100
|
+
})).toBe(1);
|
|
101
|
+
|
|
102
|
+
const completed = await fortress.auth.completePendingAuth(
|
|
103
|
+
pending.pending.continuationToken,
|
|
104
|
+
'123456',
|
|
105
|
+
);
|
|
106
|
+
expect(completed).toMatchObject({
|
|
107
|
+
status: 'success',
|
|
108
|
+
method: 'two-factor',
|
|
109
|
+
user: { email: 'gated@example.com' },
|
|
110
|
+
});
|
|
111
|
+
if (completed.status !== 'success')
|
|
112
|
+
throw new Error('Expected completed authentication');
|
|
113
|
+
expect(completed.accessToken).toBeTruthy();
|
|
114
|
+
expect(completed.refreshToken).toBeTruthy();
|
|
115
|
+
expect('passwordHash' in completed.user).toBe(false);
|
|
116
|
+
expect(await database.count({ model: 'refresh_token' })).toBe(1);
|
|
117
|
+
expect(events.some(event => event.eventType === 'MFA_VERIFY_SUCCESS' && event.method === '2fa')).toBe(true);
|
|
118
|
+
expect(events.some(event => event.eventType === 'LOGIN_SUCCESS' && event.method === '2fa')).toBe(true);
|
|
119
|
+
expect(afterLoginCalls).toBe(1);
|
|
120
|
+
|
|
121
|
+
await expect(
|
|
122
|
+
fortress.auth.completePendingAuth(pending.pending.continuationToken, '123456'),
|
|
123
|
+
).rejects.toThrow('Invalid or expired auth continuation');
|
|
124
|
+
expect(await database.count({ model: 'refresh_token' })).toBe(1);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('durably caps concurrent rejected proofs at the continuation attempt limit', async () => {
|
|
128
|
+
const database = createTestAdapter();
|
|
129
|
+
let verificationCalls = 0;
|
|
130
|
+
const fortress = createFortress({
|
|
131
|
+
jwt: { key: SECRET },
|
|
132
|
+
database,
|
|
133
|
+
plugins: [factorPlugin(
|
|
134
|
+
'factor',
|
|
135
|
+
'two-factor',
|
|
136
|
+
'correct',
|
|
137
|
+
() => verificationCalls++,
|
|
138
|
+
{ maxAttempts: 3, cooldownSeconds: 0 },
|
|
139
|
+
)],
|
|
140
|
+
});
|
|
141
|
+
await seedUser(fortress);
|
|
142
|
+
|
|
143
|
+
const pending = await fortress.auth.login('gated@example.com', 'password-123456');
|
|
144
|
+
if (pending.status !== 'pending' || !pending.pending)
|
|
145
|
+
throw new Error('Expected pending auth challenge');
|
|
146
|
+
|
|
147
|
+
const results = await Promise.allSettled(
|
|
148
|
+
Array.from({ length: 20 }, () =>
|
|
149
|
+
fortress.auth.completePendingAuth(pending.pending!.continuationToken, 'wrong')),
|
|
150
|
+
);
|
|
151
|
+
expect(results.every(result => result.status === 'rejected')).toBe(true);
|
|
152
|
+
expect(verificationCalls).toBe(3);
|
|
153
|
+
|
|
154
|
+
const continuation = await database.findOne<{
|
|
155
|
+
failedAttempts: number;
|
|
156
|
+
invalidatedAt: Date | null;
|
|
157
|
+
consumedAt: Date | null;
|
|
158
|
+
}>({
|
|
159
|
+
model: 'auth_continuation',
|
|
160
|
+
where: [{ field: 'userId', operator: '=', value: pending.user.id }],
|
|
161
|
+
});
|
|
162
|
+
expect(continuation).toMatchObject({ failedAttempts: 3, consumedAt: null });
|
|
163
|
+
expect(continuation?.invalidatedAt).toBeInstanceOf(Date);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('reruns remaining gates after one factor completes', async () => {
|
|
167
|
+
const database = createTestAdapter();
|
|
168
|
+
const fortress = createFortress({
|
|
169
|
+
jwt: { key: SECRET },
|
|
170
|
+
database,
|
|
171
|
+
plugins: [
|
|
172
|
+
factorPlugin('totp', 'two-factor', 'totp-ok'),
|
|
173
|
+
factorPlugin('passkey', 'webauthn', 'passkey-ok'),
|
|
174
|
+
],
|
|
175
|
+
});
|
|
176
|
+
await seedUser(fortress);
|
|
177
|
+
|
|
178
|
+
const first = await fortress.auth.login('gated@example.com', 'password-123456');
|
|
179
|
+
if (first.status !== 'pending' || !first.pending)
|
|
180
|
+
throw new Error('Expected first pending auth challenge');
|
|
181
|
+
expect(first.pending.reason).toBe('two-factor');
|
|
182
|
+
|
|
183
|
+
const second = await fortress.auth.completePendingAuth(first.pending.continuationToken, 'totp-ok');
|
|
184
|
+
if (second.status !== 'pending' || !second.pending)
|
|
185
|
+
throw new Error('Expected second pending auth challenge');
|
|
186
|
+
expect(second.pending.reason).toBe('webauthn');
|
|
187
|
+
expect(await database.count({ model: 'refresh_token' })).toBe(0);
|
|
188
|
+
|
|
189
|
+
const completed = await fortress.auth.completePendingAuth(second.pending.continuationToken, 'passkey-ok');
|
|
190
|
+
expect(completed.status).toBe('success');
|
|
191
|
+
expect(await database.count({ model: 'refresh_token' })).toBe(1);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('exposes pending and completion through the frozen HTTP wire without premature token fields', async () => {
|
|
195
|
+
const fortress = createFortress({
|
|
196
|
+
jwt: { key: SECRET },
|
|
197
|
+
database: createTestAdapter(),
|
|
198
|
+
plugins: [factorPlugin('two-factor', 'two-factor', '123456')],
|
|
199
|
+
});
|
|
200
|
+
await seedUser(fortress);
|
|
201
|
+
|
|
202
|
+
const loginResponse = await fortress.handleRequest(new Request('http://localhost/auth/login', {
|
|
203
|
+
method: 'POST',
|
|
204
|
+
headers: { 'content-type': 'application/json' },
|
|
205
|
+
body: JSON.stringify({ identifier: 'gated@example.com', password: 'password-123456' }),
|
|
206
|
+
}));
|
|
207
|
+
const pending = await loginResponse.json() as {
|
|
208
|
+
pending: { continuationToken: string; reason: string };
|
|
209
|
+
[key: string]: unknown;
|
|
210
|
+
};
|
|
211
|
+
expect(pending).toMatchObject({
|
|
212
|
+
status: 'pending',
|
|
213
|
+
pending: { reason: 'two-factor' },
|
|
214
|
+
});
|
|
215
|
+
expect(pending).not.toHaveProperty('accessToken');
|
|
216
|
+
expect(pending).not.toHaveProperty('refreshToken');
|
|
217
|
+
expect(loginResponse.headers.getSetCookie()).toHaveLength(0);
|
|
218
|
+
|
|
219
|
+
const completedResponse = await fortress.handleRequest(new Request('http://localhost/auth/2fa/verify', {
|
|
220
|
+
method: 'POST',
|
|
221
|
+
headers: { 'content-type': 'application/json' },
|
|
222
|
+
body: JSON.stringify({
|
|
223
|
+
continuationToken: pending.pending.continuationToken,
|
|
224
|
+
code: '123456',
|
|
225
|
+
}),
|
|
226
|
+
}));
|
|
227
|
+
expect(completedResponse.status).toBe(200);
|
|
228
|
+
const completed = await completedResponse.json() as Record<string, unknown>;
|
|
229
|
+
expect(completed).toMatchObject({ status: 'success', method: 'two-factor' });
|
|
230
|
+
expect(completed.accessToken).toEqual(expect.any(String));
|
|
231
|
+
expect(completed.refreshToken).toEqual(expect.any(String));
|
|
232
|
+
expect(completedResponse.headers.getSetCookie().length).toBeGreaterThan(0);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('allows only one concurrent completion to issue a session', async () => {
|
|
236
|
+
const database = createTestAdapter();
|
|
237
|
+
let verifyCalls = 0;
|
|
238
|
+
const fortress = createFortress({
|
|
239
|
+
jwt: { key: SECRET },
|
|
240
|
+
database,
|
|
241
|
+
plugins: [factorPlugin('factor', 'two-factor', 'ok', () => verifyCalls++)],
|
|
242
|
+
});
|
|
243
|
+
await seedUser(fortress);
|
|
244
|
+
|
|
245
|
+
const pending = await fortress.auth.login('gated@example.com', 'password-123456');
|
|
246
|
+
if (pending.status !== 'pending' || !pending.pending)
|
|
247
|
+
throw new Error('Expected pending auth challenge');
|
|
248
|
+
|
|
249
|
+
const results = await Promise.allSettled([
|
|
250
|
+
fortress.auth.completePendingAuth(pending.pending.continuationToken, 'ok'),
|
|
251
|
+
fortress.auth.completePendingAuth(pending.pending.continuationToken, 'ok'),
|
|
252
|
+
]);
|
|
253
|
+
expect(results.filter(result => result.status === 'fulfilled')).toHaveLength(1);
|
|
254
|
+
expect(results.filter(result => result.status === 'rejected')).toHaveLength(1);
|
|
255
|
+
expect(verifyCalls).toBe(1);
|
|
256
|
+
expect(await database.count({ model: 'refresh_token' })).toBe(1);
|
|
257
|
+
});
|
|
258
|
+
});
|