@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,17 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { normalizeEmail } from './email';
|
|
3
|
+
|
|
4
|
+
describe('normalizeEmail', () => {
|
|
5
|
+
it('lowercases the complete address', () => {
|
|
6
|
+
expect(normalizeEmail('User.Name@EXAMPLE.COM')).toBe('user.name@example.com');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('collapses canonically equivalent Unicode spellings with NFC', () => {
|
|
10
|
+
expect(normalizeEmail('E\u0301@Example.COM')).toBe('é@example.com');
|
|
11
|
+
expect(normalizeEmail('É@example.com')).toBe('é@example.com');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('does not silently trim invalid surrounding whitespace', () => {
|
|
15
|
+
expect(normalizeEmail(' User@Example.COM ')).toBe(' user@example.com ');
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical representation used for persisted and compared email identities.
|
|
3
|
+
*
|
|
4
|
+
* NFC collapses canonically-equivalent Unicode spellings while lowercase
|
|
5
|
+
* makes the product's explicitly case-insensitive email policy deterministic
|
|
6
|
+
* across database collations. Deliberately does not trim: surrounding
|
|
7
|
+
* whitespace is invalid input, not part of identity canonicalization.
|
|
8
|
+
*/
|
|
9
|
+
export function normalizeEmail(email: string): string {
|
|
10
|
+
return email.toLowerCase().normalize('NFC');
|
|
11
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import type { Fortress } from '../fortress';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { createTestAdapter } from '../../testing';
|
|
5
|
+
import { createFortress } from '../fortress';
|
|
6
|
+
import { assertSuccess } from '../types';
|
|
7
|
+
|
|
8
|
+
let fortress: Fortress;
|
|
9
|
+
const SECRET = 'hooks-test-secret-at-least-32chars!!';
|
|
10
|
+
|
|
11
|
+
async function seedUser(): Promise<{ id: string }> {
|
|
12
|
+
return fortress.auth.createUser({
|
|
13
|
+
email: 'hook-user@example.com',
|
|
14
|
+
name: 'Hook User',
|
|
15
|
+
password: 'password-123456',
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('plugin hooks', () => {
|
|
20
|
+
describe('beforeRegister', () => {
|
|
21
|
+
it('re-normalizes email after a hook mutates registration data', async () => {
|
|
22
|
+
const beforeRegister = vi.fn(async (ctx: { data: { email: string } }) => {
|
|
23
|
+
ctx.data.email = 'E\u0301.HOOK@EXAMPLE.COM';
|
|
24
|
+
});
|
|
25
|
+
fortress = createFortress({
|
|
26
|
+
jwt: { key: SECRET },
|
|
27
|
+
database: createTestAdapter(),
|
|
28
|
+
plugins: [{ name: 'test', hooks: { beforeRegister } }],
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const user = await fortress.auth.createUser({
|
|
32
|
+
email: 'original@example.com',
|
|
33
|
+
name: 'Hook User',
|
|
34
|
+
password: 'password-123456',
|
|
35
|
+
});
|
|
36
|
+
expect(user.email).toBe('é.hook@example.com');
|
|
37
|
+
expect((await fortress.auth.getLoginIdentifiers(user.id))[0].value).toBe('é.hook@example.com');
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('beforeLogout', () => {
|
|
42
|
+
it('is called before logout', async () => {
|
|
43
|
+
const beforeLogout = vi.fn(async () => {});
|
|
44
|
+
|
|
45
|
+
fortress = createFortress({
|
|
46
|
+
jwt: { key: SECRET },
|
|
47
|
+
database: createTestAdapter(),
|
|
48
|
+
plugins: [{ name: 'test', hooks: { beforeLogout } }],
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
await seedUser();
|
|
52
|
+
const login = await fortress.auth.login('hook-user@example.com', 'password-123456');
|
|
53
|
+
assertSuccess(login);
|
|
54
|
+
await fortress.auth.logout(login.refreshToken as string);
|
|
55
|
+
|
|
56
|
+
expect(beforeLogout).toHaveBeenCalledOnce();
|
|
57
|
+
expect(beforeLogout).toHaveBeenCalledWith(expect.objectContaining({
|
|
58
|
+
token: login.refreshToken,
|
|
59
|
+
}));
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('afterRegister', () => {
|
|
64
|
+
it('is called after user creation', async () => {
|
|
65
|
+
const afterRegister = vi.fn(async () => {});
|
|
66
|
+
|
|
67
|
+
fortress = createFortress({
|
|
68
|
+
jwt: { key: SECRET },
|
|
69
|
+
database: createTestAdapter(),
|
|
70
|
+
plugins: [{ name: 'test', hooks: { afterRegister } }],
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await fortress.auth.createUser({
|
|
74
|
+
email: 'new-user@example.com',
|
|
75
|
+
name: 'New User',
|
|
76
|
+
password: 'password-123456',
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
expect(afterRegister).toHaveBeenCalledOnce();
|
|
80
|
+
expect(afterRegister).toHaveBeenCalledWith(
|
|
81
|
+
expect.objectContaining({ responseHeaders: expect.any(Headers) }),
|
|
82
|
+
expect.objectContaining({ email: 'new-user@example.com' }),
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('receives the created user', async () => {
|
|
87
|
+
let receivedUser: unknown = null;
|
|
88
|
+
const afterRegister = vi.fn(async (_ctx: unknown, user: unknown) => {
|
|
89
|
+
receivedUser = user;
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
fortress = createFortress({
|
|
93
|
+
jwt: { key: SECRET },
|
|
94
|
+
database: createTestAdapter(),
|
|
95
|
+
plugins: [{ name: 'test', hooks: { afterRegister } }],
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
await fortress.auth.createUser({
|
|
99
|
+
email: 'check-user@example.com',
|
|
100
|
+
name: 'Check User',
|
|
101
|
+
password: 'password-123456',
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(receivedUser).toMatchObject({ email: 'check-user@example.com', name: 'Check User' });
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('beforeTokenRefresh', () => {
|
|
109
|
+
it('is called before token refresh', async () => {
|
|
110
|
+
const beforeTokenRefresh = vi.fn(async () => {});
|
|
111
|
+
|
|
112
|
+
fortress = createFortress({
|
|
113
|
+
jwt: { key: SECRET },
|
|
114
|
+
database: createTestAdapter(),
|
|
115
|
+
plugins: [{ name: 'test', hooks: { beforeTokenRefresh } }],
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
await seedUser();
|
|
119
|
+
const login = await fortress.auth.login('hook-user@example.com', 'password-123456');
|
|
120
|
+
assertSuccess(login);
|
|
121
|
+
await fortress.auth.refresh(login.refreshToken as string);
|
|
122
|
+
|
|
123
|
+
expect(beforeTokenRefresh).toHaveBeenCalledOnce();
|
|
124
|
+
expect(beforeTokenRefresh).toHaveBeenCalledWith(expect.objectContaining({
|
|
125
|
+
token: login.refreshToken,
|
|
126
|
+
}));
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('can block refresh with HookResult', async () => {
|
|
130
|
+
fortress = createFortress({
|
|
131
|
+
jwt: { key: SECRET },
|
|
132
|
+
database: createTestAdapter(),
|
|
133
|
+
plugins: [{
|
|
134
|
+
name: 'blocker',
|
|
135
|
+
hooks: {
|
|
136
|
+
async beforeTokenRefresh() {
|
|
137
|
+
return { stop: true, response: { blocked: true } };
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
}],
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
await seedUser();
|
|
144
|
+
const login = await fortress.auth.login('hook-user@example.com', 'password-123456');
|
|
145
|
+
assertSuccess(login);
|
|
146
|
+
const result = await fortress.auth.refresh(login.refreshToken as string);
|
|
147
|
+
|
|
148
|
+
expect((result as any).blocked).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('afterTokenRefresh', () => {
|
|
153
|
+
it('is called after token refresh and can modify result', async () => {
|
|
154
|
+
fortress = createFortress({
|
|
155
|
+
jwt: { key: SECRET },
|
|
156
|
+
database: createTestAdapter(),
|
|
157
|
+
plugins: [{
|
|
158
|
+
name: 'test',
|
|
159
|
+
hooks: {
|
|
160
|
+
async afterTokenRefresh(_ctx, result) {
|
|
161
|
+
return { ...result, accessToken: `modified-${result.accessToken}` };
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
}],
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
await seedUser();
|
|
168
|
+
const login = await fortress.auth.login('hook-user@example.com', 'password-123456');
|
|
169
|
+
assertSuccess(login);
|
|
170
|
+
const result = await fortress.auth.refresh(login.refreshToken as string);
|
|
171
|
+
|
|
172
|
+
expect(result.accessToken).toMatch(/^modified-/);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('onLoginFailure isolation', () => {
|
|
177
|
+
it('preserves the auth error and continues after a hook throws', async () => {
|
|
178
|
+
const secondHook = vi.fn(async () => {});
|
|
179
|
+
fortress = createFortress({
|
|
180
|
+
jwt: { key: SECRET },
|
|
181
|
+
database: createTestAdapter(),
|
|
182
|
+
plugins: [
|
|
183
|
+
{
|
|
184
|
+
name: 'broken',
|
|
185
|
+
hooks: {
|
|
186
|
+
async onLoginFailure() {
|
|
187
|
+
throw new Error('hook bug');
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
{ name: 'observer', hooks: { onLoginFailure: secondHook } },
|
|
192
|
+
],
|
|
193
|
+
});
|
|
194
|
+
await seedUser();
|
|
195
|
+
|
|
196
|
+
await expect(
|
|
197
|
+
fortress.auth.login('hook-user@example.com', 'wrong-password'),
|
|
198
|
+
).rejects.toThrow('Invalid credentials');
|
|
199
|
+
expect(secondHook).toHaveBeenCalledOnce();
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe('hook execution order', () => {
|
|
204
|
+
it('runs hooks in plugin registration order', async () => {
|
|
205
|
+
const order: string[] = [];
|
|
206
|
+
|
|
207
|
+
fortress = createFortress({
|
|
208
|
+
jwt: { key: SECRET },
|
|
209
|
+
database: createTestAdapter(),
|
|
210
|
+
plugins: [
|
|
211
|
+
{
|
|
212
|
+
name: 'first',
|
|
213
|
+
hooks: {
|
|
214
|
+
async afterLogin(_ctx, result) {
|
|
215
|
+
order.push('first');
|
|
216
|
+
return result;
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
name: 'second',
|
|
222
|
+
hooks: {
|
|
223
|
+
async afterLogin(_ctx, result) {
|
|
224
|
+
order.push('second');
|
|
225
|
+
return result;
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
await seedUser();
|
|
233
|
+
await fortress.auth.login('hook-user@example.com', 'password-123456');
|
|
234
|
+
|
|
235
|
+
expect(order).toEqual(['first', 'second']);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe('plugin validation', () => {
|
|
240
|
+
it('throws on duplicate plugin names', () => {
|
|
241
|
+
expect(() => createFortress({
|
|
242
|
+
jwt: { key: SECRET },
|
|
243
|
+
database: createTestAdapter(),
|
|
244
|
+
plugins: [
|
|
245
|
+
{ name: 'duplicate' },
|
|
246
|
+
{ name: 'duplicate' },
|
|
247
|
+
],
|
|
248
|
+
})).toThrow('Duplicate plugin name');
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
});
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import type { Fortress } from '../fortress';
|
|
2
|
+
|
|
3
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
4
|
+
import { createTestAdapter } from '../../testing';
|
|
5
|
+
import { createFortress } from '../fortress';
|
|
6
|
+
|
|
7
|
+
const SECRET = 'impersonation-test-secret-32chars!!';
|
|
8
|
+
|
|
9
|
+
let fortress: Fortress;
|
|
10
|
+
let adminId: string;
|
|
11
|
+
let targetId: string;
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
fortress = createFortress({
|
|
15
|
+
jwt: { key: SECRET },
|
|
16
|
+
database: createTestAdapter(),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const admin = await fortress.auth.createUser({
|
|
20
|
+
email: 'admin@example.com',
|
|
21
|
+
name: 'Admin User',
|
|
22
|
+
password: 'admin-pass-1234',
|
|
23
|
+
});
|
|
24
|
+
adminId = admin.id;
|
|
25
|
+
|
|
26
|
+
const target = await fortress.auth.createUser({
|
|
27
|
+
email: 'target@example.com',
|
|
28
|
+
name: 'Target User',
|
|
29
|
+
password: 'target-pass-123',
|
|
30
|
+
});
|
|
31
|
+
targetId = target.id;
|
|
32
|
+
|
|
33
|
+
await fortress.iam.bindPermissionToUser(adminId, {
|
|
34
|
+
resource: 'fortress',
|
|
35
|
+
action: 'impersonate',
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('impersonation', () => {
|
|
40
|
+
it('direct service call denies an admin without fortress:impersonate', async () => {
|
|
41
|
+
const other = await fortress.auth.createUser({
|
|
42
|
+
email: 'other-admin@example.com',
|
|
43
|
+
name: 'Other Admin',
|
|
44
|
+
password: 'other-pass-1234',
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
await expect(fortress.auth.impersonate(other.id, targetId))
|
|
48
|
+
.rejects
|
|
49
|
+
.toThrow('Insufficient permissions');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('http route denies a user without fortress:impersonate', async () => {
|
|
53
|
+
const user = await fortress.auth.createUser({
|
|
54
|
+
email: 'plain@example.com',
|
|
55
|
+
name: 'Plain User',
|
|
56
|
+
password: 'plain-pass-1234',
|
|
57
|
+
});
|
|
58
|
+
const token = await fortress.auth.signToken({
|
|
59
|
+
sub: user.id,
|
|
60
|
+
subjectType: 'USER',
|
|
61
|
+
name: user.name,
|
|
62
|
+
groups: [],
|
|
63
|
+
iss: 'fortress',
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const response = await fortress.handleRequest(new Request('http://localhost/auth/impersonate', {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers: {
|
|
69
|
+
'Authorization': `Bearer ${token}`,
|
|
70
|
+
'content-type': 'application/json',
|
|
71
|
+
},
|
|
72
|
+
body: JSON.stringify({ targetUserId: targetId }),
|
|
73
|
+
}));
|
|
74
|
+
|
|
75
|
+
expect(response.status).toBe(403);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('returns an access token with act claim containing admin userId', async () => {
|
|
79
|
+
const result = await fortress.auth.impersonate(adminId, targetId);
|
|
80
|
+
if (result.status !== 'impersonation')
|
|
81
|
+
throw new Error('Expected impersonation result');
|
|
82
|
+
const claims = await fortress.auth.verifyToken(result.accessToken as string);
|
|
83
|
+
|
|
84
|
+
expect(claims.act).toEqual({ sub: adminId, subjectType: 'USER' });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('token has the target user sub, name, and groups', async () => {
|
|
88
|
+
const result = await fortress.auth.impersonate(adminId, targetId);
|
|
89
|
+
if (result.status !== 'impersonation')
|
|
90
|
+
throw new Error('Expected impersonation result');
|
|
91
|
+
const claims = await fortress.auth.verifyToken(result.accessToken as string);
|
|
92
|
+
|
|
93
|
+
expect(claims.sub).toBe(targetId);
|
|
94
|
+
expect(claims.name).toBe('Target User');
|
|
95
|
+
expect(claims.groups).toEqual([]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('does not issue a refresh token', async () => {
|
|
99
|
+
const result = await fortress.auth.impersonate(adminId, targetId);
|
|
100
|
+
if (result.status !== 'impersonation')
|
|
101
|
+
throw new Error('Expected impersonation result');
|
|
102
|
+
|
|
103
|
+
expect(result.refreshToken).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('includes impersonation metadata in pluginData', async () => {
|
|
107
|
+
const result = await fortress.auth.impersonate(adminId, targetId);
|
|
108
|
+
if (result.status !== 'impersonation')
|
|
109
|
+
throw new Error('Expected impersonation result');
|
|
110
|
+
|
|
111
|
+
expect(result.pluginData).toEqual({
|
|
112
|
+
impersonation: {
|
|
113
|
+
adminUserId: adminId,
|
|
114
|
+
reason: null,
|
|
115
|
+
expiresInSeconds: 3600,
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('uses custom expiry when provided', async () => {
|
|
121
|
+
const result = await fortress.auth.impersonate(adminId, targetId, { expirySeconds: 600 });
|
|
122
|
+
if (result.status !== 'impersonation')
|
|
123
|
+
throw new Error('Expected impersonation result');
|
|
124
|
+
const claims = await fortress.auth.verifyToken(result.accessToken as string);
|
|
125
|
+
|
|
126
|
+
expect(result.pluginData).toMatchObject({
|
|
127
|
+
impersonation: { expiresInSeconds: 600 },
|
|
128
|
+
});
|
|
129
|
+
// Token lifetime should be roughly 600 seconds
|
|
130
|
+
expect(claims.exp - claims.iat).toBeLessThanOrEqual(600);
|
|
131
|
+
expect(claims.exp - claims.iat).toBeGreaterThanOrEqual(599);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('throws NOT_FOUND if target user does not exist or is inactive', async () => {
|
|
135
|
+
await expect(fortress.auth.impersonate(adminId, '99999')).rejects.toThrow('Target user not found');
|
|
136
|
+
|
|
137
|
+
await fortress.auth.updateUser(targetId, { isActive: false });
|
|
138
|
+
await expect(fortress.auth.impersonate(adminId, targetId)).rejects.toThrow('Target user not found');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('includes reason in pluginData when provided', async () => {
|
|
142
|
+
const result = await fortress.auth.impersonate(adminId, targetId, {
|
|
143
|
+
reason: 'Debugging user account issue #1234',
|
|
144
|
+
});
|
|
145
|
+
if (result.status !== 'impersonation')
|
|
146
|
+
throw new Error('Expected impersonation result');
|
|
147
|
+
|
|
148
|
+
expect(result.pluginData).toMatchObject({
|
|
149
|
+
impersonation: {
|
|
150
|
+
adminUserId: adminId,
|
|
151
|
+
reason: 'Debugging user account issue #1234',
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('returns the target user object without passwordHash', async () => {
|
|
157
|
+
const result = await fortress.auth.impersonate(adminId, targetId);
|
|
158
|
+
if (result.status !== 'impersonation')
|
|
159
|
+
throw new Error('Expected impersonation result');
|
|
160
|
+
|
|
161
|
+
expect(result.user.id).toBe(targetId);
|
|
162
|
+
expect(result.user.email).toBe('target@example.com');
|
|
163
|
+
expect(result.user.name).toBe('Target User');
|
|
164
|
+
expect((result.user as unknown as Record<string, unknown>).passwordHash).toBeUndefined();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// M4 regression: caller-supplied `expirySeconds` must be clamped to a
|
|
168
|
+
// configured maximum so an admin can't mint a years-long act token.
|
|
169
|
+
it('clamps oversized expirySeconds to the configured cap (M4)', async () => {
|
|
170
|
+
const result = await fortress.auth.impersonate(adminId, targetId, {
|
|
171
|
+
expirySeconds: 60 * 60 * 24 * 365 * 10, // 10 years
|
|
172
|
+
});
|
|
173
|
+
if (result.status !== 'impersonation')
|
|
174
|
+
throw new Error('Expected impersonation result');
|
|
175
|
+
const impersonationData = (result.pluginData as { impersonation?: { expiresInSeconds?: number } } | undefined)?.impersonation;
|
|
176
|
+
// Default cap is 3600s.
|
|
177
|
+
expect(impersonationData?.expiresInSeconds).toBeLessThanOrEqual(3600);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { generateKeyPair, SignJWT } from 'jose';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { signAccessToken, verifyAccessToken } from './jwt';
|
|
5
|
+
|
|
6
|
+
describe('jwt', () => {
|
|
7
|
+
const secret = 'test-secret-at-least-32-chars-long!';
|
|
8
|
+
const claims = {
|
|
9
|
+
sub: '42',
|
|
10
|
+
subjectType: 'USER' as const,
|
|
11
|
+
name: 'Test User',
|
|
12
|
+
groups: ['admin', 'editor'],
|
|
13
|
+
iss: 'fortress-test',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe('signAccessToken', () => {
|
|
17
|
+
it('signs a token and returns a string', async () => {
|
|
18
|
+
const token = await signAccessToken(claims, secret, 900);
|
|
19
|
+
expect(token).toBeTruthy();
|
|
20
|
+
expect(typeof token).toBe('string');
|
|
21
|
+
expect(token.split('.')).toHaveLength(3); // JWT has 3 parts
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('verifyAccessToken', () => {
|
|
26
|
+
it('verifies a valid token and returns claims', async () => {
|
|
27
|
+
const token = await signAccessToken(claims, secret, 900);
|
|
28
|
+
const decoded = await verifyAccessToken(token, secret);
|
|
29
|
+
|
|
30
|
+
expect(decoded.sub).toBe('42');
|
|
31
|
+
expect(decoded.name).toBe('Test User');
|
|
32
|
+
expect(decoded.groups).toEqual(['admin', 'editor']);
|
|
33
|
+
expect(decoded.iss).toBe('fortress-test');
|
|
34
|
+
expect(decoded.iat).toBeGreaterThan(0);
|
|
35
|
+
expect(decoded.exp).toBeGreaterThan(decoded.iat);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('rejects a token signed with a different secret', async () => {
|
|
39
|
+
const token = await signAccessToken(claims, secret, 900);
|
|
40
|
+
await expect(verifyAccessToken(token, 'wrong-secret')).rejects.toThrow('Invalid or expired token');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('rejects an expired token', async () => {
|
|
44
|
+
const token = await signAccessToken(claims, secret, 0); // expires immediately
|
|
45
|
+
// Small delay to ensure expiration
|
|
46
|
+
await new Promise(resolve => setTimeout(resolve, 1100));
|
|
47
|
+
await expect(verifyAccessToken(token, secret)).rejects.toThrow('Invalid or expired token');
|
|
48
|
+
}, 5000);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('secret rotation', () => {
|
|
52
|
+
const oldSecret = 'old-secret-for-rotation-testing!!';
|
|
53
|
+
const newSecret = 'new-secret-for-rotation-testing!!';
|
|
54
|
+
|
|
55
|
+
it('verifies a token signed with old secret using [new, old] array', async () => {
|
|
56
|
+
const token = await signAccessToken(claims, oldSecret, 900);
|
|
57
|
+
const decoded = await verifyAccessToken(token, [newSecret, oldSecret]);
|
|
58
|
+
expect(decoded.sub).toBe('42');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('signs with first secret in array', async () => {
|
|
62
|
+
const token = await signAccessToken(claims, [newSecret, oldSecret], 900);
|
|
63
|
+
// Should verify with newSecret alone
|
|
64
|
+
const decoded = await verifyAccessToken(token, newSecret);
|
|
65
|
+
expect(decoded.sub).toBe('42');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('fails if token was signed with a secret not in the array', async () => {
|
|
69
|
+
const token = await signAccessToken(claims, 'unknown-secret-not-in-array!!', 900);
|
|
70
|
+
await expect(verifyAccessToken(token, [newSecret, oldSecret])).rejects.toThrow();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('custom claims', () => {
|
|
75
|
+
it('includes custom claims in the token', async () => {
|
|
76
|
+
const claimsWithCustom = {
|
|
77
|
+
...claims,
|
|
78
|
+
customClaims: { tenantId: '5', tenantCode: 'acme' },
|
|
79
|
+
};
|
|
80
|
+
const token = await signAccessToken(claimsWithCustom, secret, 900);
|
|
81
|
+
const decoded = await verifyAccessToken(token, secret);
|
|
82
|
+
|
|
83
|
+
expect(decoded.customClaims?.tenantId).toBe('5');
|
|
84
|
+
expect(decoded.customClaims?.tenantCode).toBe('acme');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// M2 regression: a caller (or a plugin contributing via
|
|
88
|
+
// enrichTokenClaims) MUST NOT be able to forge `act`, `sub`, `groups`,
|
|
89
|
+
// `subjectType`, etc. through customClaims.
|
|
90
|
+
it('drops reserved keys from customClaims (no forged impersonation)', async () => {
|
|
91
|
+
const originalNodeEnv = process.env.NODE_ENV;
|
|
92
|
+
process.env.NODE_ENV = 'production'; // silent drop in prod
|
|
93
|
+
try {
|
|
94
|
+
const sneaky = {
|
|
95
|
+
...claims,
|
|
96
|
+
customClaims: {
|
|
97
|
+
// attacker tries to overwrite trusted claims
|
|
98
|
+
sub: '9999',
|
|
99
|
+
act: { sub: '1', subjectType: 'USER' },
|
|
100
|
+
groups: ['admin'],
|
|
101
|
+
subjectType: 'SERVICE_ACCOUNT',
|
|
102
|
+
tenantId: 'real-tenant',
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
const token = await signAccessToken(sneaky, secret, 900);
|
|
106
|
+
const decoded = await verifyAccessToken(token, secret);
|
|
107
|
+
|
|
108
|
+
// Real sub/groups/subjectType prevail; forged act dropped.
|
|
109
|
+
expect(decoded.sub).toBe('42');
|
|
110
|
+
expect(decoded.subjectType).toBe('USER');
|
|
111
|
+
expect(decoded.groups).toEqual(['admin', 'editor']);
|
|
112
|
+
expect(decoded.act).toBeUndefined();
|
|
113
|
+
// Non-reserved custom claims survive.
|
|
114
|
+
expect(decoded.customClaims?.tenantId).toBe('real-tenant');
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
if (originalNodeEnv === undefined)
|
|
118
|
+
delete process.env.NODE_ENV;
|
|
119
|
+
else process.env.NODE_ENV = originalNodeEnv;
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('remediation: alg + issuer pinning (M5 / P2.5)', () => {
|
|
125
|
+
it('verifies with the expected issuer', async () => {
|
|
126
|
+
const token = await signAccessToken(claims, secret, 900);
|
|
127
|
+
const decoded = await verifyAccessToken(token, secret, { issuer: 'fortress-test' });
|
|
128
|
+
expect(decoded.iss).toBe('fortress-test');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('rejects a token whose iss does not match', async () => {
|
|
132
|
+
const token = await signAccessToken(claims, secret, 900);
|
|
133
|
+
await expect(
|
|
134
|
+
verifyAccessToken(token, secret, { issuer: 'someone-else' }),
|
|
135
|
+
).rejects.toThrow();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('rejects unsigned and asymmetric-algorithm confusion tokens', async () => {
|
|
139
|
+
const encoded = (value: unknown): string => btoa(JSON.stringify(value)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
|
140
|
+
const unsigned = `${encoded({ alg: 'none' })}.${encoded({ ...claims, iat: 1, exp: 4_102_444_800 })}.`;
|
|
141
|
+
await expect(verifyAccessToken(unsigned, secret)).rejects.toThrow('Invalid or expired token');
|
|
142
|
+
|
|
143
|
+
const { privateKey } = await generateKeyPair('RS256');
|
|
144
|
+
const asymmetric = await new SignJWT({ ...claims, sub: claims.sub })
|
|
145
|
+
.setProtectedHeader({ alg: 'RS256' })
|
|
146
|
+
.setIssuedAt()
|
|
147
|
+
.setExpirationTime('900s')
|
|
148
|
+
.sign(privateKey);
|
|
149
|
+
await expect(verifyAccessToken(asymmetric, secret)).rejects.toThrow('Invalid or expired token');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('rejects tokens missing Fortress-required claims instead of fabricating defaults', async () => {
|
|
153
|
+
const token = await new SignJWT({ subjectType: 'USER', groups: [] })
|
|
154
|
+
.setProtectedHeader({ alg: 'HS256' })
|
|
155
|
+
.setIssuedAt()
|
|
156
|
+
.setExpirationTime('900s')
|
|
157
|
+
.setIssuer('fortress-test')
|
|
158
|
+
.setSubject('42')
|
|
159
|
+
.sign(new TextEncoder().encode(secret));
|
|
160
|
+
|
|
161
|
+
await expect(verifyAccessToken(token, secret)).rejects.toThrow('Invalid or expired token');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('signs and verifies audience end-to-end', async () => {
|
|
165
|
+
const token = await signAccessToken(
|
|
166
|
+
{ ...claims, customClaims: { tenantId: 'acme' } },
|
|
167
|
+
secret,
|
|
168
|
+
900,
|
|
169
|
+
{ audience: 'fortress-api' },
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const decoded = await verifyAccessToken(token, secret, {
|
|
173
|
+
audience: 'fortress-api',
|
|
174
|
+
requiredClaims: ['tenantId'],
|
|
175
|
+
});
|
|
176
|
+
expect(decoded.aud).toBe('fortress-api');
|
|
177
|
+
expect(decoded.customClaims?.tenantId).toBe('acme');
|
|
178
|
+
|
|
179
|
+
await expect(
|
|
180
|
+
verifyAccessToken(token, secret, { audience: 'other-api' }),
|
|
181
|
+
).rejects.toThrow('Invalid or expired token');
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
});
|