@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,569 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two-factor authentication plugin for fortress.
|
|
3
|
+
*
|
|
4
|
+
* Adds TOTP enrolment and verification, hashed backup codes, and trusted
|
|
5
|
+
* device enrolment so users can opt out of 2FA prompts on familiar devices.
|
|
6
|
+
* Exposes setup, verify, and disable methods on the fortress instance.
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { DatabaseAdapter } from '../../adapters/database';
|
|
12
|
+
import type { FortressPlugin } from '../../core/plugin';
|
|
13
|
+
import type { AuthResult, FortressUser, RequestMeta } from '../../core/types';
|
|
14
|
+
import { generateRefreshToken, hashToken } from '../../core/auth/refresh-token';
|
|
15
|
+
import { Errors } from '../../core/errors';
|
|
16
|
+
|
|
17
|
+
export interface TwoFactorConfig {
|
|
18
|
+
/**
|
|
19
|
+
* 32-byte AES-256-GCM key used to encrypt TOTP seeds at rest. Accepts
|
|
20
|
+
* base64/base64url, 64-character hex, or exactly 32 UTF-8 bytes. Keep this
|
|
21
|
+
* key stable and outside the database; changing it requires re-enrolment.
|
|
22
|
+
*/
|
|
23
|
+
secretEncryptionKey: string;
|
|
24
|
+
totp?: {
|
|
25
|
+
/** Issuer name shown in authenticator apps (default: 'Fortress') */
|
|
26
|
+
issuer?: string;
|
|
27
|
+
/** TOTP period in seconds (default: 30) */
|
|
28
|
+
period?: number;
|
|
29
|
+
/** Number of digits (default: 6) */
|
|
30
|
+
digits?: number;
|
|
31
|
+
};
|
|
32
|
+
backupCodes?: {
|
|
33
|
+
/** Number of backup codes to generate (default: 10) */
|
|
34
|
+
count?: number;
|
|
35
|
+
};
|
|
36
|
+
/** Days to trust a device after explicit remember-device opt-in (default: 30) */
|
|
37
|
+
trustedDeviceDays?: number;
|
|
38
|
+
/** Maximum rejected continuation proofs before invalidation (default: 5). */
|
|
39
|
+
maxAttempts?: number;
|
|
40
|
+
/** Minimum delay between rejected proofs for an account (default: 1 second). */
|
|
41
|
+
failedAttemptCooldownSeconds?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface TwoFactorSecretRecord {
|
|
45
|
+
id: string;
|
|
46
|
+
userId: string;
|
|
47
|
+
secret: string;
|
|
48
|
+
isEnabled: boolean;
|
|
49
|
+
createdAt: Date;
|
|
50
|
+
lastUsedCounter: number | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface BackupCodeRecord {
|
|
54
|
+
id: string;
|
|
55
|
+
userId: string;
|
|
56
|
+
codeHash: string;
|
|
57
|
+
isUsed: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface TrustedDeviceRecord {
|
|
61
|
+
id: string;
|
|
62
|
+
userId: string;
|
|
63
|
+
/** Hash of the server-issued secret; the raw secret is never persisted. */
|
|
64
|
+
deviceHash: string;
|
|
65
|
+
expiresAt: Date;
|
|
66
|
+
lastUsedAt: Date;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const ENCODER = new TextEncoder();
|
|
70
|
+
const DECODER = new TextDecoder();
|
|
71
|
+
const HEX_32_BYTE_KEY = /^[0-9a-f]{64}$/i;
|
|
72
|
+
|
|
73
|
+
function decodeEncryptionKey(value: string): Uint8Array {
|
|
74
|
+
const trimmed = value.trim();
|
|
75
|
+
if (HEX_32_BYTE_KEY.test(trimmed))
|
|
76
|
+
return new Uint8Array(trimmed.match(/.{2}/g)!.map(byte => Number.parseInt(byte, 16)));
|
|
77
|
+
try {
|
|
78
|
+
const normalized = trimmed.replace(/-/g, '+').replace(/_/g, '/');
|
|
79
|
+
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=');
|
|
80
|
+
const raw = atob(padded);
|
|
81
|
+
const decoded = Uint8Array.from(raw, char => char.charCodeAt(0));
|
|
82
|
+
if (decoded.length === 32)
|
|
83
|
+
return decoded;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// Fall through to exact-length UTF-8 key material.
|
|
87
|
+
}
|
|
88
|
+
return ENCODER.encode(value);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function base64UrlEncode(value: Uint8Array): string {
|
|
92
|
+
return btoa(String.fromCharCode(...value))
|
|
93
|
+
.replace(/\+/g, '-')
|
|
94
|
+
.replace(/\//g, '_')
|
|
95
|
+
.replace(/=+$/g, '');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function base64UrlDecode(value: string): Uint8Array {
|
|
99
|
+
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
|
100
|
+
const raw = atob(normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '='));
|
|
101
|
+
return Uint8Array.from(raw, char => char.charCodeAt(0));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function toArrayBuffer(value: Uint8Array): ArrayBuffer {
|
|
105
|
+
return value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength) as ArrayBuffer;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function encryptSecret(keyPromise: Promise<CryptoKey>, secret: string, userId: string): Promise<string> {
|
|
109
|
+
const key = await keyPromise;
|
|
110
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
111
|
+
const ciphertext = new Uint8Array(await crypto.subtle.encrypt(
|
|
112
|
+
{ name: 'AES-GCM', iv, additionalData: ENCODER.encode(userId) },
|
|
113
|
+
key,
|
|
114
|
+
ENCODER.encode(secret),
|
|
115
|
+
));
|
|
116
|
+
return `v1.${base64UrlEncode(iv)}.${base64UrlEncode(ciphertext)}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function decryptSecret(keyPromise: Promise<CryptoKey>, encrypted: string, userId: string): Promise<string> {
|
|
120
|
+
const [version, encodedIv, encodedCiphertext] = encrypted.split('.');
|
|
121
|
+
if (version !== 'v1' || !encodedIv || !encodedCiphertext)
|
|
122
|
+
throw Errors.unauthorized('Two-factor enrolment uses an unsupported secret format; re-enrolment is required');
|
|
123
|
+
try {
|
|
124
|
+
const key = await keyPromise;
|
|
125
|
+
const iv = base64UrlDecode(encodedIv);
|
|
126
|
+
const ciphertext = base64UrlDecode(encodedCiphertext);
|
|
127
|
+
const plaintext = await crypto.subtle.decrypt(
|
|
128
|
+
{ name: 'AES-GCM', iv: toArrayBuffer(iv), additionalData: ENCODER.encode(userId) },
|
|
129
|
+
key,
|
|
130
|
+
toArrayBuffer(ciphertext),
|
|
131
|
+
);
|
|
132
|
+
return DECODER.decode(plaintext);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
throw Errors.unauthorized('Unable to decrypt two-factor secret');
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// --- TOTP Implementation (RFC 6238 / RFC 4226) ---
|
|
140
|
+
|
|
141
|
+
function generateSecret(): string {
|
|
142
|
+
const bytes = new Uint8Array(20);
|
|
143
|
+
crypto.getRandomValues(bytes);
|
|
144
|
+
return base32Encode(bytes);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function base32Encode(bytes: Uint8Array): string {
|
|
148
|
+
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
149
|
+
let bits = 0;
|
|
150
|
+
let value = 0;
|
|
151
|
+
let result = '';
|
|
152
|
+
|
|
153
|
+
for (const byte of bytes) {
|
|
154
|
+
value = (value << 8) | byte;
|
|
155
|
+
bits += 8;
|
|
156
|
+
while (bits >= 5) {
|
|
157
|
+
result += alphabet[(value >>> (bits - 5)) & 31];
|
|
158
|
+
bits -= 5;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (bits > 0) {
|
|
163
|
+
result += alphabet[(value << (5 - bits)) & 31];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return result;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const BASE32_PADDING = /=+$/;
|
|
170
|
+
|
|
171
|
+
function base32Decode(encoded: string): Uint8Array {
|
|
172
|
+
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
173
|
+
const cleanInput = encoded.replace(BASE32_PADDING, '').toUpperCase();
|
|
174
|
+
let bits = 0;
|
|
175
|
+
let value = 0;
|
|
176
|
+
const output: number[] = [];
|
|
177
|
+
|
|
178
|
+
for (const char of cleanInput) {
|
|
179
|
+
const idx = alphabet.indexOf(char);
|
|
180
|
+
if (idx === -1)
|
|
181
|
+
continue;
|
|
182
|
+
value = (value << 5) | idx;
|
|
183
|
+
bits += 5;
|
|
184
|
+
if (bits >= 8) {
|
|
185
|
+
output.push((value >>> (bits - 8)) & 0xFF);
|
|
186
|
+
bits -= 8;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return new Uint8Array(output);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Generate a TOTP code from a base32 secret. Re-exported from this module
|
|
195
|
+
* for testing — normal callers should use the plugin methods instead.
|
|
196
|
+
*/
|
|
197
|
+
async function generateTOTPForCounter(secret: string, digits: number, counter: number): Promise<string> {
|
|
198
|
+
const counterBytes = new ArrayBuffer(8);
|
|
199
|
+
const view = new DataView(counterBytes);
|
|
200
|
+
view.setBigUint64(0, BigInt(counter));
|
|
201
|
+
|
|
202
|
+
const keyBytes = base32Decode(secret);
|
|
203
|
+
const key = await crypto.subtle.importKey(
|
|
204
|
+
'raw',
|
|
205
|
+
keyBytes.buffer as ArrayBuffer,
|
|
206
|
+
{ name: 'HMAC', hash: 'SHA-1' },
|
|
207
|
+
false,
|
|
208
|
+
['sign'],
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
const hmac = new Uint8Array(await crypto.subtle.sign('HMAC', key, counterBytes));
|
|
212
|
+
const offset = hmac.at(-1)! & 0x0F;
|
|
213
|
+
const code = (
|
|
214
|
+
((hmac[offset] & 0x7F) << 24)
|
|
215
|
+
| ((hmac[offset + 1] & 0xFF) << 16)
|
|
216
|
+
| ((hmac[offset + 2] & 0xFF) << 8)
|
|
217
|
+
| (hmac[offset + 3] & 0xFF)
|
|
218
|
+
) % (10 ** digits);
|
|
219
|
+
|
|
220
|
+
return String(code).padStart(digits, '0');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function generateTOTP(secret: string, period: number, digits: number, timeOffset = 0): Promise<string> {
|
|
224
|
+
const counter = Math.floor((Date.now() / 1000 + timeOffset) / period);
|
|
225
|
+
return generateTOTPForCounter(secret, digits, counter);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Verify a TOTP code against a base32 secret, allowing ±1 time window for
|
|
230
|
+
* clock drift. Re-exported from this module for testing — normal callers
|
|
231
|
+
* should use the plugin methods instead.
|
|
232
|
+
*/
|
|
233
|
+
async function verifyTOTPWithCounter(secret: string, code: string, period: number, digits: number): Promise<number | null> {
|
|
234
|
+
const currentCounter = Math.floor(Date.now() / 1000 / period);
|
|
235
|
+
// Check current and adjacent time windows (±1) to handle clock drift
|
|
236
|
+
for (const counter of [currentCounter, currentCounter - 1, currentCounter + 1]) {
|
|
237
|
+
const expected = await generateTOTPForCounter(secret, digits, counter);
|
|
238
|
+
if (expected === code)
|
|
239
|
+
return counter;
|
|
240
|
+
}
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function verifyTOTP(secret: string, code: string, period: number, digits: number): Promise<boolean> {
|
|
245
|
+
return (await verifyTOTPWithCounter(secret, code, period, digits)) !== null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function buildOtpauthUrl(secret: string, issuer: string, email: string, period: number, digits: number): string {
|
|
249
|
+
const label = `${issuer}:${email}`;
|
|
250
|
+
const params = new URLSearchParams({
|
|
251
|
+
secret,
|
|
252
|
+
issuer,
|
|
253
|
+
algorithm: 'SHA1',
|
|
254
|
+
digits: String(digits),
|
|
255
|
+
period: String(period),
|
|
256
|
+
});
|
|
257
|
+
return `otpauth://totp/${encodeURIComponent(label)}?${params.toString()}`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function generateBackupCodes(count: number): Promise<{ raw: string[]; hashes: string[] }> {
|
|
261
|
+
const raw: string[] = [];
|
|
262
|
+
const hashes: string[] = [];
|
|
263
|
+
|
|
264
|
+
for (let i = 0; i < count; i++) {
|
|
265
|
+
// Recovery credentials need offline-strength entropy even when endpoint
|
|
266
|
+
// throttling is misconfigured. 16 random bytes = 128 bits.
|
|
267
|
+
const bytes = new Uint8Array(16);
|
|
268
|
+
crypto.getRandomValues(bytes);
|
|
269
|
+
const code = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
|
|
270
|
+
raw.push(code);
|
|
271
|
+
hashes.push(await hashToken(code));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return { raw, hashes };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export interface TwoFactorMethods {
|
|
278
|
+
enable: (userId: string) => Promise<{ secret: string; otpauthUrl: string; backupCodes: string[] }>;
|
|
279
|
+
confirmSetup: (userId: string, code: string, meta?: RequestMeta) => Promise<{ verified: true; trustedDeviceToken?: string }>;
|
|
280
|
+
verify: (continuationToken: string, code: string, meta?: RequestMeta) => Promise<AuthResult>;
|
|
281
|
+
disable: (userId: string) => Promise<void>;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Two-factor authentication plugin factory. Returns a {@link FortressPlugin}
|
|
285
|
+
* that adds TOTP enrolment and verification, hashed backup codes, and
|
|
286
|
+
* trusted-device opt-out for the sign-in flow.
|
|
287
|
+
*/
|
|
288
|
+
export function twoFactor(config: TwoFactorConfig): FortressPlugin & { readonly name: 'two-factor' } {
|
|
289
|
+
if (!config?.secretEncryptionKey)
|
|
290
|
+
throw Errors.badRequest('twoFactor requires secretEncryptionKey');
|
|
291
|
+
const keyBytes = decodeEncryptionKey(config.secretEncryptionKey);
|
|
292
|
+
if (keyBytes.length !== 32)
|
|
293
|
+
throw Errors.badRequest('twoFactor secretEncryptionKey must decode to exactly 32 bytes for AES-256-GCM');
|
|
294
|
+
const keyBuffer = keyBytes.buffer.slice(keyBytes.byteOffset, keyBytes.byteOffset + keyBytes.byteLength) as ArrayBuffer;
|
|
295
|
+
const encryptionKey = crypto.subtle.importKey('raw', keyBuffer, 'AES-GCM', false, ['encrypt', 'decrypt']);
|
|
296
|
+
const issuer = config.totp?.issuer ?? 'Fortress';
|
|
297
|
+
const period = config.totp?.period ?? 30;
|
|
298
|
+
const digits = config.totp?.digits ?? 6;
|
|
299
|
+
const backupCodeCount = config.backupCodes?.count ?? 10;
|
|
300
|
+
const trustedDeviceDays = config.trustedDeviceDays ?? 30;
|
|
301
|
+
const maxAttempts = config.maxAttempts ?? 5;
|
|
302
|
+
const failedAttemptCooldownSeconds = config.failedAttemptCooldownSeconds ?? 1;
|
|
303
|
+
|
|
304
|
+
async function verifyCode(
|
|
305
|
+
db: DatabaseAdapter,
|
|
306
|
+
userId: string,
|
|
307
|
+
code: string,
|
|
308
|
+
meta?: RequestMeta,
|
|
309
|
+
): Promise<string | undefined> {
|
|
310
|
+
const secretRecord = await db.findOne<TwoFactorSecretRecord>({
|
|
311
|
+
model: 'two_factor_secret',
|
|
312
|
+
where: [{ field: 'userId', operator: '=', value: userId }],
|
|
313
|
+
});
|
|
314
|
+
if (!secretRecord)
|
|
315
|
+
throw Errors.badRequest('Two-factor authentication is not set up');
|
|
316
|
+
|
|
317
|
+
const secret = await decryptSecret(encryptionKey, secretRecord.secret, userId);
|
|
318
|
+
const matchedCounter = await verifyTOTPWithCounter(secret, code, period, digits);
|
|
319
|
+
if (matchedCounter !== null) {
|
|
320
|
+
const previousCounter = secretRecord.lastUsedCounter;
|
|
321
|
+
if (previousCounter !== null && matchedCounter <= previousCounter)
|
|
322
|
+
throw Errors.unauthorized('Two-factor code has already been used');
|
|
323
|
+
|
|
324
|
+
const claimed = await db.update<TwoFactorSecretRecord>({
|
|
325
|
+
model: 'two_factor_secret',
|
|
326
|
+
where: [
|
|
327
|
+
{ field: 'id', operator: '=', value: secretRecord.id },
|
|
328
|
+
previousCounter === null
|
|
329
|
+
? { field: 'lastUsedCounter', operator: 'isNull', value: null }
|
|
330
|
+
: { field: 'lastUsedCounter', operator: '=', value: previousCounter },
|
|
331
|
+
],
|
|
332
|
+
data: { isEnabled: true, lastUsedCounter: matchedCounter },
|
|
333
|
+
});
|
|
334
|
+
if (!claimed)
|
|
335
|
+
throw Errors.unauthorized('Two-factor code has already been used');
|
|
336
|
+
|
|
337
|
+
if (meta?.rememberDevice) {
|
|
338
|
+
const trusted = await generateRefreshToken();
|
|
339
|
+
const expiresAt = new Date(Date.now() + trustedDeviceDays * 24 * 60 * 60 * 1000);
|
|
340
|
+
await db.create({
|
|
341
|
+
model: 'trusted_device',
|
|
342
|
+
data: { userId, deviceHash: trusted.hash, expiresAt, lastUsedAt: new Date() },
|
|
343
|
+
});
|
|
344
|
+
return trusted.raw;
|
|
345
|
+
}
|
|
346
|
+
return undefined;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const backupCodes = await db.findMany<BackupCodeRecord>({
|
|
350
|
+
model: 'backup_code',
|
|
351
|
+
where: [
|
|
352
|
+
{ field: 'userId', operator: '=', value: userId },
|
|
353
|
+
{ field: 'isUsed', operator: '=', value: false },
|
|
354
|
+
],
|
|
355
|
+
});
|
|
356
|
+
const codeHash = await hashToken(code);
|
|
357
|
+
const matchingCode = backupCodes.find(backupCode => backupCode.codeHash === codeHash);
|
|
358
|
+
if (!matchingCode)
|
|
359
|
+
throw Errors.unauthorized('Invalid two-factor code');
|
|
360
|
+
|
|
361
|
+
const claimed = await db.update({
|
|
362
|
+
model: 'backup_code',
|
|
363
|
+
where: [
|
|
364
|
+
{ field: 'id', operator: '=', value: matchingCode.id },
|
|
365
|
+
{ field: 'isUsed', operator: '=', value: false },
|
|
366
|
+
],
|
|
367
|
+
data: { isUsed: true },
|
|
368
|
+
});
|
|
369
|
+
if (!claimed)
|
|
370
|
+
throw Errors.unauthorized('Invalid two-factor code');
|
|
371
|
+
|
|
372
|
+
if (!secretRecord.isEnabled) {
|
|
373
|
+
await db.update({
|
|
374
|
+
model: 'two_factor_secret',
|
|
375
|
+
where: [{ field: 'id', operator: '=', value: secretRecord.id }],
|
|
376
|
+
data: { isEnabled: true },
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
if (meta?.rememberDevice) {
|
|
380
|
+
const trusted = await generateRefreshToken();
|
|
381
|
+
const expiresAt = new Date(Date.now() + trustedDeviceDays * 24 * 60 * 60 * 1000);
|
|
382
|
+
await db.create({
|
|
383
|
+
model: 'trusted_device',
|
|
384
|
+
data: { userId, deviceHash: trusted.hash, expiresAt, lastUsedAt: new Date() },
|
|
385
|
+
});
|
|
386
|
+
return trusted.raw;
|
|
387
|
+
}
|
|
388
|
+
return undefined;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return {
|
|
392
|
+
name: 'two-factor',
|
|
393
|
+
|
|
394
|
+
models: [
|
|
395
|
+
{
|
|
396
|
+
name: 'two_factor_secret',
|
|
397
|
+
fields: {
|
|
398
|
+
id: { type: 'number', required: true },
|
|
399
|
+
userId: { type: 'number', required: true, unique: true, references: { model: 'user', field: 'id' } },
|
|
400
|
+
secret: { type: 'string', required: true },
|
|
401
|
+
isEnabled: { type: 'boolean', required: true },
|
|
402
|
+
lastUsedCounter: { type: 'number' },
|
|
403
|
+
createdAt: { type: 'date', required: true },
|
|
404
|
+
},
|
|
405
|
+
},
|
|
406
|
+
{
|
|
407
|
+
name: 'backup_code',
|
|
408
|
+
fields: {
|
|
409
|
+
id: { type: 'number', required: true },
|
|
410
|
+
userId: { type: 'number', required: true, references: { model: 'user', field: 'id' } },
|
|
411
|
+
codeHash: { type: 'string', required: true },
|
|
412
|
+
isUsed: { type: 'boolean', required: true },
|
|
413
|
+
createdAt: { type: 'date', required: true },
|
|
414
|
+
},
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
name: 'trusted_device',
|
|
418
|
+
fields: {
|
|
419
|
+
id: { type: 'number', required: true },
|
|
420
|
+
userId: { type: 'number', required: true, references: { model: 'user', field: 'id' } },
|
|
421
|
+
deviceHash: { type: 'string', required: true },
|
|
422
|
+
expiresAt: { type: 'date', required: true },
|
|
423
|
+
lastUsedAt: { type: 'date', required: true },
|
|
424
|
+
createdAt: { type: 'date', required: true },
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
],
|
|
428
|
+
|
|
429
|
+
hooks: {
|
|
430
|
+
postAuthGate: {
|
|
431
|
+
reason: 'two-factor',
|
|
432
|
+
maxAttempts,
|
|
433
|
+
cooldownSeconds: failedAttemptCooldownSeconds,
|
|
434
|
+
async evaluate(ctx) {
|
|
435
|
+
const secret = await ctx.db.findOne<TwoFactorSecretRecord>({
|
|
436
|
+
model: 'two_factor_secret',
|
|
437
|
+
where: [
|
|
438
|
+
{ field: 'userId', operator: '=', value: ctx.user.id },
|
|
439
|
+
{ field: 'isEnabled', operator: '=', value: true },
|
|
440
|
+
],
|
|
441
|
+
});
|
|
442
|
+
if (!secret)
|
|
443
|
+
return;
|
|
444
|
+
|
|
445
|
+
if (ctx.meta?.trustedDeviceToken) {
|
|
446
|
+
const deviceHash = await hashToken(ctx.meta.trustedDeviceToken);
|
|
447
|
+
const trusted = await ctx.db.findOne<TrustedDeviceRecord>({
|
|
448
|
+
model: 'trusted_device',
|
|
449
|
+
where: [
|
|
450
|
+
{ field: 'userId', operator: '=', value: ctx.user.id },
|
|
451
|
+
{ field: 'deviceHash', operator: '=', value: deviceHash },
|
|
452
|
+
],
|
|
453
|
+
});
|
|
454
|
+
if (trusted && trusted.expiresAt > new Date()) {
|
|
455
|
+
await ctx.db.update({
|
|
456
|
+
model: 'trusted_device',
|
|
457
|
+
where: [{ field: 'id', operator: '=', value: trusted.id }],
|
|
458
|
+
data: { lastUsedAt: new Date() },
|
|
459
|
+
});
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return { pluginData: { requires2FA: true } };
|
|
465
|
+
},
|
|
466
|
+
async verify(ctx, completion) {
|
|
467
|
+
if (typeof completion !== 'string')
|
|
468
|
+
throw Errors.unauthorized('Invalid two-factor code');
|
|
469
|
+
const trustedDeviceToken = await verifyCode(ctx.db, ctx.user.id, completion, ctx.meta);
|
|
470
|
+
return trustedDeviceToken ? { trustedDeviceToken } : undefined;
|
|
471
|
+
},
|
|
472
|
+
},
|
|
473
|
+
},
|
|
474
|
+
|
|
475
|
+
methods: (ctx) => {
|
|
476
|
+
// Auth service reference for issuing tokens after 2FA verification
|
|
477
|
+
// We need access to signToken and refresh token creation — reuse the fortress instance
|
|
478
|
+
return {
|
|
479
|
+
async enable(userId: string): Promise<{
|
|
480
|
+
secret: string;
|
|
481
|
+
otpauthUrl: string;
|
|
482
|
+
backupCodes: string[];
|
|
483
|
+
}> {
|
|
484
|
+
const user = await ctx.db.findOne<FortressUser>({
|
|
485
|
+
model: 'user',
|
|
486
|
+
where: [{ field: 'id', operator: '=', value: userId }],
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
if (!user)
|
|
490
|
+
throw Errors.notFound('User not found');
|
|
491
|
+
|
|
492
|
+
// Check if already has a secret
|
|
493
|
+
const existing = await ctx.db.findOne<TwoFactorSecretRecord>({
|
|
494
|
+
model: 'two_factor_secret',
|
|
495
|
+
where: [{ field: 'userId', operator: '=', value: userId }],
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
if (existing?.isEnabled)
|
|
499
|
+
throw Errors.badRequest('Two-factor authentication is already enabled');
|
|
500
|
+
|
|
501
|
+
// Remove any previous unenabled setup
|
|
502
|
+
if (existing) {
|
|
503
|
+
await ctx.db.delete({
|
|
504
|
+
model: 'two_factor_secret',
|
|
505
|
+
where: [{ field: 'userId', operator: '=', value: userId }],
|
|
506
|
+
});
|
|
507
|
+
await ctx.db.delete({
|
|
508
|
+
model: 'backup_code',
|
|
509
|
+
where: [{ field: 'userId', operator: '=', value: userId }],
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const secret = generateSecret();
|
|
514
|
+
const otpauthUrl = buildOtpauthUrl(secret, issuer, user.email, period, digits);
|
|
515
|
+
|
|
516
|
+
// Generate backup codes
|
|
517
|
+
const { raw: backupCodesRaw, hashes } = await generateBackupCodes(backupCodeCount);
|
|
518
|
+
|
|
519
|
+
// Store only an authenticated ciphertext; the raw TOTP seed is
|
|
520
|
+
// returned once for authenticator enrolment and never persisted.
|
|
521
|
+
const encryptedSecret = await encryptSecret(encryptionKey, secret, userId);
|
|
522
|
+
await ctx.db.create({
|
|
523
|
+
model: 'two_factor_secret',
|
|
524
|
+
data: { userId, secret: encryptedSecret, isEnabled: false, lastUsedCounter: null },
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// Store backup code hashes
|
|
528
|
+
for (const hash of hashes) {
|
|
529
|
+
await ctx.db.create({
|
|
530
|
+
model: 'backup_code',
|
|
531
|
+
data: { userId, codeHash: hash, isUsed: false },
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return { secret, otpauthUrl, backupCodes: backupCodesRaw };
|
|
536
|
+
},
|
|
537
|
+
|
|
538
|
+
async confirmSetup(userId: string, code: string, meta?: RequestMeta): Promise<{ verified: true; trustedDeviceToken?: string }> {
|
|
539
|
+
const trustedDeviceToken = await verifyCode(ctx.db, userId, code, meta);
|
|
540
|
+
return { verified: true, ...(trustedDeviceToken ? { trustedDeviceToken } : {}) };
|
|
541
|
+
},
|
|
542
|
+
|
|
543
|
+
async verify(continuationToken: string, code: string, meta?: RequestMeta): Promise<AuthResult> {
|
|
544
|
+
if (!ctx.auth)
|
|
545
|
+
throw Errors.badRequest('Auth service is unavailable');
|
|
546
|
+
return ctx.auth.completePendingAuth(continuationToken, code, meta);
|
|
547
|
+
},
|
|
548
|
+
|
|
549
|
+
async disable(userId: string): Promise<void> {
|
|
550
|
+
await ctx.db.delete({
|
|
551
|
+
model: 'two_factor_secret',
|
|
552
|
+
where: [{ field: 'userId', operator: '=', value: userId }],
|
|
553
|
+
});
|
|
554
|
+
await ctx.db.delete({
|
|
555
|
+
model: 'backup_code',
|
|
556
|
+
where: [{ field: 'userId', operator: '=', value: userId }],
|
|
557
|
+
});
|
|
558
|
+
await ctx.db.delete({
|
|
559
|
+
model: 'trusted_device',
|
|
560
|
+
where: [{ field: 'userId', operator: '=', value: userId }],
|
|
561
|
+
});
|
|
562
|
+
},
|
|
563
|
+
};
|
|
564
|
+
},
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Export TOTP utilities for testing
|
|
569
|
+
export { generateTOTP, verifyTOTP };
|