@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,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JWT signing and verification helpers built on `jose` (Web Crypto API).
|
|
3
|
+
*
|
|
4
|
+
* Provides {@link signAccessToken} and {@link verifyAccessToken} for short-lived
|
|
5
|
+
* access tokens. Refresh tokens are handled separately in
|
|
6
|
+
* `core/auth/refresh-token.ts`. Supports key rotation by accepting an array
|
|
7
|
+
* of keys — the first is used to sign, all are accepted on verify.
|
|
8
|
+
*
|
|
9
|
+
* @module
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { JWTVerifyOptions } from 'jose';
|
|
13
|
+
import type { SubjectType, TokenClaims } from '../types';
|
|
14
|
+
|
|
15
|
+
import { jwtVerify, SignJWT } from 'jose';
|
|
16
|
+
import { Errors } from '../errors';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Key material accepted by {@link signAccessToken} / {@link verifyAccessToken}.
|
|
20
|
+
*
|
|
21
|
+
* Today this is just an HS256 shared secret (string) or a rotation array of
|
|
22
|
+
* shared secrets. The alias exists so the public signature is stable when we
|
|
23
|
+
* expand to asymmetric algorithms (RS256 / EdDSA) and JWKS-backed verification.
|
|
24
|
+
*
|
|
25
|
+
* Expected expansion:
|
|
26
|
+
*
|
|
27
|
+
* ```ts
|
|
28
|
+
* import type { CryptoKey, JWK, JWTVerifyGetKey, KeyObject } from 'jose';
|
|
29
|
+
*
|
|
30
|
+
* export type JwtStaticKey =
|
|
31
|
+
* | string // HS* shared secret (utf-8)
|
|
32
|
+
* | Uint8Array // HS* shared secret (raw bytes)
|
|
33
|
+
* | CryptoKey // Web Crypto key (any alg)
|
|
34
|
+
* | KeyObject // Node KeyObject (any alg)
|
|
35
|
+
* | JWK; // JSON Web Key
|
|
36
|
+
*
|
|
37
|
+
* export type JwtKeyMaterial = JwtStaticKey | readonly JwtStaticKey[];
|
|
38
|
+
*
|
|
39
|
+
* // Verification additionally accepts a resolver (e.g. createRemoteJWKSet):
|
|
40
|
+
* export type JwtVerifyKeyMaterial = JwtKeyMaterial | JWTVerifyGetKey;
|
|
41
|
+
* ```
|
|
42
|
+
*
|
|
43
|
+
* Until then, the runtime helpers ({@link normalizeKeys}) only know how to
|
|
44
|
+
* encode strings — widening the type without widening the helpers would be a
|
|
45
|
+
* lie, so do both together.
|
|
46
|
+
*/
|
|
47
|
+
export type JwtKeyMaterial = string | string[];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* JWT claim names fortress controls and that must never be overridable by
|
|
51
|
+
* caller-supplied `customClaims` or by plugin `enrichTokenClaims` output.
|
|
52
|
+
*
|
|
53
|
+
* Letting a caller set `act` would forge an impersonation marker; letting a
|
|
54
|
+
* caller set `sub`/`iss`/`exp` would let them mint a token for any subject
|
|
55
|
+
* or extend its lifetime. The denylist is enforced in {@link signAccessToken}
|
|
56
|
+
* — extra claims are dropped (with a `warn` log in dev) before the safe
|
|
57
|
+
* claims are spread.
|
|
58
|
+
*/
|
|
59
|
+
export const RESERVED_JWT_CLAIMS: ReadonlySet<string> = new Set([
|
|
60
|
+
'sub',
|
|
61
|
+
'iss',
|
|
62
|
+
'iat',
|
|
63
|
+
'exp',
|
|
64
|
+
'nbf',
|
|
65
|
+
'aud',
|
|
66
|
+
'jti',
|
|
67
|
+
'act',
|
|
68
|
+
'groups',
|
|
69
|
+
'subjectType',
|
|
70
|
+
'name',
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
export interface SignAccessTokenOptions {
|
|
74
|
+
/** Audience to include in the JWT `aud` claim. Omitted when unset. */
|
|
75
|
+
audience?: string | string[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface VerifyAccessTokenOptions {
|
|
79
|
+
/** Expected issuer. When set, jose also requires the `iss` claim to be present. */
|
|
80
|
+
issuer?: string | string[];
|
|
81
|
+
/** Expected audience. When set, jose also requires the `aud` claim to be present. */
|
|
82
|
+
audience?: string | string[];
|
|
83
|
+
/** Clock skew tolerance for `nbf`, `exp`, and max-token-age checks. */
|
|
84
|
+
clockTolerance?: string | number;
|
|
85
|
+
/** Additional claim names that must be present alongside Fortress's required access-token claims. */
|
|
86
|
+
requiredClaims?: string[];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const REQUIRED_ACCESS_TOKEN_CLAIMS = ['sub', 'subjectType', 'name', 'groups', 'iss', 'iat', 'exp'] as const;
|
|
90
|
+
const SUBJECT_TYPES: readonly SubjectType[] = ['USER', 'GROUP', 'SERVICE_ACCOUNT'];
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Strip any reserved keys from a custom-claims object. In development we
|
|
94
|
+
* log a warning so library users notice the collision; in production we
|
|
95
|
+
* silently drop (the safe default — a misbehaving plugin shouldn't be able
|
|
96
|
+
* to break token issuance).
|
|
97
|
+
*/
|
|
98
|
+
export function stripReservedClaims(
|
|
99
|
+
claims: Record<string, unknown> | undefined,
|
|
100
|
+
context: string,
|
|
101
|
+
): Record<string, unknown> {
|
|
102
|
+
if (!claims)
|
|
103
|
+
return {};
|
|
104
|
+
const out: Record<string, unknown> = {};
|
|
105
|
+
let stripped: string[] | undefined;
|
|
106
|
+
for (const [key, value] of Object.entries(claims)) {
|
|
107
|
+
if (RESERVED_JWT_CLAIMS.has(key)) {
|
|
108
|
+
(stripped ??= []).push(key);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
out[key] = value;
|
|
112
|
+
}
|
|
113
|
+
if (stripped && typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') {
|
|
114
|
+
console.warn(`[fortress/jwt] dropping reserved claim(s) from ${context}: ${stripped.join(', ')}`);
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Encode a shared-secret string to Uint8Array for jose.
|
|
121
|
+
*/
|
|
122
|
+
function encodeSecret(secret: string): Uint8Array {
|
|
123
|
+
return new TextEncoder().encode(secret);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Normalize key material to an array of encoded keys.
|
|
128
|
+
* First entry is used for signing, all are used for verification.
|
|
129
|
+
*/
|
|
130
|
+
function normalizeKeys(key: JwtKeyMaterial): Uint8Array[] {
|
|
131
|
+
const keys = Array.isArray(key) ? key : [key];
|
|
132
|
+
return keys.map(encodeSecret);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Sign a JWT access token.
|
|
137
|
+
*/
|
|
138
|
+
export async function signAccessToken(
|
|
139
|
+
claims: Omit<TokenClaims, 'iat' | 'exp' | 'aud'>,
|
|
140
|
+
key: JwtKeyMaterial,
|
|
141
|
+
expiresInSeconds: number,
|
|
142
|
+
options?: SignAccessTokenOptions,
|
|
143
|
+
): Promise<string> {
|
|
144
|
+
const [signingKey] = normalizeKeys(key);
|
|
145
|
+
|
|
146
|
+
// Strip reserved claim names from customClaims before spreading — a caller
|
|
147
|
+
// (or a plugin contributing via enrichTokenClaims) MUST NOT be able to
|
|
148
|
+
// overwrite `act`, `sub`, `groups`, etc. (See RESERVED_JWT_CLAIMS.)
|
|
149
|
+
const safeCustom = stripReservedClaims(claims.customClaims, 'signAccessToken.customClaims');
|
|
150
|
+
|
|
151
|
+
let jwt = new SignJWT({
|
|
152
|
+
name: claims.name,
|
|
153
|
+
subjectType: claims.subjectType,
|
|
154
|
+
groups: claims.groups,
|
|
155
|
+
...(claims.act ? { act: claims.act } : {}),
|
|
156
|
+
...safeCustom,
|
|
157
|
+
})
|
|
158
|
+
.setProtectedHeader({ alg: 'HS256' })
|
|
159
|
+
.setIssuedAt()
|
|
160
|
+
.setExpirationTime(`${expiresInSeconds}s`)
|
|
161
|
+
.setIssuer(claims.iss);
|
|
162
|
+
if (options?.audience !== undefined)
|
|
163
|
+
jwt = jwt.setAudience(options.audience);
|
|
164
|
+
return jwt.setSubject(claims.sub).sign(signingKey);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Verify a JWT access token. Tries each key in order for rotation support.
|
|
169
|
+
*
|
|
170
|
+
* Pins `alg: HS256` and (when supplied) the expected `issuer`. jose already
|
|
171
|
+
* rejects `alg: none` by default; the explicit allowlist is defense in
|
|
172
|
+
* depth against future jose behavioural drift and against confused-deputy
|
|
173
|
+
* attacks if anyone later adds asymmetric key support.
|
|
174
|
+
*/
|
|
175
|
+
export async function verifyAccessToken(
|
|
176
|
+
token: string,
|
|
177
|
+
key: JwtKeyMaterial,
|
|
178
|
+
options?: VerifyAccessTokenOptions,
|
|
179
|
+
): Promise<TokenClaims> {
|
|
180
|
+
const keys = normalizeKeys(key);
|
|
181
|
+
const requiredClaims = [...new Set([...REQUIRED_ACCESS_TOKEN_CLAIMS, ...(options?.requiredClaims ?? [])])];
|
|
182
|
+
const verifyOptions: JWTVerifyOptions = { algorithms: ['HS256'], requiredClaims };
|
|
183
|
+
if (options?.issuer)
|
|
184
|
+
verifyOptions.issuer = options.issuer;
|
|
185
|
+
if (options?.audience)
|
|
186
|
+
verifyOptions.audience = options.audience;
|
|
187
|
+
if (options?.clockTolerance !== undefined)
|
|
188
|
+
verifyOptions.clockTolerance = options.clockTolerance;
|
|
189
|
+
|
|
190
|
+
for (const candidate of keys) {
|
|
191
|
+
try {
|
|
192
|
+
const { payload } = await jwtVerify(token, candidate, verifyOptions);
|
|
193
|
+
const subjectType = payload.subjectType;
|
|
194
|
+
const groups = payload.groups;
|
|
195
|
+
const name = payload.name;
|
|
196
|
+
const issuer = payload.iss;
|
|
197
|
+
const audience = payload.aud;
|
|
198
|
+
const issuedAt = payload.iat;
|
|
199
|
+
const expiresAt = payload.exp;
|
|
200
|
+
|
|
201
|
+
if (typeof payload.sub !== 'string' || payload.sub.length === 0)
|
|
202
|
+
throw new Error('Invalid sub claim');
|
|
203
|
+
if (typeof subjectType !== 'string' || !SUBJECT_TYPES.includes(subjectType as SubjectType))
|
|
204
|
+
throw new Error('Invalid subjectType claim');
|
|
205
|
+
if (typeof name !== 'string')
|
|
206
|
+
throw new Error('Invalid name claim');
|
|
207
|
+
if (!Array.isArray(groups) || !groups.every(group => typeof group === 'string'))
|
|
208
|
+
throw new Error('Invalid groups claim');
|
|
209
|
+
if (typeof issuer !== 'string' || issuer.length === 0)
|
|
210
|
+
throw new Error('Invalid iss claim');
|
|
211
|
+
if (audience !== undefined && (!Array.isArray(audience) || !audience.every(value => typeof value === 'string')) && typeof audience !== 'string')
|
|
212
|
+
throw new Error('Invalid aud claim');
|
|
213
|
+
if (typeof issuedAt !== 'number')
|
|
214
|
+
throw new Error('Invalid iat claim');
|
|
215
|
+
if (typeof expiresAt !== 'number')
|
|
216
|
+
throw new Error('Invalid exp claim');
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
sub: payload.sub,
|
|
220
|
+
subjectType: subjectType as SubjectType,
|
|
221
|
+
name,
|
|
222
|
+
groups,
|
|
223
|
+
iss: issuer,
|
|
224
|
+
...(audience !== undefined ? { aud: audience } : {}),
|
|
225
|
+
iat: issuedAt,
|
|
226
|
+
exp: expiresAt,
|
|
227
|
+
act: payload.act as { sub: string; subjectType?: SubjectType } | undefined,
|
|
228
|
+
customClaims: Object.fromEntries(
|
|
229
|
+
Object.entries(payload).filter(([k]) => !RESERVED_JWT_CLAIMS.has(k)),
|
|
230
|
+
),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
throw Errors.unauthorized('Invalid or expired token');
|
|
239
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Login timing-oracle regression test.
|
|
3
|
+
*
|
|
4
|
+
* Background: an earlier implementation used a hard-coded malformed Argon2 PHC
|
|
5
|
+
* string as the dummy hash for the "user not found / no password" branch in
|
|
6
|
+
* `login()`. `hash-wasm`'s parser threw before running the KDF, so that branch
|
|
7
|
+
* completed in ~0.3ms while a real password verify took ~50-200ms. The gap
|
|
8
|
+
* allowed user enumeration over the network.
|
|
9
|
+
*
|
|
10
|
+
* This test asserts the two branches are within the same order of magnitude
|
|
11
|
+
* so the dummy hash continues to be a *real* Argon2 hash (i.e. the verify
|
|
12
|
+
* actually runs the KDF). It is intentionally generous: Argon2 timings vary
|
|
13
|
+
* across runtimes, but a malformed dummy regresses by ~100x which this
|
|
14
|
+
* threshold catches reliably without false positives.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { Fortress } from '../fortress';
|
|
18
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
19
|
+
import { createTestAdapter } from '../../testing';
|
|
20
|
+
import { createFortress } from '../fortress';
|
|
21
|
+
|
|
22
|
+
const SECRET = 'login-timing-test-secret-32chars!!';
|
|
23
|
+
const PASSWORD = 'password-123456';
|
|
24
|
+
const EMAIL = 'timing@example.com';
|
|
25
|
+
|
|
26
|
+
let fortress: Fortress;
|
|
27
|
+
|
|
28
|
+
async function timeFailedLogin(identifier: string): Promise<number> {
|
|
29
|
+
const start = performance.now();
|
|
30
|
+
try {
|
|
31
|
+
await fortress.auth.login(identifier, 'definitely-not-the-password');
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
/* expected */
|
|
35
|
+
}
|
|
36
|
+
return performance.now() - start;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function median(samples: number[]): Promise<number> {
|
|
40
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
41
|
+
return sorted[Math.floor(sorted.length / 2)];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('login timing-oracle defense', () => {
|
|
45
|
+
beforeEach(async () => {
|
|
46
|
+
fortress = createFortress({
|
|
47
|
+
jwt: { key: SECRET },
|
|
48
|
+
database: createTestAdapter(),
|
|
49
|
+
});
|
|
50
|
+
await fortress.auth.createUser({
|
|
51
|
+
email: EMAIL,
|
|
52
|
+
name: 'Timing User',
|
|
53
|
+
password: PASSWORD,
|
|
54
|
+
});
|
|
55
|
+
// Warm the lazy dummy hash so the *first* miss isn't unfairly slow.
|
|
56
|
+
await timeFailedLogin('missing@example.com');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('user-not-found branch takes a real Argon2-verify amount of time', async () => {
|
|
60
|
+
const N = 5;
|
|
61
|
+
const missSamples: number[] = [];
|
|
62
|
+
const hitSamples: number[] = [];
|
|
63
|
+
for (let i = 0; i < N; i++) {
|
|
64
|
+
missSamples.push(await timeFailedLogin(`ghost-${i}@example.com`));
|
|
65
|
+
hitSamples.push(await timeFailedLogin(EMAIL));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const missMedian = await median(missSamples);
|
|
69
|
+
const hitMedian = await median(hitSamples);
|
|
70
|
+
|
|
71
|
+
// Both branches should perform a real Argon2 verify. We require the miss
|
|
72
|
+
// branch to be at least ~30% of the hit branch's wall-clock time. The
|
|
73
|
+
// malformed-dummy regression produced a ~300x gap, so this catches it
|
|
74
|
+
// with comfortable margin while tolerating GC / jitter noise.
|
|
75
|
+
expect(missMedian).toBeGreaterThan(hitMedian * 0.3);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createTestAdapter } from '../../testing';
|
|
3
|
+
import { createFortress } from '../fortress';
|
|
4
|
+
import { clearHibpCache, isPasswordBreached, validatePassword } from './password-policy';
|
|
5
|
+
|
|
6
|
+
describe('password-policy', () => {
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
clearHibpCache();
|
|
9
|
+
vi.restoreAllMocks();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe('validatePassword', () => {
|
|
13
|
+
it('accepts a valid password with default config', async () => {
|
|
14
|
+
await expect(validatePassword('secureP@ss12345')).resolves.toBeUndefined();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('rejects passwords shorter than minLength', async () => {
|
|
18
|
+
await expect(validatePassword('short', { minLength: 8 })).rejects.toThrow(
|
|
19
|
+
'Password must be at least 8 characters',
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('rejects passwords longer than maxLength', async () => {
|
|
24
|
+
const longPassword = 'a'.repeat(200);
|
|
25
|
+
await expect(validatePassword(longPassword, { maxLength: 128 })).rejects.toThrow(
|
|
26
|
+
'Password must be at most 128 characters',
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('accepts password at exact minLength boundary', async () => {
|
|
31
|
+
await expect(validatePassword('12345678', { minLength: 8 })).resolves.toBeUndefined();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('accepts password at exact maxLength boundary', async () => {
|
|
35
|
+
const exactMax = 'a'.repeat(128);
|
|
36
|
+
await expect(validatePassword(exactMax, { maxLength: 128 })).resolves.toBeUndefined();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('uses default min 15 and max 128 when not configured', async () => {
|
|
40
|
+
await expect(validatePassword('12345678901234')).rejects.toThrow('at least 15');
|
|
41
|
+
await expect(validatePassword('a'.repeat(129))).rejects.toThrow('at most 128');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('respects custom minLength and maxLength', async () => {
|
|
45
|
+
await expect(validatePassword('abc', { minLength: 3, maxLength: 10 })).resolves.toBeUndefined();
|
|
46
|
+
await expect(validatePassword('ab', { minLength: 3 })).rejects.toThrow('at least 3');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('applies NFKC before length checks', async () => {
|
|
50
|
+
await expect(validatePassword('ffi', { minLength: 3, maxLength: 3 })).resolves.toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('isPasswordBreached (HIBP)', () => {
|
|
55
|
+
it('detects a breached password', async () => {
|
|
56
|
+
// "password" is SHA-1: 5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8
|
|
57
|
+
// prefix: 5BAA6, suffix: 1E4C9B93F3F0682250B6CF8331B7EE68FD8
|
|
58
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
59
|
+
new Response('1E4C9B93F3F0682250B6CF8331B7EE68FD8:3861493\r\nABCDEF1234567890ABCDEF1234567890ABC:5', {
|
|
60
|
+
status: 200,
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const result = await isPasswordBreached('password');
|
|
65
|
+
expect(result).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('returns false for a clean password', async () => {
|
|
69
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
70
|
+
new Response('0000000000000000000000000000000000000:1\r\nFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF:2', {
|
|
71
|
+
status: 200,
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const result = await isPasswordBreached('my-unique-fortress-password-xyz');
|
|
76
|
+
expect(result).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('fails open on network error', async () => {
|
|
80
|
+
vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new Error('Network error'));
|
|
81
|
+
|
|
82
|
+
const result = await isPasswordBreached('password');
|
|
83
|
+
expect(result).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('fails open on non-200 API response', async () => {
|
|
87
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
88
|
+
new Response('Service Unavailable', { status: 503 }),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const result = await isPasswordBreached('password');
|
|
92
|
+
expect(result).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('uses cached HIBP response within TTL', async () => {
|
|
96
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
97
|
+
new Response('1E4C9B93F3F0682250B6CF8331B7EE68FD8:3861493', { status: 200 }),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
await isPasswordBreached('password');
|
|
101
|
+
await isPasswordBreached('password');
|
|
102
|
+
|
|
103
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('evicts the least-recently-used range rather than the oldest inserted range', async () => {
|
|
107
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () =>
|
|
108
|
+
new Response('00000000000000000000000000000000000:1', { status: 200 }));
|
|
109
|
+
const options = { cacheMaxEntries: 2 };
|
|
110
|
+
|
|
111
|
+
await isPasswordBreached('first-unique-password', options);
|
|
112
|
+
await isPasswordBreached('second-unique-password', options);
|
|
113
|
+
await isPasswordBreached('first-unique-password', options); // touch first
|
|
114
|
+
await isPasswordBreached('third-unique-password', options); // evicts second
|
|
115
|
+
await isPasswordBreached('first-unique-password', options); // still cached
|
|
116
|
+
await isPasswordBreached('second-unique-password', options); // fetch again
|
|
117
|
+
|
|
118
|
+
expect(fetchSpy).toHaveBeenCalledTimes(4);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('bypasses existing cache entries when caching is disabled', async () => {
|
|
122
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () =>
|
|
123
|
+
new Response('1E4C9B93F3F0682250B6CF8331B7EE68FD8:1', { status: 200 }));
|
|
124
|
+
|
|
125
|
+
await expect(isPasswordBreached('password')).resolves.toBe(true);
|
|
126
|
+
await expect(isPasswordBreached('password', { cacheMaxEntries: 0 })).resolves.toBe(true);
|
|
127
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('fails closed and notifies its observer when HIBP is unavailable', async () => {
|
|
131
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
132
|
+
new Response('Service Unavailable', { status: 503 }),
|
|
133
|
+
);
|
|
134
|
+
const observer = vi.fn();
|
|
135
|
+
|
|
136
|
+
await expect(isPasswordBreached('password', {
|
|
137
|
+
failureMode: 'closed',
|
|
138
|
+
observer,
|
|
139
|
+
})).rejects.toMatchObject({ code: 'SERVICE_UNAVAILABLE' });
|
|
140
|
+
expect(observer).toHaveBeenCalledOnce();
|
|
141
|
+
expect(observer).toHaveBeenCalledWith({ failureMode: 'closed', status: 503 });
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('validatePassword with breach checking', () => {
|
|
146
|
+
it('rejects breached password when checkBreached is enabled', async () => {
|
|
147
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
148
|
+
new Response('1E4C9B93F3F0682250B6CF8331B7EE68FD8:3861493', { status: 200 }),
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
await expect(
|
|
152
|
+
validatePassword('password', { checkBreached: true, minLength: 8 }),
|
|
153
|
+
).rejects.toThrow('data breach');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('does not check breaches when checkBreached is false', async () => {
|
|
157
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
158
|
+
|
|
159
|
+
await expect(
|
|
160
|
+
validatePassword('password', { checkBreached: false, minLength: 8 }),
|
|
161
|
+
).resolves.toBeUndefined();
|
|
162
|
+
|
|
163
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('integration with createUser', () => {
|
|
168
|
+
it('rejects short passwords during user creation', async () => {
|
|
169
|
+
const fortress = createFortress({
|
|
170
|
+
jwt: { key: 'test-secret-at-least-32-characters!!' },
|
|
171
|
+
database: createTestAdapter(),
|
|
172
|
+
passwordPolicy: { minLength: 10 },
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
await expect(
|
|
176
|
+
fortress.auth.createUser({ email: 'a@b.com', name: 'Test', password: 'short' }),
|
|
177
|
+
).rejects.toThrow('at least 10');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('emits a degraded event and honors fail-closed mode during user creation', async () => {
|
|
181
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
182
|
+
new Response('Service Unavailable', { status: 503 }),
|
|
183
|
+
);
|
|
184
|
+
const fortress = createFortress({
|
|
185
|
+
jwt: { key: 'test-secret-at-least-32-characters!!' },
|
|
186
|
+
database: createTestAdapter(),
|
|
187
|
+
passwordPolicy: { checkBreached: true, breachedFailureMode: 'closed' },
|
|
188
|
+
});
|
|
189
|
+
const events: string[] = [];
|
|
190
|
+
fortress.auth.addAuthObserver(event => void events.push(event.eventType));
|
|
191
|
+
|
|
192
|
+
await expect(fortress.auth.createUser({
|
|
193
|
+
email: 'degraded@b.com',
|
|
194
|
+
name: 'Test',
|
|
195
|
+
password: 'valid-password-123456',
|
|
196
|
+
})).rejects.toMatchObject({ code: 'SERVICE_UNAVAILABLE' });
|
|
197
|
+
expect(events.filter(event => event === 'PASSWORD_BREACH_CHECK_DEGRADED')).toHaveLength(1);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('allows valid passwords during user creation', async () => {
|
|
201
|
+
const fortress = createFortress({
|
|
202
|
+
jwt: { key: 'test-secret-at-least-32-characters!!' },
|
|
203
|
+
database: createTestAdapter(),
|
|
204
|
+
passwordPolicy: { minLength: 6 },
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const user = await fortress.auth.createUser({
|
|
208
|
+
email: 'a@b.com',
|
|
209
|
+
name: 'Test',
|
|
210
|
+
password: 'validpass',
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
expect(user.email).toBe('a@b.com');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('allows passwordless user creation (no validation)', async () => {
|
|
217
|
+
const fortress = createFortress({
|
|
218
|
+
jwt: { key: 'test-secret-at-least-32-characters!!' },
|
|
219
|
+
database: createTestAdapter(),
|
|
220
|
+
passwordPolicy: { minLength: 10 },
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const user = await fortress.auth.createUser({ email: 'a@b.com', name: 'Test' });
|
|
224
|
+
expect(user.email).toBe('a@b.com');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('normalizes passwords consistently across create, update, and login', async () => {
|
|
228
|
+
const passwordHasher = {
|
|
229
|
+
hash: async (password: string) => `hash:${password}`,
|
|
230
|
+
verify: async (hash: string, password: string) => hash === `hash:${password}`,
|
|
231
|
+
};
|
|
232
|
+
const fortress = createFortress({
|
|
233
|
+
jwt: { key: 'test-secret-at-least-32-characters!!' },
|
|
234
|
+
database: createTestAdapter(),
|
|
235
|
+
passwordHasher,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const user = await fortress.auth.createUser({
|
|
239
|
+
email: 'nfkc@test.com',
|
|
240
|
+
name: 'NFKC',
|
|
241
|
+
password: 'Password123456!',
|
|
242
|
+
});
|
|
243
|
+
await expect(fortress.auth.login('nfkc@test.com', 'Password123456!')).resolves.toMatchObject({
|
|
244
|
+
status: 'success',
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
await fortress.auth.updateUser(user.id, { password: 'NewPassword123!' });
|
|
248
|
+
await expect(fortress.auth.login('nfkc@test.com', 'NewPassword123!')).resolves.toMatchObject({
|
|
249
|
+
status: 'success',
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
});
|