@bajustone/fortress 1.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1011 -0
- package/LICENSE +21 -0
- package/README.md +760 -0
- package/SECURITY.md +197 -0
- package/bin/fortress.ts +667 -0
- package/dist/auth-service-BcyQ2PuZ.d.cts +307 -0
- package/dist/auth-service-BkzZkWfH.d.ts +307 -0
- package/dist/config-B2366s_G.d.ts +665 -0
- package/dist/config-GtlVt6ZD.d.cts +665 -0
- package/dist/convert-routes-BLCQT57f.d.ts +72 -0
- package/dist/convert-routes-BdMvP2_X.d.cts +72 -0
- package/dist/crypto.cjs +61 -0
- package/dist/crypto.d.cts +36 -0
- package/dist/crypto.d.ts +36 -0
- package/dist/crypto.js +35 -0
- package/dist/drift-BT1wtMDJ.d.cts +22 -0
- package/dist/drift-k9LiYpRP.d.ts +22 -0
- package/dist/drizzle/pg.cjs +481 -0
- package/dist/drizzle/pg.d.cts +15 -0
- package/dist/drizzle/pg.d.ts +15 -0
- package/dist/drizzle/pg.js +454 -0
- package/dist/drizzle.cjs +1442 -0
- package/dist/drizzle.d.cts +85 -0
- package/dist/drizzle.d.ts +85 -0
- package/dist/drizzle.js +1411 -0
- package/dist/express.cjs +1357 -0
- package/dist/express.d.cts +333 -0
- package/dist/express.d.ts +333 -0
- package/dist/express.js +1314 -0
- package/dist/fetcher.cjs +77 -0
- package/dist/fetcher.d.cts +7 -0
- package/dist/fetcher.d.ts +7 -0
- package/dist/fetcher.js +41 -0
- package/dist/fortress-BmSvFfqK.d.cts +1089 -0
- package/dist/fortress-uA-_cheR.d.ts +1089 -0
- package/dist/hono.cjs +1530 -0
- package/dist/hono.d.cts +514 -0
- package/dist/hono.d.ts +514 -0
- package/dist/hono.js +1483 -0
- package/dist/index-CxEkfCA0.d.cts +77 -0
- package/dist/index-CxEkfCA0.d.ts +77 -0
- package/dist/index-D054pcb-.d.cts +146 -0
- package/dist/index-jAMbbUAY.d.ts +146 -0
- package/dist/index.cjs +9046 -0
- package/dist/index.d.cts +717 -0
- package/dist/index.d.ts +717 -0
- package/dist/index.js +8935 -0
- package/dist/jwt.cjs +255 -0
- package/dist/jwt.d.cts +90 -0
- package/dist/jwt.d.ts +90 -0
- package/dist/jwt.js +227 -0
- package/dist/otel.cjs +108 -0
- package/dist/otel.d.cts +62 -0
- package/dist/otel.d.ts +62 -0
- package/dist/otel.js +73 -0
- package/dist/plugins/account-lockout.cjs +322 -0
- package/dist/plugins/account-lockout.d.cts +42 -0
- package/dist/plugins/account-lockout.d.ts +42 -0
- package/dist/plugins/account-lockout.js +295 -0
- package/dist/plugins/admin.cjs +2378 -0
- package/dist/plugins/admin.d.cts +72 -0
- package/dist/plugins/admin.d.ts +72 -0
- package/dist/plugins/admin.js +2351 -0
- package/dist/plugins/api-key.cjs +792 -0
- package/dist/plugins/api-key.d.cts +121 -0
- package/dist/plugins/api-key.d.ts +121 -0
- package/dist/plugins/api-key.js +765 -0
- package/dist/plugins/audit-log.cjs +493 -0
- package/dist/plugins/audit-log.d.cts +86 -0
- package/dist/plugins/audit-log.d.ts +86 -0
- package/dist/plugins/audit-log.js +468 -0
- package/dist/plugins/data-isolation.cjs +211 -0
- package/dist/plugins/data-isolation.d.cts +49 -0
- package/dist/plugins/data-isolation.d.ts +49 -0
- package/dist/plugins/data-isolation.js +186 -0
- package/dist/plugins/email-verification.cjs +273 -0
- package/dist/plugins/email-verification.d.cts +44 -0
- package/dist/plugins/email-verification.d.ts +44 -0
- package/dist/plugins/email-verification.js +246 -0
- package/dist/plugins/magic-link.cjs +231 -0
- package/dist/plugins/magic-link.d.cts +39 -0
- package/dist/plugins/magic-link.d.ts +39 -0
- package/dist/plugins/magic-link.js +204 -0
- package/dist/plugins/oauth.cjs +1696 -0
- package/dist/plugins/oauth.d.cts +406 -0
- package/dist/plugins/oauth.d.ts +406 -0
- package/dist/plugins/oauth.js +1669 -0
- package/dist/plugins/openapi.cjs +1185 -0
- package/dist/plugins/openapi.d.cts +6 -0
- package/dist/plugins/openapi.d.ts +6 -0
- package/dist/plugins/openapi.js +1158 -0
- package/dist/plugins/rate-limit/express.cjs +55 -0
- package/dist/plugins/rate-limit/express.d.cts +64 -0
- package/dist/plugins/rate-limit/express.d.ts +64 -0
- package/dist/plugins/rate-limit/express.js +30 -0
- package/dist/plugins/rate-limit/hono.cjs +51 -0
- package/dist/plugins/rate-limit/hono.d.cts +59 -0
- package/dist/plugins/rate-limit/hono.d.ts +59 -0
- package/dist/plugins/rate-limit/hono.js +26 -0
- package/dist/plugins/rate-limit/sveltekit.cjs +49 -0
- package/dist/plugins/rate-limit/sveltekit.d.cts +59 -0
- package/dist/plugins/rate-limit/sveltekit.d.ts +59 -0
- package/dist/plugins/rate-limit/sveltekit.js +24 -0
- package/dist/plugins/rate-limit.cjs +354 -0
- package/dist/plugins/rate-limit.d.cts +140 -0
- package/dist/plugins/rate-limit.d.ts +140 -0
- package/dist/plugins/rate-limit.js +325 -0
- package/dist/plugins/social-login.cjs +905 -0
- package/dist/plugins/social-login.d.cts +114 -0
- package/dist/plugins/social-login.d.ts +114 -0
- package/dist/plugins/social-login.js +880 -0
- package/dist/plugins/tenancy.cjs +780 -0
- package/dist/plugins/tenancy.d.cts +108 -0
- package/dist/plugins/tenancy.d.ts +108 -0
- package/dist/plugins/tenancy.js +753 -0
- package/dist/plugins/two-factor.cjs +555 -0
- package/dist/plugins/two-factor.d.cts +67 -0
- package/dist/plugins/two-factor.d.ts +67 -0
- package/dist/plugins/two-factor.js +526 -0
- package/dist/plugins/webauthn.cjs +786 -0
- package/dist/plugins/webauthn.d.cts +72 -0
- package/dist/plugins/webauthn.d.ts +72 -0
- package/dist/plugins/webauthn.js +766 -0
- package/dist/plugins/webhook.cjs +819 -0
- package/dist/plugins/webhook.d.cts +254 -0
- package/dist/plugins/webhook.d.ts +254 -0
- package/dist/plugins/webhook.js +789 -0
- package/dist/protect-D1v_0upK.d.cts +123 -0
- package/dist/protect-DsxV_-dJ.d.ts +123 -0
- package/dist/sveltekit.cjs +1258 -0
- package/dist/sveltekit.d.cts +420 -0
- package/dist/sveltekit.d.ts +420 -0
- package/dist/sveltekit.js +1207 -0
- package/dist/testing.cjs +4294 -0
- package/dist/testing.d.cts +197 -0
- package/dist/testing.d.ts +197 -0
- package/dist/testing.js +4264 -0
- package/dist/types-et0R8tsC.d.cts +217 -0
- package/dist/types-et0R8tsC.d.ts +217 -0
- package/docs/adapter-typed-helpers.md +192 -0
- package/docs/admin-recipes.md +227 -0
- package/docs/architecture.md +434 -0
- package/docs/ci/github-actions.yml +59 -0
- package/docs/ci.md +109 -0
- package/docs/compatibility.md +115 -0
- package/docs/deployment.md +305 -0
- package/docs/hardening.md +118 -0
- package/docs/host-owned-routes.md +154 -0
- package/docs/migrations/0001-schema-version.md +54 -0
- package/docs/migrations/0002-initial-schema.md +84 -0
- package/docs/migrations/upgrade-guide.md +160 -0
- package/docs/observability.md +394 -0
- package/docs/plugins/account-lockout.md +131 -0
- package/docs/plugins/admin.md +402 -0
- package/docs/plugins/api-key.md +327 -0
- package/docs/plugins/audit-log.md +261 -0
- package/docs/plugins/data-isolation.md +186 -0
- package/docs/plugins/email-verification.md +121 -0
- package/docs/plugins/magic-link.md +123 -0
- package/docs/plugins/oauth.md +544 -0
- package/docs/plugins/openapi.md +250 -0
- package/docs/plugins/rate-limit.md +223 -0
- package/docs/plugins/social-login.md +337 -0
- package/docs/plugins/tenancy.md +122 -0
- package/docs/plugins/two-factor.md +191 -0
- package/docs/plugins/webauthn.md +188 -0
- package/docs/plugins/webhook.md +242 -0
- package/docs/policy-as-code.md +156 -0
- package/docs/route-manifest.md +46 -0
- package/docs/security.md +261 -0
- package/docs/threat-model.md +161 -0
- package/examples/README.md +119 -0
- package/examples/express-app/index.ts +747 -0
- package/examples/hono-app/docker-compose.yml +14 -0
- package/examples/hono-app/index.ts +1009 -0
- package/examples/policy/fortress.policy.json +47 -0
- package/examples/sveltekit-app/README.md +125 -0
- package/examples/sveltekit-app/src/app.d.ts +16 -0
- package/examples/sveltekit-app/src/hooks.server.ts +23 -0
- package/examples/sveltekit-app/src/lib/server/fortress.ts +81 -0
- package/examples/sveltekit-app/src/routes/api/echo/[id]/+server.ts +32 -0
- package/examples/sveltekit-app/src/routes/api/fortress/[...path]/+server.ts +15 -0
- package/examples/sveltekit-app/src/routes/dashboard/+page.server.ts +20 -0
- package/examples/sveltekit-app/src/routes/login/+page.server.ts +48 -0
- package/examples/sveltekit-app/src/routes/oauth/consent/+page.server.ts +69 -0
- package/examples/sveltekit-app/src/routes/oauth/consent/+page.svelte +83 -0
- package/migrations/pg/0001_schema_version.down.sql +2 -0
- package/migrations/pg/0001_schema_version.sql +8 -0
- package/migrations/pg/0002_initial_schema.down.sql +36 -0
- package/migrations/pg/0002_initial_schema.sql +376 -0
- package/migrations/pg/0003_auth_continuation.down.sql +6 -0
- package/migrations/pg/0003_auth_continuation.sql +30 -0
- package/migrations/pg/0004_tenant_default_unique.down.sql +1 -0
- package/migrations/pg/0004_tenant_default_unique.sql +14 -0
- package/migrations/pg/0005_hot_indexes_timestamptz.down.sql +71 -0
- package/migrations/pg/0005_hot_indexes_timestamptz.sql +71 -0
- package/migrations/pg/0006_canonical_email.down.sql +3 -0
- package/migrations/pg/0006_canonical_email.sql +8 -0
- package/migrations/pg/0007_audit_chain_anchor.down.sql +1 -0
- package/migrations/pg/0007_audit_chain_anchor.sql +8 -0
- package/migrations/pg/0008_two_factor_hardening.down.sql +8 -0
- package/migrations/pg/0008_two_factor_hardening.sql +8 -0
- package/migrations/pg/0009_encrypt_totp_secrets.down.sql +2 -0
- package/migrations/pg/0009_encrypt_totp_secrets.sql +5 -0
- package/migrations/pg/0010_bigint_append_only_ids.down.sql +2 -0
- package/migrations/pg/0010_bigint_append_only_ids.sql +23 -0
- package/migrations/sqlite/0001_schema_version.down.sql +2 -0
- package/migrations/sqlite/0001_schema_version.sql +8 -0
- package/migrations/sqlite/0002_initial_schema.down.sql +36 -0
- package/migrations/sqlite/0002_initial_schema.sql +376 -0
- package/migrations/sqlite/0003_auth_continuation.down.sql +27 -0
- package/migrations/sqlite/0003_auth_continuation.sql +45 -0
- package/migrations/sqlite/0004_tenant_default_unique.down.sql +1 -0
- package/migrations/sqlite/0004_tenant_default_unique.sql +13 -0
- package/migrations/sqlite/0005_hot_indexes_timestamptz.down.sql +10 -0
- package/migrations/sqlite/0005_hot_indexes_timestamptz.sql +10 -0
- package/migrations/sqlite/0006_canonical_email.down.sql +3 -0
- package/migrations/sqlite/0006_canonical_email.sql +8 -0
- package/migrations/sqlite/0007_audit_chain_anchor.down.sql +1 -0
- package/migrations/sqlite/0007_audit_chain_anchor.sql +8 -0
- package/migrations/sqlite/0008_two_factor_hardening.down.sql +14 -0
- package/migrations/sqlite/0008_two_factor_hardening.sql +7 -0
- package/migrations/sqlite/0009_encrypt_totp_secrets.down.sql +2 -0
- package/migrations/sqlite/0009_encrypt_totp_secrets.sql +5 -0
- package/migrations/sqlite/0010_bigint_append_only_ids.down.sql +1 -0
- package/migrations/sqlite/0010_bigint_append_only_ids.sql +2 -0
- package/package.json +398 -0
- package/src/adapters/database/index.ts +63 -0
- package/src/adapters/database/types.ts +22 -0
- package/src/changelog-script.test.ts +21 -0
- package/src/cli.test.ts +79 -0
- package/src/core/auth/auth-admin.test.ts +247 -0
- package/src/core/auth/auth-endpoints.ts +558 -0
- package/src/core/auth/auth-observer.test.ts +191 -0
- package/src/core/auth/auth-service.ts +1464 -0
- package/src/core/auth/email.test.ts +17 -0
- package/src/core/auth/email.ts +11 -0
- package/src/core/auth/hooks.test.ts +251 -0
- package/src/core/auth/impersonation.test.ts +179 -0
- package/src/core/auth/jwt.test.ts +184 -0
- package/src/core/auth/jwt.ts +239 -0
- package/src/core/auth/login-timing.test.ts +77 -0
- package/src/core/auth/password-policy.test.ts +253 -0
- package/src/core/auth/password-policy.ts +220 -0
- package/src/core/auth/password.test.ts +42 -0
- package/src/core/auth/password.ts +62 -0
- package/src/core/auth/post-auth-gate.test.ts +258 -0
- package/src/core/auth/post-auth-gate.ts +228 -0
- package/src/core/auth/refresh-token.test.ts +86 -0
- package/src/core/auth/refresh-token.ts +105 -0
- package/src/core/auth/timing-safe.ts +23 -0
- package/src/core/config.ts +212 -0
- package/src/core/endpoint.ts +149 -0
- package/src/core/errors.ts +199 -0
- package/src/core/fetcher-interop.test.ts +106 -0
- package/src/core/fortress.test.ts +354 -0
- package/src/core/fortress.ts +550 -0
- package/src/core/http/call.test.ts +148 -0
- package/src/core/http/call.ts +149 -0
- package/src/core/http/cookie-serialize.test.ts +198 -0
- package/src/core/http/cookie-serialize.ts +107 -0
- package/src/core/http/csrf.test.ts +140 -0
- package/src/core/http/csrf.ts +173 -0
- package/src/core/http/dispatch.ts +652 -0
- package/src/core/http/error-response.test.ts +69 -0
- package/src/core/http/error-response.ts +93 -0
- package/src/core/http/fortress-rbac.test.ts +43 -0
- package/src/core/http/fortress-rbac.ts +127 -0
- package/src/core/http/handle-request.test.ts +441 -0
- package/src/core/http/handle-request.ts +354 -0
- package/src/core/http/match.test.ts +122 -0
- package/src/core/http/match.ts +126 -0
- package/src/core/http/outbound.test.ts +34 -0
- package/src/core/http/outbound.ts +105 -0
- package/src/core/http/plugin-middleware.ts +67 -0
- package/src/core/http/principal.test.ts +345 -0
- package/src/core/http/principal.ts +86 -0
- package/src/core/http/protect.test.ts +220 -0
- package/src/core/http/protect.ts +394 -0
- package/src/core/http/token-extraction.test.ts +59 -0
- package/src/core/http/token-extraction.ts +53 -0
- package/src/core/iam/explain.test.ts +177 -0
- package/src/core/iam/explain.ts +302 -0
- package/src/core/iam/iam-endpoints.ts +779 -0
- package/src/core/iam/iam-service.test.ts +829 -0
- package/src/core/iam/iam-service.ts +1137 -0
- package/src/core/iam/permission-cache.test.ts +115 -0
- package/src/core/iam/permission-cache.ts +71 -0
- package/src/core/iam/permission-check-observer.test.ts +90 -0
- package/src/core/iam/permission-evaluator.test.ts +291 -0
- package/src/core/iam/permission-evaluator.ts +198 -0
- package/src/core/iam/permission-sync.test.ts +166 -0
- package/src/core/iam/permission-sync.ts +192 -0
- package/src/core/iam/resource-sync.test.ts +70 -0
- package/src/core/iam/resource-sync.ts +182 -0
- package/src/core/iam/service-account-http.test.ts +529 -0
- package/src/core/iam/service-account.test.ts +282 -0
- package/src/core/internal-adapter.test.ts +389 -0
- package/src/core/internal-adapter.ts +435 -0
- package/src/core/json-schema.ts +50 -0
- package/src/core/manifest/__snapshots__/route-manifest.test.ts.snap +192 -0
- package/src/core/manifest/drift.ts +103 -0
- package/src/core/manifest/route-manifest.test.ts +142 -0
- package/src/core/manifest/route-manifest.ts +154 -0
- package/src/core/migrate.test.ts +72 -0
- package/src/core/migrations/engine.test.ts +308 -0
- package/src/core/migrations/engine.ts +548 -0
- package/src/core/migrations/migrations.ts +1771 -0
- package/src/core/migrations/pg-migration.integration-test.ts +353 -0
- package/src/core/migrations/upgrade-fixture.test.ts +378 -0
- package/src/core/observability/db-instrumentation.ts +205 -0
- package/src/core/observability/listener-list.test.ts +124 -0
- package/src/core/observability/listener-list.ts +73 -0
- package/src/core/observability/logger.test.ts +43 -0
- package/src/core/observability/logger.ts +49 -0
- package/src/core/observability/spans.test.ts +193 -0
- package/src/core/observability/types.ts +80 -0
- package/src/core/openapi-drift.test.ts +41 -0
- package/src/core/openapi.ts +47 -0
- package/src/core/plugin-methods-map.ts +75 -0
- package/src/core/plugin-runner.test.ts +233 -0
- package/src/core/plugin-runner.ts +276 -0
- package/src/core/plugin.ts +210 -0
- package/src/core/policy/apply.ts +272 -0
- package/src/core/policy/diff.ts +288 -0
- package/src/core/policy/loader.test.ts +63 -0
- package/src/core/policy/loader.ts +214 -0
- package/src/core/policy/policy.test.ts +232 -0
- package/src/core/policy/types.ts +105 -0
- package/src/core/schema-builder.test.ts +660 -0
- package/src/core/schema-builder.ts +881 -0
- package/src/core/standard-schema.ts +56 -0
- package/src/core/types.ts +258 -0
- package/src/core/validation.test.ts +195 -0
- package/src/core/validation.ts +192 -0
- package/src/drizzle/adapter.test.ts +425 -0
- package/src/drizzle/adapter.ts +474 -0
- package/src/drizzle/index.ts +31 -0
- package/src/drizzle/pg/adapter.integration-test.ts +456 -0
- package/src/drizzle/pg/index.ts +13 -0
- package/src/drizzle/pg/pg.integration-test.ts +1539 -0
- package/src/drizzle/pg/schema.ts +539 -0
- package/src/drizzle/pg-error-map.test.ts +218 -0
- package/src/drizzle/pg-error-map.ts +151 -0
- package/src/drizzle/schema.ts +544 -0
- package/src/drizzle/sqlite-serialization.test.ts +106 -0
- package/src/express/express-api-key-user-routes.test.ts +241 -0
- package/src/express/express.test.ts +442 -0
- package/src/express/handle.ts +191 -0
- package/src/express/index.ts +62 -0
- package/src/express/middleware.ts +551 -0
- package/src/express/protect.ts +154 -0
- package/src/express/validated.test.ts +86 -0
- package/src/express/validated.ts +99 -0
- package/src/fetcher/index.ts +81 -0
- package/src/hono/convert-routes.test.ts +220 -0
- package/src/hono/convert-routes.ts +196 -0
- package/src/hono/converters.test.ts +50 -0
- package/src/hono/converters.ts +43 -0
- package/src/hono/handle.ts +79 -0
- package/src/hono/helpers.ts +2 -0
- package/src/hono/hono-api-key-user-routes.test.ts +225 -0
- package/src/hono/hono-handlers.test.ts +861 -0
- package/src/hono/hono.test.ts +454 -0
- package/src/hono/index.ts +101 -0
- package/src/hono/middleware/auth.ts +236 -0
- package/src/hono/middleware/auth.types.test.ts +94 -0
- package/src/hono/middleware/csrf.test.ts +127 -0
- package/src/hono/middleware/csrf.ts +68 -0
- package/src/hono/middleware/error-handler.ts +12 -0
- package/src/hono/middleware/plugin-middleware.ts +35 -0
- package/src/hono/middleware/rbac.ts +131 -0
- package/src/hono/middleware/security-headers.test.ts +88 -0
- package/src/hono/middleware/security-headers.ts +68 -0
- package/src/hono/openapi.ts +237 -0
- package/src/hono/protect.ts +87 -0
- package/src/hono/validated.test.ts +123 -0
- package/src/hono/validated.ts +62 -0
- package/src/index.ts +309 -0
- package/src/integration.test.ts +718 -0
- package/src/internal/changelog.ts +56 -0
- package/src/otel/index.ts +158 -0
- package/src/otel/otel-types.test.ts +35 -0
- package/src/otel/otel.test.ts +235 -0
- package/src/plugins/account-lockout/account-lockout.test.ts +367 -0
- package/src/plugins/account-lockout/index.ts +267 -0
- package/src/plugins/admin/admin-api-key.test.ts +438 -0
- package/src/plugins/admin/index.ts +1612 -0
- package/src/plugins/api-key/api-key.test.ts +684 -0
- package/src/plugins/api-key/core.ts +336 -0
- package/src/plugins/api-key/index.ts +315 -0
- package/src/plugins/audit-log/audit-log.test.ts +749 -0
- package/src/plugins/audit-log/index.ts +680 -0
- package/src/plugins/data-isolation/data-isolation.test.ts +197 -0
- package/src/plugins/data-isolation/index.ts +157 -0
- package/src/plugins/email-verification/email-verification.test.ts +199 -0
- package/src/plugins/email-verification/index.ts +190 -0
- package/src/plugins/magic-link/index.ts +129 -0
- package/src/plugins/magic-link/magic-link.test.ts +156 -0
- package/src/plugins/oauth/id-token.ts +86 -0
- package/src/plugins/oauth/index.ts +2145 -0
- package/src/plugins/oauth/jwks.test.ts +32 -0
- package/src/plugins/oauth/jwks.ts +182 -0
- package/src/plugins/oauth/oauth.test.ts +2220 -0
- package/src/plugins/oauth/pkce.ts +58 -0
- package/src/plugins/openapi/index.ts +298 -0
- package/src/plugins/openapi/openapi.test.ts +425 -0
- package/src/plugins/openapi/spec-builder.ts +287 -0
- package/src/plugins/rate-limit/express.test.ts +89 -0
- package/src/plugins/rate-limit/express.ts +86 -0
- package/src/plugins/rate-limit/hono.test.ts +60 -0
- package/src/plugins/rate-limit/hono.ts +74 -0
- package/src/plugins/rate-limit/index.ts +383 -0
- package/src/plugins/rate-limit/memory-store.ts +61 -0
- package/src/plugins/rate-limit/rate-limit.test.ts +756 -0
- package/src/plugins/rate-limit/sveltekit.test.ts +58 -0
- package/src/plugins/rate-limit/sveltekit.ts +66 -0
- package/src/plugins/social-login/index.ts +715 -0
- package/src/plugins/social-login/providers/apple.ts +34 -0
- package/src/plugins/social-login/providers/discord.ts +26 -0
- package/src/plugins/social-login/providers/github.ts +54 -0
- package/src/plugins/social-login/providers/google.ts +23 -0
- package/src/plugins/social-login/providers/index.ts +22 -0
- package/src/plugins/social-login/providers/microsoft.ts +35 -0
- package/src/plugins/social-login/providers/oidc.ts +37 -0
- package/src/plugins/social-login/providers/providers.test.ts +211 -0
- package/src/plugins/social-login/social-login.test.ts +675 -0
- package/src/plugins/social-login/types.ts +80 -0
- package/src/plugins/tenancy/index.ts +544 -0
- package/src/plugins/tenancy/tenancy.test.ts +323 -0
- package/src/plugins/two-factor/index.ts +569 -0
- package/src/plugins/two-factor/two-factor.test.ts +235 -0
- package/src/plugins/webauthn/index.ts +554 -0
- package/src/plugins/webauthn/webauthn.test.ts +472 -0
- package/src/plugins/webhook/builtin-events.ts +29 -0
- package/src/plugins/webhook/delivery.test.ts +51 -0
- package/src/plugins/webhook/delivery.ts +154 -0
- package/src/plugins/webhook/hooks.ts +89 -0
- package/src/plugins/webhook/index.ts +445 -0
- package/src/plugins/webhook/queue/database.ts +67 -0
- package/src/plugins/webhook/queue/in-memory.test.ts +48 -0
- package/src/plugins/webhook/queue/in-memory.ts +71 -0
- package/src/plugins/webhook/queue/types.ts +51 -0
- package/src/plugins/webhook/registry.test.ts +48 -0
- package/src/plugins/webhook/registry.ts +64 -0
- package/src/plugins/webhook/signing.ts +45 -0
- package/src/plugins/webhook/ssrf.ts +198 -0
- package/src/plugins/webhook/types.ts +138 -0
- package/src/plugins/webhook/webhook.test.ts +324 -0
- package/src/sveltekit/actions.ts +233 -0
- package/src/sveltekit/catch-all.ts +43 -0
- package/src/sveltekit/cookies.ts +136 -0
- package/src/sveltekit/handle.ts +356 -0
- package/src/sveltekit/helpers.ts +69 -0
- package/src/sveltekit/index.ts +74 -0
- package/src/sveltekit/protect.ts +80 -0
- package/src/sveltekit/sveltekit-types.test.ts +31 -0
- package/src/sveltekit/sveltekit.test.ts +799 -0
- package/src/sveltekit/types.ts +134 -0
- package/src/sveltekit/validated.test.ts +117 -0
- package/src/sveltekit/validated.ts +78 -0
- package/src/testing/adapter-conformance.test.ts +408 -0
- package/src/testing/checks.test.ts +124 -0
- package/src/testing/checks.ts +304 -0
- package/src/testing/index.ts +107 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import type { EndpointDefinition } from '../endpoint';
|
|
2
|
+
import type { FortressPlugin } from '../plugin';
|
|
3
|
+
import type { ProtectedRouteContext } from './protect';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
import { createTestAdapter } from '../../testing';
|
|
6
|
+
import { createFortress } from '../fortress';
|
|
7
|
+
import { buildRouteManifest } from '../manifest/route-manifest';
|
|
8
|
+
import { endpoint, id, int, obj, str } from '../schema-builder';
|
|
9
|
+
import { protect } from './protect';
|
|
10
|
+
|
|
11
|
+
/** Compile-time assertion that two types are identical. */
|
|
12
|
+
type Expect<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
|
|
13
|
+
|
|
14
|
+
const secret = 'test-secret-minimum-32-bytes-long!!';
|
|
15
|
+
|
|
16
|
+
function testEndpoint(): EndpointDefinition {
|
|
17
|
+
return {
|
|
18
|
+
method: 'POST',
|
|
19
|
+
path: '/host/things/:id',
|
|
20
|
+
handler: 'createHostThing',
|
|
21
|
+
meta: { summary: 'Create host thing', security: ['none'] },
|
|
22
|
+
input: {
|
|
23
|
+
body: obj({ name: str() }, 'name'),
|
|
24
|
+
bodySchema: obj({ name: str() }, 'name'),
|
|
25
|
+
query: obj({ draft: str() }),
|
|
26
|
+
querySchema: obj({ draft: str() }),
|
|
27
|
+
params: obj({ id: id() }, 'id'),
|
|
28
|
+
paramsSchema: obj({ id: id() }, 'id'),
|
|
29
|
+
},
|
|
30
|
+
responses: { 201: { description: 'Created', schema: obj({ ok: str() }, 'ok') } },
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('protect()', () => {
|
|
35
|
+
it('runs plugin middleware, validates/coerces input, and calls the host handler', async () => {
|
|
36
|
+
const calls: string[] = [];
|
|
37
|
+
const ep = testEndpoint();
|
|
38
|
+
const plugin: FortressPlugin = {
|
|
39
|
+
name: 'host-routes',
|
|
40
|
+
routes: { createHostThing: ep },
|
|
41
|
+
middleware: [
|
|
42
|
+
{
|
|
43
|
+
path: '/host/things/:id',
|
|
44
|
+
position: 'before-auth',
|
|
45
|
+
handler: async (_ctx, _request, next) => {
|
|
46
|
+
calls.push('before-auth');
|
|
47
|
+
await next();
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
path: '/host/things/:id',
|
|
52
|
+
position: 'after-auth',
|
|
53
|
+
handler: async (_ctx, _request, next) => {
|
|
54
|
+
calls.push('after-auth');
|
|
55
|
+
await next();
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
path: '/host/things/:id',
|
|
60
|
+
position: 'after-rbac',
|
|
61
|
+
handler: async (_ctx, _request, next) => {
|
|
62
|
+
calls.push('after-rbac');
|
|
63
|
+
await next();
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
};
|
|
68
|
+
const fortress = createFortress({
|
|
69
|
+
database: createTestAdapter(),
|
|
70
|
+
jwt: { key: secret },
|
|
71
|
+
csrf: { enabled: false },
|
|
72
|
+
plugins: [plugin],
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const handler = protect(fortress, 'createHostThing', (ctx) => {
|
|
76
|
+
expect(ctx.manifest.classification).toBe('public');
|
|
77
|
+
expect(ctx.input).toEqual({ name: 'alpha', draft: 'true', id: '123' });
|
|
78
|
+
expect(ctx.params).toEqual({ id: '123' });
|
|
79
|
+
return { ok: 'yes' };
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const res = await handler(new Request('http://localhost/host/things/123?draft=true', {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: { 'content-type': 'application/json' },
|
|
85
|
+
body: JSON.stringify({ name: 'alpha' }),
|
|
86
|
+
}));
|
|
87
|
+
|
|
88
|
+
expect(res.status).toBe(201);
|
|
89
|
+
expect(await res.json()).toEqual({ ok: 'yes' });
|
|
90
|
+
expect(calls).toEqual(['before-auth', 'after-auth', 'after-rbac']);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('authenticates JWTs and enforces RBAC for permission-only host handlers', async () => {
|
|
94
|
+
const ep = {
|
|
95
|
+
method: 'GET',
|
|
96
|
+
path: '/reports/:id',
|
|
97
|
+
handler: 'readReport',
|
|
98
|
+
meta: {
|
|
99
|
+
summary: 'Read report',
|
|
100
|
+
permission: { resource: 'report', action: 'read' },
|
|
101
|
+
},
|
|
102
|
+
input: {
|
|
103
|
+
params: obj({ id: id() }, 'id'),
|
|
104
|
+
paramsSchema: obj({ id: id() }, 'id'),
|
|
105
|
+
},
|
|
106
|
+
responses: { 200: { description: 'OK' } },
|
|
107
|
+
} as EndpointDefinition;
|
|
108
|
+
const checked: unknown[] = [];
|
|
109
|
+
const fortress = {
|
|
110
|
+
endpoints: [ep],
|
|
111
|
+
config: { plugins: [], csrf: { enabled: false }, database: createTestAdapter() },
|
|
112
|
+
cookies: { accessName: 'fortress_access', refreshName: 'fortress_refresh' },
|
|
113
|
+
get manifest() { return buildRouteManifest(this as any); },
|
|
114
|
+
auth: {
|
|
115
|
+
verifyToken: async () => ({
|
|
116
|
+
sub: '7',
|
|
117
|
+
subjectType: 'USER',
|
|
118
|
+
name: 'User',
|
|
119
|
+
groups: [],
|
|
120
|
+
iss: 'test',
|
|
121
|
+
iat: 1,
|
|
122
|
+
exp: 2,
|
|
123
|
+
}),
|
|
124
|
+
},
|
|
125
|
+
iam: {
|
|
126
|
+
checkPermission: async (...args: unknown[]) => {
|
|
127
|
+
checked.push(args);
|
|
128
|
+
return true;
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
extractAccessToken: (request: Request) => request.headers.get('authorization')?.replace('Bearer ', '') ?? null,
|
|
132
|
+
serializeAuthCookies: () => [],
|
|
133
|
+
logger: undefined,
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const handler = protect(fortress as any, ep, ctx => ({ subject: ctx.subject, input: ctx.input }));
|
|
137
|
+
const res = await handler(new Request('http://localhost/reports/42', {
|
|
138
|
+
headers: { Authorization: 'Bearer token' },
|
|
139
|
+
}));
|
|
140
|
+
|
|
141
|
+
expect(res.status).toBe(200);
|
|
142
|
+
expect(await res.json()).toEqual({
|
|
143
|
+
subject: { type: 'USER', id: '7' },
|
|
144
|
+
input: { id: '42' },
|
|
145
|
+
});
|
|
146
|
+
expect(checked).toHaveLength(1);
|
|
147
|
+
expect(checked[0]).toEqual([{ type: 'USER', id: '7' }, 'report', 'read', { credentialScopes: undefined }]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('attaches auth cookies for token-shaped handler results', async () => {
|
|
151
|
+
const ep = testEndpoint();
|
|
152
|
+
const fortress = createFortress({
|
|
153
|
+
database: createTestAdapter(),
|
|
154
|
+
jwt: { key: secret },
|
|
155
|
+
csrf: { enabled: false },
|
|
156
|
+
plugins: [{ name: 'host-routes', routes: { createHostThing: ep } }],
|
|
157
|
+
cookies: { secure: false },
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const handler = protect(fortress, ep, () => ({ accessToken: 'access', refreshToken: 'refresh' }));
|
|
161
|
+
const res = await handler(new Request('http://localhost/host/things/1', {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
headers: { 'content-type': 'application/json' },
|
|
164
|
+
body: JSON.stringify({ name: 'alpha' }),
|
|
165
|
+
}));
|
|
166
|
+
|
|
167
|
+
expect(res.headers.getSetCookie().length).toBeGreaterThanOrEqual(2);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('exposes ctx.respond for typed non-success status returns', async () => {
|
|
171
|
+
const ep = testEndpoint();
|
|
172
|
+
const fortress = createFortress({
|
|
173
|
+
database: createTestAdapter(),
|
|
174
|
+
jwt: { key: secret },
|
|
175
|
+
csrf: { enabled: false },
|
|
176
|
+
plugins: [{ name: 'host-routes', routes: { createHostThing: ep } }],
|
|
177
|
+
cookies: { secure: false },
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const handler = protect(fortress, ep, (ctx) => {
|
|
181
|
+
return ctx.respond(404, { code: 'NOT_FOUND', message: 'no thing', statusCode: 404 } as any);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const res = await handler(new Request('http://localhost/host/things/1', {
|
|
185
|
+
method: 'POST',
|
|
186
|
+
headers: { 'content-type': 'application/json' },
|
|
187
|
+
body: JSON.stringify({ name: 'alpha' }),
|
|
188
|
+
}));
|
|
189
|
+
|
|
190
|
+
expect(res.status).toBe(404);
|
|
191
|
+
expect(await res.json()).toEqual({ code: 'NOT_FOUND', message: 'no thing', statusCode: 404 });
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('narrows ctx.body to non-optional T when a body schema is declared (type-level)', () => {
|
|
195
|
+
const withBody = endpoint('POST', '/things')
|
|
196
|
+
.body(obj({ name: str() }, 'name'))
|
|
197
|
+
.response(201, 'Created', obj({ ok: str() }, 'ok'))
|
|
198
|
+
.handler('createThing')
|
|
199
|
+
.build();
|
|
200
|
+
const noBody = endpoint('GET', '/things/:id')
|
|
201
|
+
.params(obj({ id: int() }, 'id'))
|
|
202
|
+
.response(200, 'OK', obj({ ok: str() }, 'ok'))
|
|
203
|
+
.handler('getThing')
|
|
204
|
+
.build();
|
|
205
|
+
|
|
206
|
+
type WithBodyCtx = ProtectedRouteContext<typeof withBody>;
|
|
207
|
+
type NoBodyCtx = ProtectedRouteContext<typeof noBody>;
|
|
208
|
+
|
|
209
|
+
// Declared body schema → non-optional `{ name: string }` (no `| undefined`).
|
|
210
|
+
const _bodyNonOptional: Expect<WithBodyCtx['body'], { name: string }> = true;
|
|
211
|
+
// No body schema → loose `unknown`.
|
|
212
|
+
const _bodyLoose: Expect<NoBodyCtx['body'], unknown> = true;
|
|
213
|
+
|
|
214
|
+
// Touch the runtime values so they aren't flagged as type-only.
|
|
215
|
+
expect(withBody.handler).toBe('createThing');
|
|
216
|
+
expect(noBody.handler).toBe('getThing');
|
|
217
|
+
expect(_bodyNonOptional).toBe(true);
|
|
218
|
+
expect(_bodyLoose).toBe(true);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-owned route protection.
|
|
3
|
+
*
|
|
4
|
+
* Lets adapters and framework-less hosts run the same Fortress security
|
|
5
|
+
* pipeline used by `fortress.handleRequest()` around handlers they register
|
|
6
|
+
* themselves: plugin middleware, CSRF, principal resolution, RBAC,
|
|
7
|
+
* validation, and auth-cookie attachment.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
EndpointDefinition,
|
|
12
|
+
InferEndpointBody,
|
|
13
|
+
InferEndpointCallInput,
|
|
14
|
+
InferEndpointParams,
|
|
15
|
+
InferEndpointQuery,
|
|
16
|
+
InferEndpointResponses,
|
|
17
|
+
} from '../endpoint';
|
|
18
|
+
import type { Fortress } from '../fortress';
|
|
19
|
+
import type { RouteManifestEntry } from '../manifest/route-manifest';
|
|
20
|
+
import type { Subject, TokenClaims } from '../types';
|
|
21
|
+
import { Errors, FortressError } from '../errors';
|
|
22
|
+
import { coerceBySchema, validateRequest } from '../validation';
|
|
23
|
+
import { enforceCsrf, resolveCsrfConfig } from './csrf';
|
|
24
|
+
import { errorToResponse, withCookies } from './error-response';
|
|
25
|
+
import { enforceFortressPermission } from './fortress-rbac';
|
|
26
|
+
import { buildRouteTable, matchRoute } from './match';
|
|
27
|
+
import { runPluginMiddleware } from './plugin-middleware';
|
|
28
|
+
import { tryPluginPrincipal } from './principal';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* What you point `protect()` at: an `EndpointDefinition` (preferred — its
|
|
32
|
+
* phantom `<TBody, TQuery, TParams, TResponses>` types flow into the
|
|
33
|
+
* `ProtectedRouteContext`) or a handler name from `fortress.endpoints`
|
|
34
|
+
* (looser typing, falls back to `Record<string, unknown>` / `unknown`).
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
export type ProtectedRouteTarget<E extends EndpointDefinition<any, any, any, any> = EndpointDefinition>
|
|
38
|
+
= string | E;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Widen `{}` (the `EndpointDefinition` default for an unspecified input slot)
|
|
42
|
+
* back to `Record<string, unknown>` so adapter callers that pass a string
|
|
43
|
+
* target — or an untyped `EndpointDefinition` — keep the loose ergonomics
|
|
44
|
+
* they had before generics existed. A real declared schema produces a
|
|
45
|
+
* narrow object type, which passes through unchanged.
|
|
46
|
+
*/
|
|
47
|
+
type WidenObj<T> = [keyof T] extends [never] ? Record<string, unknown> : T;
|
|
48
|
+
|
|
49
|
+
export interface ProtectOptions {
|
|
50
|
+
/**
|
|
51
|
+
* Canonical path to use for plugin middleware / CSRF matching when the
|
|
52
|
+
* host-owned route is mounted at a path different from the endpoint path.
|
|
53
|
+
* Defaults to the incoming request pathname.
|
|
54
|
+
*/
|
|
55
|
+
path?: string;
|
|
56
|
+
/** Override HTTP method for manifest lookup. Defaults to request.method. */
|
|
57
|
+
method?: string;
|
|
58
|
+
/** Explicit params for host paths that do not match the endpoint path. */
|
|
59
|
+
params?: Record<string, string>;
|
|
60
|
+
/** Attach auth cookies when the handler returns `{ accessToken, refreshToken? }`. Default `true`. */
|
|
61
|
+
attachAuthCookies?: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Per-request context handed to the host callback. When `target` is a
|
|
66
|
+
* typed `EndpointDefinition`, `body` / `query` / `params` / `input` are
|
|
67
|
+
* inferred from its phantom generics; when `target` is a handler name
|
|
68
|
+
* string, they fall back to `Record<string, unknown>` / `unknown`.
|
|
69
|
+
*/
|
|
70
|
+
export interface ProtectedRouteContext<
|
|
71
|
+
|
|
72
|
+
E extends EndpointDefinition<any, any, any, any> = EndpointDefinition,
|
|
73
|
+
> {
|
|
74
|
+
request: Request;
|
|
75
|
+
/** The resolved endpoint definition (typed when the target was typed). */
|
|
76
|
+
endpoint: E;
|
|
77
|
+
manifest: RouteManifestEntry;
|
|
78
|
+
subject?: Subject;
|
|
79
|
+
userId?: string;
|
|
80
|
+
claims?: TokenClaims;
|
|
81
|
+
scopes?: string[] | null;
|
|
82
|
+
params: WidenObj<InferEndpointParams<E>>;
|
|
83
|
+
query: WidenObj<InferEndpointQuery<E>>;
|
|
84
|
+
/**
|
|
85
|
+
* Parsed request body.
|
|
86
|
+
*
|
|
87
|
+
* When the endpoint declares a body schema, the handler only runs after
|
|
88
|
+
* that body passes Standard Schema validation, so `body` is narrowed to a
|
|
89
|
+
* non-optional `T` — use it directly without `!` or reaching for
|
|
90
|
+
* `ctx.input`. Endpoints with no declared body schema fall back to the
|
|
91
|
+
* loose `unknown` (which still admits `undefined`), matching the
|
|
92
|
+
* pre-generics ergonomics for string / untyped targets.
|
|
93
|
+
*/
|
|
94
|
+
body: [keyof InferEndpointBody<E>] extends [never]
|
|
95
|
+
? unknown
|
|
96
|
+
: InferEndpointBody<E>;
|
|
97
|
+
/**
|
|
98
|
+
* Merged `{ ...body, ...query, ...params }` input after URL coercion.
|
|
99
|
+
* Typed as the same intersection the `fortress.call.*` proxy uses.
|
|
100
|
+
*/
|
|
101
|
+
input: [keyof InferEndpointCallInput<E>] extends [never]
|
|
102
|
+
? Record<string, unknown>
|
|
103
|
+
: InferEndpointCallInput<E>;
|
|
104
|
+
/**
|
|
105
|
+
* Build a typed JSON response for a status declared on the endpoint.
|
|
106
|
+
*
|
|
107
|
+
* When `target` was a typed `EndpointDefinition`, `status` is narrowed
|
|
108
|
+
* to the response codes that endpoint actually declares and `body` is
|
|
109
|
+
* narrowed to that response's schema output. Use this instead of
|
|
110
|
+
* hand-rolling `new Response(JSON.stringify(...), { status })` for
|
|
111
|
+
* non-2xx returns:
|
|
112
|
+
*
|
|
113
|
+
* ```ts
|
|
114
|
+
* protectedRoute(fortress, getSchool, async (ctx) => {
|
|
115
|
+
* const school = await loadSchool(ctx.params.id);
|
|
116
|
+
* if (!school)
|
|
117
|
+
* return ctx.respond(404, { code: 'NOT_FOUND', message: 'School not found', statusCode: 404 });
|
|
118
|
+
* return { data: school }; // 2xx success returned as a plain object
|
|
119
|
+
* });
|
|
120
|
+
* ```
|
|
121
|
+
*
|
|
122
|
+
* For string-target / untyped endpoints, both arguments fall back to
|
|
123
|
+
* `number` / `unknown`, matching the loose ergonomics elsewhere in the
|
|
124
|
+
* context.
|
|
125
|
+
*/
|
|
126
|
+
respond: [keyof InferEndpointResponses<E>] extends [never]
|
|
127
|
+
? (status: number, body: unknown) => Response
|
|
128
|
+
: <S extends keyof InferEndpointResponses<E> & number>(
|
|
129
|
+
status: S,
|
|
130
|
+
body: InferEndpointResponses<E>[S],
|
|
131
|
+
) => Response;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export type ProtectedRouteHandler<
|
|
135
|
+
|
|
136
|
+
E extends EndpointDefinition<any, any, any, any> = EndpointDefinition,
|
|
137
|
+
TResult = unknown,
|
|
138
|
+
> = (
|
|
139
|
+
ctx: ProtectedRouteContext<E>,
|
|
140
|
+
) => TResult | Response | Promise<TResult | Response>;
|
|
141
|
+
|
|
142
|
+
function targetLabel(target: ProtectedRouteTarget): string {
|
|
143
|
+
return typeof target === 'string' ? target : `${target.method} ${target.path}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function routeKey(route: Pick<EndpointDefinition, 'method' | 'path'>): string {
|
|
147
|
+
return `${route.method.toUpperCase()} ${route.path}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function findEndpoint(fortress: Fortress, target: ProtectedRouteTarget, method?: string): EndpointDefinition {
|
|
151
|
+
if (typeof target !== 'string') {
|
|
152
|
+
return target;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const matches = fortress.endpoints.filter(endpoint => endpoint.handler === target);
|
|
156
|
+
if (matches.length === 0)
|
|
157
|
+
throw Errors.notFound(`No endpoint found for handler '${target}'`);
|
|
158
|
+
|
|
159
|
+
if (method) {
|
|
160
|
+
const byMethod = matches.find(endpoint => endpoint.method.toUpperCase() === method.toUpperCase());
|
|
161
|
+
if (byMethod)
|
|
162
|
+
return byMethod;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (matches.length > 1) {
|
|
166
|
+
throw Errors.badRequest(
|
|
167
|
+
`Handler '${target}' maps to multiple endpoints; pass the EndpointDefinition or ProtectOptions.method.`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return matches[0];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function findManifestEntry(fortress: Fortress, endpoint: EndpointDefinition): RouteManifestEntry {
|
|
175
|
+
const key = routeKey(endpoint);
|
|
176
|
+
const entry = fortress.manifest.find(route => `${route.method.toUpperCase()} ${route.path}` === key);
|
|
177
|
+
if (!entry)
|
|
178
|
+
throw Errors.notFound(`No route manifest entry found for ${key}`);
|
|
179
|
+
return entry;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function rewriteRequestPath(request: Request, pathname: string): Request {
|
|
183
|
+
const current = new URL(request.url);
|
|
184
|
+
if (current.pathname === pathname)
|
|
185
|
+
return request;
|
|
186
|
+
const rewritten = new URL(request.url);
|
|
187
|
+
rewritten.pathname = pathname;
|
|
188
|
+
return new Request(rewritten, request);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function parseJsonBody(request: Request): Promise<unknown> {
|
|
192
|
+
if (request.method === 'GET' || request.method === 'HEAD')
|
|
193
|
+
return undefined;
|
|
194
|
+
if (!(request.headers.get('content-type') ?? '').includes('json'))
|
|
195
|
+
return undefined;
|
|
196
|
+
return request.clone().json().catch(() => undefined);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function objectOrEmpty(value: unknown): Record<string, unknown> {
|
|
200
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
201
|
+
? value as Record<string, unknown>
|
|
202
|
+
: {};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function successStatus(endpoint: EndpointDefinition): number {
|
|
206
|
+
const status = Object.keys(endpoint.responses ?? {})
|
|
207
|
+
.map(Number)
|
|
208
|
+
.find(code => code >= 200 && code < 300);
|
|
209
|
+
return status ?? 200;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function jsonResponse(body: unknown, status: number): Response {
|
|
213
|
+
return new Response(JSON.stringify(body ?? { ok: true }), {
|
|
214
|
+
status,
|
|
215
|
+
headers: { 'Content-Type': 'application/json' },
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function maybeAttachAuthCookies(fortress: Fortress, result: Response | unknown, response: Response): Response {
|
|
220
|
+
if (result instanceof Response)
|
|
221
|
+
return response;
|
|
222
|
+
const obj = result as { accessToken?: unknown; refreshToken?: unknown } | undefined;
|
|
223
|
+
if (typeof obj?.accessToken !== 'string')
|
|
224
|
+
return response;
|
|
225
|
+
return withCookies(response, fortress.serializeAuthCookies({
|
|
226
|
+
accessToken: obj.accessToken,
|
|
227
|
+
refreshToken: typeof obj.refreshToken === 'string' ? obj.refreshToken : null,
|
|
228
|
+
}));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Build a web-standard protected route handler for a host-owned route.
|
|
233
|
+
* Adapter-specific helpers wrap this function for Hono, Express, and
|
|
234
|
+
* SvelteKit ergonomics.
|
|
235
|
+
*
|
|
236
|
+
* Two overloads:
|
|
237
|
+
*
|
|
238
|
+
* - **Typed target** — pass an `EndpointDefinition` (e.g. the value produced
|
|
239
|
+
* by `endpoint(...).build()`). Its `<TBody, TQuery, TParams, TResponses>`
|
|
240
|
+
* phantoms flow into `ctx.body` / `ctx.query` / `ctx.params` / `ctx.input`.
|
|
241
|
+
* - **String target** — pass a unique `handler` name. Inputs fall back to
|
|
242
|
+
* `Record<string, unknown>` / `unknown`, matching pre-0.2.x behaviour.
|
|
243
|
+
*
|
|
244
|
+
* The runtime is identical in both forms; this is purely typing.
|
|
245
|
+
*/
|
|
246
|
+
export function protect<
|
|
247
|
+
|
|
248
|
+
E extends EndpointDefinition<any, any, any, any>,
|
|
249
|
+
TResult = unknown,
|
|
250
|
+
>(
|
|
251
|
+
fortress: Fortress,
|
|
252
|
+
target: E,
|
|
253
|
+
handler: ProtectedRouteHandler<E, TResult>,
|
|
254
|
+
options?: ProtectOptions,
|
|
255
|
+
): (request: Request) => Promise<Response>;
|
|
256
|
+
export function protect<TResult = unknown>(
|
|
257
|
+
fortress: Fortress,
|
|
258
|
+
target: string,
|
|
259
|
+
handler: ProtectedRouteHandler<EndpointDefinition, TResult>,
|
|
260
|
+
options?: ProtectOptions,
|
|
261
|
+
): (request: Request) => Promise<Response>;
|
|
262
|
+
export function protect(
|
|
263
|
+
fortress: Fortress,
|
|
264
|
+
target: ProtectedRouteTarget,
|
|
265
|
+
|
|
266
|
+
handler: ProtectedRouteHandler<any, unknown>,
|
|
267
|
+
options: ProtectOptions = {},
|
|
268
|
+
): (request: Request) => Promise<Response> {
|
|
269
|
+
const endpoint = findEndpoint(fortress, target, options.method);
|
|
270
|
+
const manifest = findManifestEntry(fortress, endpoint);
|
|
271
|
+
const endpointRouteTable = buildRouteTable([endpoint]);
|
|
272
|
+
const csrfConfig = resolveCsrfConfig(fortress.config.csrf);
|
|
273
|
+
const plugins = fortress.config.plugins ?? [];
|
|
274
|
+
|
|
275
|
+
return async function protectedRequestHandler(request: Request): Promise<Response> {
|
|
276
|
+
let response: Response | undefined;
|
|
277
|
+
try {
|
|
278
|
+
const url = new URL(request.url);
|
|
279
|
+
const pipelinePath = options.path ?? url.pathname;
|
|
280
|
+
const pipelineRequest = rewriteRequestPath(request, pipelinePath);
|
|
281
|
+
|
|
282
|
+
await runPluginMiddleware(plugins, fortress.config, 'before-auth', { request: pipelineRequest });
|
|
283
|
+
enforceCsrf(pipelineRequest, pipelinePath, csrfConfig, fortress.cookies);
|
|
284
|
+
|
|
285
|
+
let subject: Subject | undefined;
|
|
286
|
+
let userId: string | undefined;
|
|
287
|
+
let claims: TokenClaims | undefined;
|
|
288
|
+
let scopes: string[] | null | undefined;
|
|
289
|
+
const selfManagedBearer = endpoint.meta?.bearerKind === 'oauth';
|
|
290
|
+
|
|
291
|
+
if (!selfManagedBearer) {
|
|
292
|
+
const pluginResolved = await tryPluginPrincipal(fortress, pipelineRequest);
|
|
293
|
+
if (pluginResolved) {
|
|
294
|
+
subject = pluginResolved.subject;
|
|
295
|
+
claims = pluginResolved.claims;
|
|
296
|
+
scopes = pluginResolved.scopes;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const requiresBearer = !selfManagedBearer
|
|
301
|
+
&& ((endpoint.meta?.security?.includes('bearer') ?? false) || !!endpoint.meta?.permission);
|
|
302
|
+
if (!subject && requiresBearer) {
|
|
303
|
+
const token = fortress.extractAccessToken(pipelineRequest);
|
|
304
|
+
if (!token)
|
|
305
|
+
throw Errors.unauthorized('Missing access token');
|
|
306
|
+
try {
|
|
307
|
+
claims = await fortress.auth.verifyToken(token);
|
|
308
|
+
subject = { type: claims.subjectType, id: claims.sub };
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
if (err instanceof FortressError)
|
|
312
|
+
throw err;
|
|
313
|
+
throw Errors.unauthorized('Invalid access token');
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (subject?.type === 'USER')
|
|
318
|
+
userId = subject.id;
|
|
319
|
+
|
|
320
|
+
await runPluginMiddleware(plugins, fortress.config, 'after-auth', {
|
|
321
|
+
request: pipelineRequest,
|
|
322
|
+
fortressSubject: subject,
|
|
323
|
+
fortressUserId: userId,
|
|
324
|
+
fortressClaims: claims,
|
|
325
|
+
fortressScopes: scopes,
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
if (!selfManagedBearer) {
|
|
329
|
+
await enforceFortressPermission(endpoint, subject, {
|
|
330
|
+
checkPermission: (subj, resource, action, credentialScopes): Promise<boolean> =>
|
|
331
|
+
fortress.iam.checkPermission(subj, resource, action, { credentialScopes }),
|
|
332
|
+
}, scopes);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
await runPluginMiddleware(plugins, fortress.config, 'after-rbac', {
|
|
336
|
+
request: pipelineRequest,
|
|
337
|
+
fortressSubject: subject,
|
|
338
|
+
fortressUserId: userId,
|
|
339
|
+
fortressClaims: claims,
|
|
340
|
+
fortressScopes: scopes,
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const body = await parseJsonBody(request);
|
|
344
|
+
const rawQuery = Object.fromEntries(url.searchParams);
|
|
345
|
+
const match = matchRoute(endpointRouteTable, endpoint.method, options.path ?? url.pathname);
|
|
346
|
+
const rawParams = options.params ?? match?.params ?? {};
|
|
347
|
+
const query = coerceBySchema(endpoint.input?.query, rawQuery) ?? rawQuery;
|
|
348
|
+
const params = coerceBySchema(endpoint.input?.params, rawParams) ?? rawParams;
|
|
349
|
+
await validateRequest(endpoint.input, { body, query, params });
|
|
350
|
+
|
|
351
|
+
const input = { ...objectOrEmpty(body), ...query, ...params };
|
|
352
|
+
const result = await handler({
|
|
353
|
+
request,
|
|
354
|
+
endpoint,
|
|
355
|
+
manifest,
|
|
356
|
+
subject,
|
|
357
|
+
userId,
|
|
358
|
+
claims,
|
|
359
|
+
scopes,
|
|
360
|
+
params,
|
|
361
|
+
query,
|
|
362
|
+
body,
|
|
363
|
+
input,
|
|
364
|
+
respond: (status: number, responseBody: unknown) => jsonResponse(responseBody, status),
|
|
365
|
+
// Cast: the runtime values are untyped records; the generic context
|
|
366
|
+
// type narrows them for the caller, but the impl signature uses the
|
|
367
|
+
// loose `ProtectedRouteContext<EndpointDefinition>`.
|
|
368
|
+
} as ProtectedRouteContext);
|
|
369
|
+
|
|
370
|
+
response = result instanceof Response ? result : jsonResponse(result, successStatus(endpoint));
|
|
371
|
+
if (options.attachAuthCookies ?? true)
|
|
372
|
+
response = maybeAttachAuthCookies(fortress, result, response);
|
|
373
|
+
return response;
|
|
374
|
+
}
|
|
375
|
+
catch (err) {
|
|
376
|
+
response = errorToResponse(err, fortress.logger);
|
|
377
|
+
return response;
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Resolve an endpoint eagerly; useful for adapter wrappers and diagnostics. */
|
|
383
|
+
export function resolveProtectedEndpoint(
|
|
384
|
+
fortress: Fortress,
|
|
385
|
+
target: ProtectedRouteTarget,
|
|
386
|
+
method?: string,
|
|
387
|
+
): EndpointDefinition {
|
|
388
|
+
return findEndpoint(fortress, target, method);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Human-readable name for errors/logs. */
|
|
392
|
+
export function describeProtectedTarget(target: ProtectedRouteTarget): string {
|
|
393
|
+
return targetLabel(target);
|
|
394
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { resolveCookieConfig } from '../config';
|
|
3
|
+
import { extractAccessToken, extractRefreshToken } from './token-extraction';
|
|
4
|
+
|
|
5
|
+
const cookies = resolveCookieConfig({ secure: false }); // dev defaults: fortress_access / fortress_refresh
|
|
6
|
+
|
|
7
|
+
function req(headers: Record<string, string>): Request {
|
|
8
|
+
return new Request('http://localhost/', { headers });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('extractAccessToken', () => {
|
|
12
|
+
it('reads the access token from the configured cookie first', () => {
|
|
13
|
+
const r = req({ cookie: 'fortress_access=cookie-token' });
|
|
14
|
+
expect(extractAccessToken(r, cookies)).toBe('cookie-token');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('falls back to Authorization: Bearer when no cookie', () => {
|
|
18
|
+
const r = req({ authorization: 'Bearer header-token' });
|
|
19
|
+
expect(extractAccessToken(r, cookies)).toBe('header-token');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('accepts the case-insensitive Bearer authentication scheme', () => {
|
|
23
|
+
const r = req({ authorization: 'bearer lowercase-token' });
|
|
24
|
+
expect(extractAccessToken(r, cookies)).toBe('lowercase-token');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('prefers Authorization header over cookie (P3.7 — cookie-shadow fix)', () => {
|
|
28
|
+
const r = req({
|
|
29
|
+
cookie: 'fortress_access=cookie-token',
|
|
30
|
+
authorization: 'Bearer header-token',
|
|
31
|
+
});
|
|
32
|
+
expect(extractAccessToken(r, cookies)).toBe('header-token');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('returns null when neither source has a token', () => {
|
|
36
|
+
expect(extractAccessToken(req({}), cookies)).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('ignores non-Bearer Authorization headers', () => {
|
|
40
|
+
const r = req({ authorization: 'Basic dXNlcjpwYXNz' });
|
|
41
|
+
expect(extractAccessToken(r, cookies)).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('extractRefreshToken', () => {
|
|
46
|
+
it('reads the refresh token from the cookie jar', () => {
|
|
47
|
+
const r = req({ cookie: 'fortress_refresh=rtoken' });
|
|
48
|
+
expect(extractRefreshToken(r, cookies)).toBe('rtoken');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('returns null when no refresh cookie present', () => {
|
|
52
|
+
expect(extractRefreshToken(req({}), cookies)).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('does NOT fall back to Authorization header', () => {
|
|
56
|
+
const r = req({ authorization: 'Bearer some-token' });
|
|
57
|
+
expect(extractRefreshToken(r, cookies)).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
});
|