@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,228 @@
|
|
|
1
|
+
import type { DatabaseAdapter } from '../../adapters/database';
|
|
2
|
+
import type { FortressConfig } from '../config';
|
|
3
|
+
import type { StoredContinuation } from '../internal-adapter';
|
|
4
|
+
import type { FortressPlugin, PostAuthGateDecision } from '../plugin';
|
|
5
|
+
import type { AuthChallenge, FortressUser, PendingReason, RequestMeta } from '../types';
|
|
6
|
+
import { Errors } from '../errors';
|
|
7
|
+
import { generateRefreshToken, hashToken } from './refresh-token';
|
|
8
|
+
|
|
9
|
+
/** Default lifetime of a pending-auth continuation (five minutes). */
|
|
10
|
+
export const AUTH_CONTINUATION_TTL_SECONDS = 300;
|
|
11
|
+
|
|
12
|
+
export interface PostAuthGateHold {
|
|
13
|
+
challenge: AuthChallenge;
|
|
14
|
+
pluginData?: Record<string, unknown>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const DEFAULT_MAX_ATTEMPTS = 5;
|
|
18
|
+
const DEFAULT_COOLDOWN_SECONDS = 1;
|
|
19
|
+
|
|
20
|
+
/** Persist a short-lived, hashed continuation and return only its raw bearer token. */
|
|
21
|
+
export async function mintAuthContinuation(
|
|
22
|
+
db: DatabaseAdapter,
|
|
23
|
+
userId: string,
|
|
24
|
+
reason: PendingReason,
|
|
25
|
+
ttlSeconds: number = AUTH_CONTINUATION_TTL_SECONDS,
|
|
26
|
+
policy: { maxAttempts?: number; cooldownSeconds?: number } = {},
|
|
27
|
+
): Promise<AuthChallenge> {
|
|
28
|
+
const { raw, hash } = await generateRefreshToken();
|
|
29
|
+
const now = new Date();
|
|
30
|
+
await db.create({
|
|
31
|
+
model: 'auth_continuation',
|
|
32
|
+
data: {
|
|
33
|
+
userId,
|
|
34
|
+
tokenHash: hash,
|
|
35
|
+
reason,
|
|
36
|
+
expiresAt: new Date(now.getTime() + ttlSeconds * 1000),
|
|
37
|
+
consumedAt: null,
|
|
38
|
+
failedAttempts: 0,
|
|
39
|
+
lastFailedAt: null,
|
|
40
|
+
invalidatedAt: null,
|
|
41
|
+
maxAttempts: Math.max(1, policy.maxAttempts ?? DEFAULT_MAX_ATTEMPTS),
|
|
42
|
+
cooldownSeconds: Math.max(0, policy.cooldownSeconds ?? DEFAULT_COOLDOWN_SECONDS),
|
|
43
|
+
createdAt: now,
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
return { reason, continuationToken: raw };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function assertUsableContinuation(record: StoredContinuation | null): StoredContinuation {
|
|
50
|
+
if (!record || record.consumedAt || record.invalidatedAt || record.expiresAt <= new Date())
|
|
51
|
+
throw Errors.unauthorized('Invalid or expired auth continuation');
|
|
52
|
+
return record;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Inspect a continuation without consuming it, for a factor plugin's credential check. */
|
|
56
|
+
export async function peekAuthContinuation(
|
|
57
|
+
db: DatabaseAdapter,
|
|
58
|
+
continuationToken: string,
|
|
59
|
+
): Promise<StoredContinuation> {
|
|
60
|
+
const tokenHash = await hashToken(continuationToken);
|
|
61
|
+
const record = await db.findOne<StoredContinuation>({
|
|
62
|
+
model: 'auth_continuation',
|
|
63
|
+
where: [{ field: 'tokenHash', operator: '=', value: tokenHash }],
|
|
64
|
+
});
|
|
65
|
+
return assertUsableContinuation(record);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Atomically claim a continuation exactly once. */
|
|
69
|
+
export async function consumeAuthContinuation(
|
|
70
|
+
db: DatabaseAdapter,
|
|
71
|
+
continuationToken: string,
|
|
72
|
+
verify?: (tx: DatabaseAdapter, continuation: StoredContinuation) => Promise<void>,
|
|
73
|
+
): Promise<StoredContinuation> {
|
|
74
|
+
const tokenHash = await hashToken(continuationToken);
|
|
75
|
+
const consumedAt = new Date();
|
|
76
|
+
|
|
77
|
+
const outcome = await db.transaction(async (tx): Promise<
|
|
78
|
+
| { ok: true; continuation: StoredContinuation }
|
|
79
|
+
| { ok: false; error: unknown }
|
|
80
|
+
> => {
|
|
81
|
+
const existing = await tx.findOne<StoredContinuation>({
|
|
82
|
+
model: 'auth_continuation',
|
|
83
|
+
where: [{ field: 'tokenHash', operator: '=', value: tokenHash }],
|
|
84
|
+
});
|
|
85
|
+
const usable = assertUsableContinuation(existing);
|
|
86
|
+
const maxAttempts = usable.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
87
|
+
const failedAttempts = usable.failedAttempts ?? 0;
|
|
88
|
+
if (failedAttempts >= maxAttempts)
|
|
89
|
+
throw Errors.unauthorized('Invalid or expired auth continuation');
|
|
90
|
+
|
|
91
|
+
const cooldownSeconds = usable.cooldownSeconds ?? DEFAULT_COOLDOWN_SECONDS;
|
|
92
|
+
if (cooldownSeconds > 0) {
|
|
93
|
+
const cutoff = new Date(consumedAt.getTime() - cooldownSeconds * 1000);
|
|
94
|
+
const recent = await tx.findOne<StoredContinuation>({
|
|
95
|
+
model: 'auth_continuation',
|
|
96
|
+
where: [
|
|
97
|
+
{ field: 'userId', operator: '=', value: usable.userId },
|
|
98
|
+
{ field: 'reason', operator: '=', value: usable.reason },
|
|
99
|
+
{ field: 'lastFailedAt', operator: 'gt', value: cutoff },
|
|
100
|
+
],
|
|
101
|
+
});
|
|
102
|
+
if (recent)
|
|
103
|
+
throw Errors.unauthorized('Too many authentication attempts; try again shortly');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const record = await tx.update<StoredContinuation>({
|
|
107
|
+
model: 'auth_continuation',
|
|
108
|
+
where: [
|
|
109
|
+
{ field: 'tokenHash', operator: '=', value: tokenHash },
|
|
110
|
+
{ field: 'consumedAt', operator: 'isNull', value: null },
|
|
111
|
+
{ field: 'invalidatedAt', operator: 'isNull', value: null },
|
|
112
|
+
{ field: 'expiresAt', operator: 'gt', value: consumedAt },
|
|
113
|
+
{ field: 'failedAttempts', operator: 'lt', value: maxAttempts },
|
|
114
|
+
],
|
|
115
|
+
data: { consumedAt },
|
|
116
|
+
});
|
|
117
|
+
if (!record)
|
|
118
|
+
throw Errors.unauthorized('Invalid or expired auth continuation');
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
// The row claim serializes concurrent guesses. On rejection, convert the
|
|
122
|
+
// error to data so the transaction can durably release the claim and
|
|
123
|
+
// increment the counter before the original error is re-thrown outside.
|
|
124
|
+
await verify?.(tx, record);
|
|
125
|
+
return { ok: true, continuation: record };
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
const nextFailedAttempts = (record.failedAttempts ?? 0) + 1;
|
|
129
|
+
const released = await tx.update<StoredContinuation>({
|
|
130
|
+
model: 'auth_continuation',
|
|
131
|
+
where: [
|
|
132
|
+
{ field: 'tokenHash', operator: '=', value: tokenHash },
|
|
133
|
+
{ field: 'consumedAt', operator: '=', value: consumedAt },
|
|
134
|
+
],
|
|
135
|
+
data: {
|
|
136
|
+
consumedAt: null,
|
|
137
|
+
failedAttempts: nextFailedAttempts,
|
|
138
|
+
lastFailedAt: consumedAt,
|
|
139
|
+
...(nextFailedAttempts >= maxAttempts ? { invalidatedAt: consumedAt } : {}),
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
if (!released)
|
|
143
|
+
throw Errors.unauthorized('Invalid or expired auth continuation');
|
|
144
|
+
return { ok: false, error };
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
if (!outcome.ok)
|
|
149
|
+
throw outcome.error;
|
|
150
|
+
return outcome.continuation;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Run post-credential gates in registration order and mint the first hold. */
|
|
154
|
+
export async function runPostAuthGates(
|
|
155
|
+
plugins: readonly FortressPlugin[],
|
|
156
|
+
db: DatabaseAdapter,
|
|
157
|
+
config: FortressConfig,
|
|
158
|
+
user: FortressUser,
|
|
159
|
+
meta: RequestMeta | undefined,
|
|
160
|
+
completedReasons: readonly PendingReason[] = [],
|
|
161
|
+
): Promise<PostAuthGateHold | null> {
|
|
162
|
+
const providers = plugins.flatMap(plugin => plugin.hooks?.postAuthGate ? [plugin.hooks.postAuthGate] : []);
|
|
163
|
+
const duplicateReason = providers.find((provider, index) =>
|
|
164
|
+
providers.findIndex(candidate => candidate.reason === provider.reason) !== index,
|
|
165
|
+
);
|
|
166
|
+
if (duplicateReason)
|
|
167
|
+
throw Errors.badRequest(`Duplicate post-auth gate reason: '${duplicateReason.reason}'`);
|
|
168
|
+
|
|
169
|
+
const resumeReason = completedReasons.at(-1);
|
|
170
|
+
const resumeIndex = resumeReason == null
|
|
171
|
+
? -1
|
|
172
|
+
: providers.findIndex(provider => provider.reason === resumeReason);
|
|
173
|
+
if (resumeReason != null && resumeIndex < 0)
|
|
174
|
+
throw Errors.unauthorized('No auth gate can resume this continuation');
|
|
175
|
+
|
|
176
|
+
const satisfiedReasons = providers.slice(0, resumeIndex + 1).map(provider => provider.reason);
|
|
177
|
+
for (const provider of providers.slice(resumeIndex + 1)) {
|
|
178
|
+
const decision: PostAuthGateDecision | void = await provider.evaluate({
|
|
179
|
+
db,
|
|
180
|
+
config,
|
|
181
|
+
meta,
|
|
182
|
+
user,
|
|
183
|
+
completedReasons: satisfiedReasons,
|
|
184
|
+
});
|
|
185
|
+
if (!decision)
|
|
186
|
+
continue;
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
challenge: await mintAuthContinuation(db, user.id, provider.reason, AUTH_CONTINUATION_TTL_SECONDS, {
|
|
190
|
+
maxAttempts: provider.maxAttempts,
|
|
191
|
+
cooldownSeconds: provider.cooldownSeconds,
|
|
192
|
+
}),
|
|
193
|
+
pluginData: decision.pluginData,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Dispatch completion proof to the provider that created this continuation. */
|
|
200
|
+
export async function verifyAuthContinuation(
|
|
201
|
+
plugins: readonly FortressPlugin[],
|
|
202
|
+
db: DatabaseAdapter,
|
|
203
|
+
config: FortressConfig,
|
|
204
|
+
user: FortressUser,
|
|
205
|
+
continuation: StoredContinuation,
|
|
206
|
+
completion: unknown,
|
|
207
|
+
meta?: RequestMeta,
|
|
208
|
+
): Promise<Record<string, unknown> | void> {
|
|
209
|
+
const providers = plugins.flatMap((plugin) => {
|
|
210
|
+
const provider = plugin.hooks?.postAuthGate;
|
|
211
|
+
return provider?.reason === continuation.reason ? [provider] : [];
|
|
212
|
+
});
|
|
213
|
+
if (providers.length !== 1)
|
|
214
|
+
throw Errors.unauthorized('No unique auth gate can complete this continuation');
|
|
215
|
+
const [provider] = providers;
|
|
216
|
+
|
|
217
|
+
return provider.verify({
|
|
218
|
+
db,
|
|
219
|
+
config,
|
|
220
|
+
meta,
|
|
221
|
+
user,
|
|
222
|
+
continuation: {
|
|
223
|
+
id: continuation.id,
|
|
224
|
+
reason: continuation.reason,
|
|
225
|
+
expiresAt: continuation.expiresAt,
|
|
226
|
+
},
|
|
227
|
+
}, completion);
|
|
228
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { deriveRefreshTokenSuccessor, generateRefreshToken, generateTokenFamily, hashRefreshFingerprint, hashToken } from './refresh-token';
|
|
4
|
+
|
|
5
|
+
describe('refresh-token', () => {
|
|
6
|
+
describe('generateRefreshToken', () => {
|
|
7
|
+
it('generates a raw token and its hash', async () => {
|
|
8
|
+
const { raw, hash } = await generateRefreshToken();
|
|
9
|
+
expect(raw).toBeTruthy();
|
|
10
|
+
expect(hash).toBeTruthy();
|
|
11
|
+
expect(raw).not.toBe(hash);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('generates unique tokens each time', async () => {
|
|
15
|
+
const t1 = await generateRefreshToken();
|
|
16
|
+
const t2 = await generateRefreshToken();
|
|
17
|
+
expect(t1.raw).not.toBe(t2.raw);
|
|
18
|
+
expect(t1.hash).not.toBe(t2.hash);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('raw token is base64url encoded (no +, /, =)', async () => {
|
|
22
|
+
const { raw } = await generateRefreshToken();
|
|
23
|
+
expect(raw).not.toMatch(/[+/=]/);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('hash is a hex string', async () => {
|
|
27
|
+
const { hash } = await generateRefreshToken();
|
|
28
|
+
expect(hash).toMatch(/^[0-9a-f]{64}$/); // SHA256 = 64 hex chars
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('hashToken', () => {
|
|
33
|
+
it('produces consistent hash for the same input', async () => {
|
|
34
|
+
const hash1 = await hashToken('same-token');
|
|
35
|
+
const hash2 = await hashToken('same-token');
|
|
36
|
+
expect(hash1).toBe(hash2);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('produces different hash for different input', async () => {
|
|
40
|
+
const hash1 = await hashToken('token-a');
|
|
41
|
+
const hash2 = await hashToken('token-b');
|
|
42
|
+
expect(hash1).not.toBe(hash2);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('hash of generated token matches the returned hash', async () => {
|
|
46
|
+
const { raw, hash } = await generateRefreshToken();
|
|
47
|
+
const recomputed = await hashToken(raw);
|
|
48
|
+
expect(recomputed).toBe(hash);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('deriveRefreshTokenSuccessor', () => {
|
|
53
|
+
it('recomputes the same successor without storing its raw value', async () => {
|
|
54
|
+
const first = await deriveRefreshTokenSuccessor('old-token', 'secret-a');
|
|
55
|
+
const retry = await deriveRefreshTokenSuccessor('old-token', 'secret-a');
|
|
56
|
+
const otherKey = await deriveRefreshTokenSuccessor('old-token', 'secret-b');
|
|
57
|
+
|
|
58
|
+
expect(retry).toEqual(first);
|
|
59
|
+
expect(otherKey.raw).not.toBe(first.raw);
|
|
60
|
+
expect(await hashToken(first.raw)).toBe(first.hash);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('hashRefreshFingerprint', () => {
|
|
65
|
+
it('is keyed and binds both User-Agent and source IP', async () => {
|
|
66
|
+
const first = await hashRefreshFingerprint('Browser/1', '203.0.113.1', 'secret-a');
|
|
67
|
+
expect(await hashRefreshFingerprint('Browser/1', '203.0.113.1', 'secret-a')).toBe(first);
|
|
68
|
+
expect(await hashRefreshFingerprint('Browser/1', '203.0.113.2', 'secret-a')).not.toBe(first);
|
|
69
|
+
expect(await hashRefreshFingerprint('Browser/1', '203.0.113.1', 'secret-b')).not.toBe(first);
|
|
70
|
+
expect(first).not.toBe(await hashToken('Browser/1'));
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('generateTokenFamily', () => {
|
|
75
|
+
it('generates a non-empty string', () => {
|
|
76
|
+
const family = generateTokenFamily();
|
|
77
|
+
expect(family).toBeTruthy();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('generates unique families', () => {
|
|
81
|
+
const f1 = generateTokenFamily();
|
|
82
|
+
const f2 = generateTokenFamily();
|
|
83
|
+
expect(f1).not.toBe(f2);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Refresh token utilities.
|
|
3
|
+
* Tokens are 32 bytes of cryptographic randomness, base64url encoded.
|
|
4
|
+
* Only the SHA256 hash is stored — the raw token is never persisted.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Generate a new refresh token and its SHA256 hash.
|
|
9
|
+
*/
|
|
10
|
+
export async function generateRefreshToken(): Promise<{ raw: string; hash: string }> {
|
|
11
|
+
const bytes = new Uint8Array(32);
|
|
12
|
+
crypto.getRandomValues(bytes);
|
|
13
|
+
const raw = base64UrlEncode(bytes);
|
|
14
|
+
const hash = await hashToken(raw);
|
|
15
|
+
return { raw, hash };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Derive a rotation successor without persisting its raw value. A
|
|
20
|
+
* domain-separated HKDF subkey derived from the configured root secret keys
|
|
21
|
+
* the rotation HMAC, so the JWT signing key is never used directly for a
|
|
22
|
+
* second primitive. A grace-window retry can recompute the exact successor
|
|
23
|
+
* and confirm it against `successorTokenHash`.
|
|
24
|
+
*/
|
|
25
|
+
export async function deriveRefreshTokenSuccessor(
|
|
26
|
+
rawToken: string,
|
|
27
|
+
secret: string,
|
|
28
|
+
): Promise<{ raw: string; hash: string }> {
|
|
29
|
+
const encoder = new TextEncoder();
|
|
30
|
+
const key = await deriveRefreshHmacKey(secret, 'rotation-successor-hmac');
|
|
31
|
+
const digest = await crypto.subtle.sign(
|
|
32
|
+
'HMAC',
|
|
33
|
+
key,
|
|
34
|
+
encoder.encode(`fortress-refresh-successor\0${rawToken}`),
|
|
35
|
+
);
|
|
36
|
+
const raw = base64UrlEncode(new Uint8Array(digest));
|
|
37
|
+
return { raw, hash: await hashToken(raw) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Keyed refresh-client consistency signal. It binds both User-Agent and the
|
|
42
|
+
* observed source IP without storing a reversible fingerprint in the DB.
|
|
43
|
+
* This remains optional defense-in-depth rather than proof of device identity.
|
|
44
|
+
*/
|
|
45
|
+
export async function hashRefreshFingerprint(
|
|
46
|
+
userAgent: string,
|
|
47
|
+
ipAddress: string | undefined,
|
|
48
|
+
secret: string,
|
|
49
|
+
): Promise<string> {
|
|
50
|
+
const encoder = new TextEncoder();
|
|
51
|
+
const key = await deriveRefreshHmacKey(secret, 'client-fingerprint-hmac');
|
|
52
|
+
const digest = await crypto.subtle.sign(
|
|
53
|
+
'HMAC',
|
|
54
|
+
key,
|
|
55
|
+
encoder.encode(`${userAgent}\0${ipAddress ?? ''}`),
|
|
56
|
+
);
|
|
57
|
+
return hexEncode(new Uint8Array(digest));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function deriveRefreshHmacKey(secret: string, info: string): Promise<CryptoKey> {
|
|
61
|
+
const encoder = new TextEncoder();
|
|
62
|
+
const rootKey = await crypto.subtle.importKey('raw', encoder.encode(secret), 'HKDF', false, ['deriveKey']);
|
|
63
|
+
return crypto.subtle.deriveKey(
|
|
64
|
+
{
|
|
65
|
+
name: 'HKDF',
|
|
66
|
+
hash: 'SHA-256',
|
|
67
|
+
salt: encoder.encode('fortress-refresh-token-kdf-v1'),
|
|
68
|
+
info: encoder.encode(info),
|
|
69
|
+
},
|
|
70
|
+
rootKey,
|
|
71
|
+
{ name: 'HMAC', hash: 'SHA-256', length: 256 },
|
|
72
|
+
false,
|
|
73
|
+
['sign'],
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Generate a random token family ID for rotation tracking.
|
|
79
|
+
*/
|
|
80
|
+
export function generateTokenFamily(): string {
|
|
81
|
+
const bytes = new Uint8Array(16);
|
|
82
|
+
crypto.getRandomValues(bytes);
|
|
83
|
+
return base64UrlEncode(bytes);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* SHA256 hash a raw token. Used for storage and lookup.
|
|
88
|
+
*/
|
|
89
|
+
export async function hashToken(raw: string): Promise<string> {
|
|
90
|
+
const data = new TextEncoder().encode(raw);
|
|
91
|
+
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
|
92
|
+
return hexEncode(new Uint8Array(hashBuffer));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function base64UrlEncode(bytes: Uint8Array): string {
|
|
96
|
+
const binString = Array.from(bytes, b => String.fromCodePoint(b)).join('');
|
|
97
|
+
return btoa(binString)
|
|
98
|
+
.replace(/\+/g, '-')
|
|
99
|
+
.replace(/\//g, '_')
|
|
100
|
+
.replace(/=/g, '');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function hexEncode(bytes: Uint8Array): string {
|
|
104
|
+
return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
|
|
105
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Constant-time equality for secret-derived strings.
|
|
3
|
+
*
|
|
4
|
+
* `===` on two strings short-circuits at the first mismatched byte, which —
|
|
5
|
+
* combined with a remote attacker's ability to time HTTP responses — leaks
|
|
6
|
+
* one bit per request about how far into the secret the comparison got.
|
|
7
|
+
* The standard mitigation is to compare every byte regardless of mismatch.
|
|
8
|
+
*
|
|
9
|
+
* Both arms of OAuth's hot paths (client-secret check, access-token /
|
|
10
|
+
* authorization-code lookup) hash the inbound value with SHA-256 before
|
|
11
|
+
* comparing, so both operands here are 64-character lowercase hex. The
|
|
12
|
+
* length check is therefore a no-op in practice but preserved for safety.
|
|
13
|
+
*
|
|
14
|
+
* @see RFC 9700 §2.1.1, OWASP ASVS V2.4
|
|
15
|
+
*/
|
|
16
|
+
export function timingSafeEqualHex(a: string, b: string): boolean {
|
|
17
|
+
if (a.length !== b.length)
|
|
18
|
+
return false;
|
|
19
|
+
let diff = 0;
|
|
20
|
+
for (let i = 0; i < a.length; i++)
|
|
21
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
22
|
+
return diff === 0;
|
|
23
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import type { DatabaseAdapter } from '../adapters/database';
|
|
2
|
+
import type { JwtKeyMaterial } from './auth/jwt';
|
|
3
|
+
import type { PasswordPolicyConfig } from './auth/password-policy';
|
|
4
|
+
import type { EndpointDefinition } from './endpoint';
|
|
5
|
+
import type { CsrfConfig } from './http/csrf';
|
|
6
|
+
import type { FortressLogger } from './observability/logger';
|
|
7
|
+
import type { TelemetryProvider } from './observability/types';
|
|
8
|
+
import type { FortressPlugin } from './plugin';
|
|
9
|
+
|
|
10
|
+
/** Pluggable password hashing contract — implement to swap fortress's default Argon2id WASM hasher. */
|
|
11
|
+
export interface PasswordHasher {
|
|
12
|
+
hash: (password: string) => Promise<string>;
|
|
13
|
+
verify: (hash: string, password: string) => Promise<boolean>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Cookie configuration consumed by `fortress.handleRequest` and any framework
|
|
18
|
+
* adapter that wants to serialize auth tokens to `Set-Cookie` headers.
|
|
19
|
+
*
|
|
20
|
+
* Defaults are environment-independent and production-safe: `__Host-`
|
|
21
|
+
* prefixed names, `httpOnly`, `secure`, `sameSite: 'lax'`, `path: '/'`.
|
|
22
|
+
* Plain-HTTP local development must explicitly set `secure: false`, which
|
|
23
|
+
* also selects non-`__Host-` default names.
|
|
24
|
+
*/
|
|
25
|
+
export interface CookieConfig {
|
|
26
|
+
/** Override the access-token cookie name. Default: `__Host-fortress_access` when host-prefix constraints permit. */
|
|
27
|
+
accessName?: string;
|
|
28
|
+
/** Override the refresh-token cookie name. Default: `__Host-fortress_refresh` when host-prefix constraints permit. */
|
|
29
|
+
refreshName?: string;
|
|
30
|
+
/** SameSite attribute. Default: `'lax'`. */
|
|
31
|
+
sameSite?: 'lax' | 'strict' | 'none';
|
|
32
|
+
/** Force the `Secure` attribute on or off. Default: `true` in every environment. */
|
|
33
|
+
secure?: boolean;
|
|
34
|
+
/** Optional `Domain` attribute. Defaults to host-only when omitted. */
|
|
35
|
+
domain?: string;
|
|
36
|
+
/** `Path` attribute. Default: `'/'`. */
|
|
37
|
+
path?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Opt-in refresh-session controls. Omitted fields preserve existing behavior. */
|
|
41
|
+
export interface SessionConfig {
|
|
42
|
+
/** Accept the immediately previous refresh token for this many seconds. */
|
|
43
|
+
refreshGraceSeconds?: number;
|
|
44
|
+
/** Reject a session after this many seconds without refresh activity. */
|
|
45
|
+
idleTimeoutSeconds?: number;
|
|
46
|
+
/** Reject a refresh family after this many seconds, regardless of activity. */
|
|
47
|
+
absoluteTimeoutSeconds?: number;
|
|
48
|
+
/** Maximum active refresh families per user; oldest sessions are revoked first. */
|
|
49
|
+
maxSessionsPerUser?: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Top-level fortress configuration accepted by {@link createFortress}. */
|
|
53
|
+
export interface FortressConfig {
|
|
54
|
+
jwt: {
|
|
55
|
+
/**
|
|
56
|
+
* Signing/verification key material. Today: an HS256 shared secret
|
|
57
|
+
* (string) or a rotation array of shared secrets (first signs, all
|
|
58
|
+
* verify). See {@link JwtKeyMaterial} for the planned expansion to
|
|
59
|
+
* asymmetric keys / JWKS.
|
|
60
|
+
*/
|
|
61
|
+
key: JwtKeyMaterial;
|
|
62
|
+
issuer?: string;
|
|
63
|
+
/** Optional audience included in and required for access tokens. */
|
|
64
|
+
audience?: string | string[];
|
|
65
|
+
accessTokenExpirySeconds?: number;
|
|
66
|
+
refreshTokenExpirySeconds?: number;
|
|
67
|
+
/**
|
|
68
|
+
* Compare a domain-separated keyed fingerprint of User-Agent + source IP
|
|
69
|
+
* across refreshes. This is an anomaly signal, not proof of device identity;
|
|
70
|
+
* enable hard rejection only where client IP stability is acceptable.
|
|
71
|
+
*/
|
|
72
|
+
validateRefreshFingerprint?: boolean | 'warn';
|
|
73
|
+
/** Optional session rotation/cap controls. No caps or grace apply when omitted. */
|
|
74
|
+
session?: SessionConfig;
|
|
75
|
+
};
|
|
76
|
+
rbac?: {
|
|
77
|
+
evaluationMode?: 'allow-only' | 'deny-overrides';
|
|
78
|
+
resourceFile?: string;
|
|
79
|
+
cache?: {
|
|
80
|
+
ttlSeconds?: number;
|
|
81
|
+
maxEntries?: number;
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
database: DatabaseAdapter;
|
|
85
|
+
passwordHasher?: PasswordHasher;
|
|
86
|
+
passwordPolicy?: PasswordPolicyConfig;
|
|
87
|
+
/**
|
|
88
|
+
* Impersonation hardening (RFC 8693 `act` tokens). All fields optional;
|
|
89
|
+
* defaults to a 1-hour ceiling on caller-supplied `expirySeconds`.
|
|
90
|
+
*/
|
|
91
|
+
impersonation?: {
|
|
92
|
+
/**
|
|
93
|
+
* Hard ceiling on the impersonation access-token lifetime in seconds.
|
|
94
|
+
* `auth.impersonate({ expirySeconds })` is clamped to this. Default: 3600.
|
|
95
|
+
*/
|
|
96
|
+
maxTtlSeconds?: number;
|
|
97
|
+
};
|
|
98
|
+
plugins?: readonly FortressPlugin[];
|
|
99
|
+
/**
|
|
100
|
+
* Host-application endpoint definitions to register with the manifest,
|
|
101
|
+
* OpenAPI generation, and `protect()` / adapter `protectedRoute()` helpers
|
|
102
|
+
* without authoring a plugin. Fortress tracks them internally under the
|
|
103
|
+
* reserved name `__host`.
|
|
104
|
+
*
|
|
105
|
+
* Top-level `routes` are metadata-only: adapters leave them to the host
|
|
106
|
+
* router and they do not add `fortress.call.*` entries because no handler
|
|
107
|
+
* methods are registered. If you need Fortress-mounted routes or typed
|
|
108
|
+
* in-process callables for custom routes, declare a real plugin with both
|
|
109
|
+
* `routes` and matching `methods`.
|
|
110
|
+
*
|
|
111
|
+
* Keyed by handler name, matching {@link FortressPlugin.routes}:
|
|
112
|
+
*
|
|
113
|
+
* ```ts
|
|
114
|
+
* createFortress({
|
|
115
|
+
* database, jwt, cookies,
|
|
116
|
+
* routes: appEndpoints,
|
|
117
|
+
* });
|
|
118
|
+
* ```
|
|
119
|
+
*
|
|
120
|
+
* The name `__host` is reserved — declaring a plugin called `__host`
|
|
121
|
+
* alongside `routes` is a configuration error.
|
|
122
|
+
*/
|
|
123
|
+
routes?: Record<string, EndpointDefinition>;
|
|
124
|
+
/** Auth-cookie naming and attributes used by `fortress.handleRequest` and framework adapters. */
|
|
125
|
+
cookies?: CookieConfig;
|
|
126
|
+
/**
|
|
127
|
+
* Pipeline CSRF protection (H5). Defaults to `{ enabled: true }`.
|
|
128
|
+
* Bearer/API-key requests are exempt automatically — only cookie-auth
|
|
129
|
+
* traffic is checked. See {@link CsrfConfig}.
|
|
130
|
+
*/
|
|
131
|
+
csrf?: CsrfConfig;
|
|
132
|
+
/**
|
|
133
|
+
* Optional pluggable logger. Accepts any object that conforms structurally
|
|
134
|
+
* to {@link FortressLogger} — a `pino()` instance, Fastify's `app.log`,
|
|
135
|
+
* or a hand-rolled `console` wrapper. Defaults to a silent no-op so
|
|
136
|
+
* Fortress never writes to stderr unless the caller opts in.
|
|
137
|
+
*/
|
|
138
|
+
logger?: FortressLogger;
|
|
139
|
+
/**
|
|
140
|
+
* Optional telemetry provider (tracer + meter). Wire this to the
|
|
141
|
+
* OpenTelemetry adapter with `createOtelTelemetry` from the
|
|
142
|
+
* `@bajustone/fortress/otel` sub-path, or provide any custom
|
|
143
|
+
* implementation. Defaults to a zero-alloc no-op provider.
|
|
144
|
+
*/
|
|
145
|
+
observability?: TelemetryProvider;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Validated cookie attributes resolved independently of `NODE_ENV`. */
|
|
149
|
+
export interface ResolvedCookieConfig {
|
|
150
|
+
accessName: string;
|
|
151
|
+
refreshName: string;
|
|
152
|
+
sameSite: 'lax' | 'strict' | 'none';
|
|
153
|
+
secure: boolean;
|
|
154
|
+
domain?: string;
|
|
155
|
+
path: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Resolve a {@link CookieConfig} with secure, environment-independent defaults.
|
|
160
|
+
* Plain-HTTP localhost development must explicitly pass `secure: false`; this
|
|
161
|
+
* disables the `__Host-` default names because that prefix requires Secure.
|
|
162
|
+
*/
|
|
163
|
+
export function resolveCookieConfig(config?: CookieConfig): ResolvedCookieConfig {
|
|
164
|
+
// P3.7: default to `Secure` unless the caller explicitly opts out. Many
|
|
165
|
+
// production runtimes do not set NODE_ENV to 'production' (containers,
|
|
166
|
+
// serverless, Kubernetes Jobs), so falling back to NODE_ENV would leave
|
|
167
|
+
// cookies unprotected. Local HTTP development must now opt out
|
|
168
|
+
// explicitly via `cookies: { secure: false }`.
|
|
169
|
+
const secure = config?.secure ?? true;
|
|
170
|
+
const domain = config?.domain?.trim() || undefined;
|
|
171
|
+
// __Host- prefix requires Secure + Path=/ + no Domain (RFC 6265bis).
|
|
172
|
+
const canHostPrefix = secure && !domain && (config?.path ?? '/') === '/';
|
|
173
|
+
const defaultAccess = canHostPrefix ? '__Host-fortress_access' : 'fortress_access';
|
|
174
|
+
const defaultRefresh = canHostPrefix ? '__Host-fortress_refresh' : 'fortress_refresh';
|
|
175
|
+
const accessName = config?.accessName ?? defaultAccess;
|
|
176
|
+
const refreshName = config?.refreshName ?? defaultRefresh;
|
|
177
|
+
const sameSite = config?.sameSite ?? 'lax';
|
|
178
|
+
const path = config?.path ?? '/';
|
|
179
|
+
|
|
180
|
+
if (sameSite === 'none' && !secure)
|
|
181
|
+
throw new Error('Cookie SameSite=None requires Secure');
|
|
182
|
+
|
|
183
|
+
for (const name of [accessName, refreshName]) {
|
|
184
|
+
if (name.startsWith('__Secure-') && !secure)
|
|
185
|
+
throw new Error(`Cookie ${name} uses the __Secure- prefix but Secure is disabled`);
|
|
186
|
+
if (name.startsWith('__Host-') && (!secure || domain !== undefined || path !== '/')) {
|
|
187
|
+
throw new Error(`Cookie ${name} uses the __Host- prefix but requires Secure, Path=/, and no Domain`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
accessName,
|
|
193
|
+
refreshName,
|
|
194
|
+
sameSite,
|
|
195
|
+
secure,
|
|
196
|
+
domain,
|
|
197
|
+
path,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Default JWT and RBAC settings applied when {@link FortressConfig} omits them. */
|
|
202
|
+
export const DEFAULT_CONFIG = {
|
|
203
|
+
jwt: {
|
|
204
|
+
issuer: 'fortress',
|
|
205
|
+
accessTokenExpirySeconds: 900,
|
|
206
|
+
refreshTokenExpirySeconds: 604800,
|
|
207
|
+
},
|
|
208
|
+
rbac: {
|
|
209
|
+
evaluationMode: 'allow-only' as const,
|
|
210
|
+
resourceFile: './fortress.resources.json',
|
|
211
|
+
},
|
|
212
|
+
} as const;
|