@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,684 @@
|
|
|
1
|
+
import type { Fortress } from '../../core/fortress';
|
|
2
|
+
import type { PluginRouteContext } from '../../core/plugin';
|
|
3
|
+
import type { Subject } from '../../core/types';
|
|
4
|
+
import type { ApiKeyConfig, ApiKeyMethods } from './index';
|
|
5
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
6
|
+
import { createFortress } from '../../core/fortress';
|
|
7
|
+
import { bool, endpoint, obj, str } from '../../core/schema-builder';
|
|
8
|
+
import { createTestAdapter } from '../../testing';
|
|
9
|
+
import { apiKey } from './index';
|
|
10
|
+
|
|
11
|
+
const SECRET = 'api-key-test-secret-at-least-32!!';
|
|
12
|
+
|
|
13
|
+
function httpCtx(subject: Subject | undefined): PluginRouteContext {
|
|
14
|
+
return {
|
|
15
|
+
subject,
|
|
16
|
+
userId: subject?.type === 'USER' ? subject.id : undefined,
|
|
17
|
+
claims: undefined,
|
|
18
|
+
meta: undefined,
|
|
19
|
+
request: new Request('http://localhost/api-key/keys'),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function userSubject(id: string): Subject {
|
|
24
|
+
return { type: 'USER', id };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function setup(config: ApiKeyConfig = { prefix: 'test', maxKeysPerSubject: 3 }): Promise<{
|
|
28
|
+
fortress: Fortress<any>;
|
|
29
|
+
methods: ApiKeyMethods;
|
|
30
|
+
userId: string;
|
|
31
|
+
otherUserId: string;
|
|
32
|
+
accessToken: string;
|
|
33
|
+
}> {
|
|
34
|
+
const fortress = createFortress({
|
|
35
|
+
jwt: { key: SECRET },
|
|
36
|
+
database: createTestAdapter(),
|
|
37
|
+
plugins: [apiKey(config)],
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const methods = fortress.plugins['api-key'] as unknown as ApiKeyMethods;
|
|
41
|
+
|
|
42
|
+
const alice = await fortress.auth.createUser({
|
|
43
|
+
email: 'alice@example.com',
|
|
44
|
+
name: 'Alice',
|
|
45
|
+
password: 'password-123456',
|
|
46
|
+
});
|
|
47
|
+
const bob = await fortress.auth.createUser({
|
|
48
|
+
email: 'bob@example.com',
|
|
49
|
+
name: 'Bob',
|
|
50
|
+
password: 'password-123456',
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const login = await fortress.auth.login('alice@example.com', 'password-123456');
|
|
54
|
+
if (login.status !== 'success')
|
|
55
|
+
throw new Error('expected login success');
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
fortress,
|
|
59
|
+
methods,
|
|
60
|
+
userId: alice.id,
|
|
61
|
+
otherUserId: bob.id,
|
|
62
|
+
accessToken: login.accessToken,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe('api-key plugin — programmatic methods', () => {
|
|
67
|
+
let methods: ApiKeyMethods;
|
|
68
|
+
let userId: string;
|
|
69
|
+
let otherUserId: string;
|
|
70
|
+
|
|
71
|
+
beforeEach(async () => {
|
|
72
|
+
({ methods, userId, otherUserId } = await setup());
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('createKey', () => {
|
|
76
|
+
it('returns a key with the configured prefix', async () => {
|
|
77
|
+
const result = await methods.createKey({ subject: userSubject(userId), name: 'My Key' });
|
|
78
|
+
expect(result.key).toMatch(/^test_sk_[a-f0-9]{64}$/);
|
|
79
|
+
expect(result.id).toBeDefined();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('enforces maxKeysPerSubject', async () => {
|
|
83
|
+
await methods.createKey({ subject: userSubject(userId), name: 'Key 1' });
|
|
84
|
+
await methods.createKey({ subject: userSubject(userId), name: 'Key 2' });
|
|
85
|
+
await methods.createKey({ subject: userSubject(userId), name: 'Key 3' });
|
|
86
|
+
|
|
87
|
+
await expect(methods.createKey({ subject: userSubject(userId), name: 'Key 4' }))
|
|
88
|
+
.rejects
|
|
89
|
+
.toThrow('Maximum of 3 active API keys');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('does not count revoked keys toward the limit', async () => {
|
|
93
|
+
const { id } = await methods.createKey({ subject: userSubject(userId), name: 'Key 1' });
|
|
94
|
+
await methods.createKey({ subject: userSubject(userId), name: 'Key 2' });
|
|
95
|
+
await methods.createKey({ subject: userSubject(userId), name: 'Key 3' });
|
|
96
|
+
|
|
97
|
+
await methods.revokeKey({ subject: userSubject(userId), id });
|
|
98
|
+
|
|
99
|
+
const result = await methods.createKey({ subject: userSubject(userId), name: 'Key 4' });
|
|
100
|
+
expect(result.key).toBeTruthy();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('requires subject when called without routeCtx', async () => {
|
|
104
|
+
await expect(methods.createKey({ name: 'Orphan' } as { name: string }))
|
|
105
|
+
.rejects
|
|
106
|
+
.toThrow('subject is required');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('listKeys', () => {
|
|
111
|
+
it('returns only non-revoked keys', async () => {
|
|
112
|
+
const { id } = await methods.createKey({ subject: userSubject(userId), name: 'Key A' });
|
|
113
|
+
await methods.createKey({ subject: userSubject(userId), name: 'Key B' });
|
|
114
|
+
await methods.revokeKey({ subject: userSubject(userId), id });
|
|
115
|
+
|
|
116
|
+
const keys = await methods.listKeys({ subject: userSubject(userId) });
|
|
117
|
+
expect(keys).toHaveLength(1);
|
|
118
|
+
expect(keys[0].name).toBe('Key B');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('never exposes the full key or hash', async () => {
|
|
122
|
+
await methods.createKey({ subject: userSubject(userId), name: 'Secret Key' });
|
|
123
|
+
|
|
124
|
+
const keys = await methods.listKeys({ subject: userSubject(userId) });
|
|
125
|
+
const key = keys[0] as unknown as Record<string, unknown>;
|
|
126
|
+
|
|
127
|
+
expect(key.keyPrefix).toBeTruthy();
|
|
128
|
+
expect(key).not.toHaveProperty('keyHash');
|
|
129
|
+
expect(key).not.toHaveProperty('key');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('scopes by subject — one user cannot see another user\'s keys', async () => {
|
|
133
|
+
await methods.createKey({ subject: userSubject(userId), name: 'Alice Key' });
|
|
134
|
+
await methods.createKey({ subject: userSubject(otherUserId), name: 'Bob Key' });
|
|
135
|
+
|
|
136
|
+
const aliceKeys = await methods.listKeys({ subject: userSubject(userId) });
|
|
137
|
+
const bobKeys = await methods.listKeys({ subject: userSubject(otherUserId) });
|
|
138
|
+
|
|
139
|
+
expect(aliceKeys).toHaveLength(1);
|
|
140
|
+
expect(aliceKeys[0].name).toBe('Alice Key');
|
|
141
|
+
expect(bobKeys).toHaveLength(1);
|
|
142
|
+
expect(bobKeys[0].name).toBe('Bob Key');
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('revokeKey', () => {
|
|
147
|
+
it('marks a key as revoked', async () => {
|
|
148
|
+
const { key, id } = await methods.createKey({ subject: userSubject(userId), name: 'To Revoke' });
|
|
149
|
+
await methods.revokeKey({ subject: userSubject(userId), id });
|
|
150
|
+
|
|
151
|
+
const resolved = await methods.resolveKey(key);
|
|
152
|
+
expect(resolved).toBeNull();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('rejects revoking another user\'s key', async () => {
|
|
156
|
+
const { id } = await methods.createKey({ subject: userSubject(otherUserId), name: 'Bob Key' });
|
|
157
|
+
await expect(methods.revokeKey({ subject: userSubject(userId), id }))
|
|
158
|
+
.rejects
|
|
159
|
+
.toThrow('API key not found');
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('rotateKey', () => {
|
|
164
|
+
it('revokes the old key and creates a new one', async () => {
|
|
165
|
+
const original = await methods.createKey({ subject: userSubject(userId), name: 'Rotate Me' });
|
|
166
|
+
const rotated = await methods.rotateKey({ subject: userSubject(userId), id: original.id });
|
|
167
|
+
|
|
168
|
+
const oldResolved = await methods.resolveKey(original.key);
|
|
169
|
+
expect(oldResolved).toBeNull();
|
|
170
|
+
|
|
171
|
+
const newResolved = await methods.resolveKey(rotated.key);
|
|
172
|
+
expect(newResolved).not.toBeNull();
|
|
173
|
+
expect(newResolved!.subject.type).toBe('USER');
|
|
174
|
+
expect(newResolved!.subject.id).toBe(userId);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe('resolveKey', () => {
|
|
179
|
+
it('resolves a valid key', async () => {
|
|
180
|
+
const { key } = await methods.createKey({ subject: userSubject(userId), name: 'Valid Key' });
|
|
181
|
+
const result = await methods.resolveKey(key);
|
|
182
|
+
expect(result).not.toBeNull();
|
|
183
|
+
expect(result!.subject).toEqual({ type: 'USER', id: userId });
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('rejects a revoked key', async () => {
|
|
187
|
+
const { key, id } = await methods.createKey({ subject: userSubject(userId), name: 'Revoked Key' });
|
|
188
|
+
await methods.revokeKey({ subject: userSubject(userId), id });
|
|
189
|
+
const result = await methods.resolveKey(key);
|
|
190
|
+
expect(result).toBeNull();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('rejects an expired key', async () => {
|
|
194
|
+
const { key } = await methods.createKey({
|
|
195
|
+
subject: userSubject(userId),
|
|
196
|
+
name: 'Expired Key',
|
|
197
|
+
expiresAt: new Date(Date.now() - 1000),
|
|
198
|
+
});
|
|
199
|
+
const result = await methods.resolveKey(key);
|
|
200
|
+
expect(result).toBeNull();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('updates lastUsedAt on resolve', async () => {
|
|
204
|
+
const { key } = await methods.createKey({ subject: userSubject(userId), name: 'Track Usage' });
|
|
205
|
+
|
|
206
|
+
let keys = await methods.listKeys({ subject: userSubject(userId) });
|
|
207
|
+
expect(keys[0].lastUsedAt).toBeNull();
|
|
208
|
+
|
|
209
|
+
await methods.resolveKey(key);
|
|
210
|
+
|
|
211
|
+
keys = await methods.listKeys({ subject: userSubject(userId) });
|
|
212
|
+
expect(keys[0].lastUsedAt).toBeTruthy();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('returns null for an unknown key', async () => {
|
|
216
|
+
const result = await methods.resolveKey('nonexistent_sk_key');
|
|
217
|
+
expect(result).toBeNull();
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe('scoped keys', () => {
|
|
222
|
+
it('returns the correct scopes on resolve', async () => {
|
|
223
|
+
const scopes = ['article:read', 'article:list'];
|
|
224
|
+
const { key } = await methods.createKey({ subject: userSubject(userId), name: 'Scoped Key', scopes });
|
|
225
|
+
const result = await methods.resolveKey(key);
|
|
226
|
+
expect(result!.scopes).toEqual(scopes);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('returns null scopes for an unscoped key', async () => {
|
|
230
|
+
const { key } = await methods.createKey({ subject: userSubject(userId), name: 'Unscoped Key' });
|
|
231
|
+
const result = await methods.resolveKey(key);
|
|
232
|
+
expect(result!.scopes).toBeNull();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('narrows IAM route permissions to the API-key scopes', async () => {
|
|
236
|
+
const reportsPlugin = {
|
|
237
|
+
name: 'reports',
|
|
238
|
+
routes: {
|
|
239
|
+
deleteReport: endpoint('DELETE', '/reports/:id')
|
|
240
|
+
.security('bearer')
|
|
241
|
+
.permission('report', 'delete')
|
|
242
|
+
.params(obj({ id: str('Report id') }, 'id'))
|
|
243
|
+
.response(200, 'Deleted', obj({ ok: bool() }, 'ok'))
|
|
244
|
+
.handler('deleteReport')
|
|
245
|
+
.build(),
|
|
246
|
+
},
|
|
247
|
+
methods: () => ({ deleteReport: () => ({ ok: true }) }),
|
|
248
|
+
};
|
|
249
|
+
const fortress = createFortress({
|
|
250
|
+
jwt: { key: SECRET },
|
|
251
|
+
database: createTestAdapter(),
|
|
252
|
+
plugins: [apiKey({ prefix: 'test' }), reportsPlugin],
|
|
253
|
+
});
|
|
254
|
+
const apiKeys = fortress.plugins['api-key'] as unknown as ApiKeyMethods;
|
|
255
|
+
const user = await fortress.auth.createUser({
|
|
256
|
+
email: 'scoped@example.com',
|
|
257
|
+
name: 'Scoped',
|
|
258
|
+
password: 'password-123456',
|
|
259
|
+
});
|
|
260
|
+
const role = await fortress.iam.createRole('report-deleter', [{ resource: 'report', action: 'delete' }]);
|
|
261
|
+
await fortress.iam.bindRoleToUser(user.id, role.id);
|
|
262
|
+
|
|
263
|
+
const readOnly = await apiKeys.createKey({
|
|
264
|
+
subject: userSubject(user.id),
|
|
265
|
+
name: 'Read only',
|
|
266
|
+
scopes: ['report:read'],
|
|
267
|
+
});
|
|
268
|
+
const denied = await fortress.handleRequest(new Request('http://localhost/reports/r1', {
|
|
269
|
+
method: 'DELETE',
|
|
270
|
+
headers: { Authorization: `ApiKey ${readOnly.key}` },
|
|
271
|
+
}));
|
|
272
|
+
expect(denied.status).toBe(403);
|
|
273
|
+
|
|
274
|
+
const wildcard = await apiKeys.createKey({
|
|
275
|
+
subject: userSubject(user.id),
|
|
276
|
+
name: 'Report wildcard',
|
|
277
|
+
scopes: ['report:*'],
|
|
278
|
+
});
|
|
279
|
+
const allowed = await fortress.handleRequest(new Request('http://localhost/reports/r1', {
|
|
280
|
+
method: 'DELETE',
|
|
281
|
+
headers: { Authorization: `ApiKey ${wildcard.key}` },
|
|
282
|
+
}));
|
|
283
|
+
expect(allowed.status).toBe(200);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
describe('api-key plugin — dual-mode (routeCtx takes precedence over input.subject)', () => {
|
|
289
|
+
it('uses routeCtx.subject and ignores input.subject when routeCtx is present', async () => {
|
|
290
|
+
const { methods, userId, otherUserId } = await setup();
|
|
291
|
+
|
|
292
|
+
// Caller tries to forge a key for another subject by passing subject in input.
|
|
293
|
+
// The route-mode invocation must ignore input.subject and trust routeCtx.
|
|
294
|
+
const result = await methods.createKey(
|
|
295
|
+
{ subject: userSubject(otherUserId), name: 'Forged' },
|
|
296
|
+
httpCtx(userSubject(userId)),
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
// Key should belong to the authenticated caller, not the forged target
|
|
300
|
+
const aliceKeys = await methods.listKeys({ subject: userSubject(userId) });
|
|
301
|
+
const bobKeys = await methods.listKeys({ subject: userSubject(otherUserId) });
|
|
302
|
+
|
|
303
|
+
expect(aliceKeys.map(k => k.id)).toContain(result.id);
|
|
304
|
+
expect(bobKeys.map(k => k.id)).not.toContain(result.id);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('throws UNAUTHORIZED when routeCtx.subject is missing', async () => {
|
|
308
|
+
const { methods } = await setup();
|
|
309
|
+
await expect(methods.createKey({ name: 'X' } as { name: string }, httpCtx(undefined)))
|
|
310
|
+
.rejects
|
|
311
|
+
.toThrow('Not authenticated');
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
describe('api-key plugin — HTTP routes (opt-in flag)', () => {
|
|
316
|
+
describe('routes: false (default)', () => {
|
|
317
|
+
it('does not mount /api-key/keys — POST returns 404', async () => {
|
|
318
|
+
const { fortress, accessToken } = await setup({ prefix: 'test' });
|
|
319
|
+
|
|
320
|
+
const res = await fortress.handleRequest(new Request('http://localhost/api-key/keys', {
|
|
321
|
+
method: 'POST',
|
|
322
|
+
headers: {
|
|
323
|
+
'authorization': `Bearer ${accessToken}`,
|
|
324
|
+
'content-type': 'application/json',
|
|
325
|
+
},
|
|
326
|
+
body: JSON.stringify({ name: 'Unmounted' }),
|
|
327
|
+
}));
|
|
328
|
+
expect(res.status).toBe(404);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('programmatic methods still work', async () => {
|
|
332
|
+
const { methods, userId } = await setup({ prefix: 'test' });
|
|
333
|
+
const result = await methods.createKey({ subject: userSubject(userId), name: 'Programmatic' });
|
|
334
|
+
expect(result.key).toMatch(/^test_sk_/);
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
describe('routes: true', () => {
|
|
339
|
+
it('rejects unauthenticated POST /api-key/keys with 401', async () => {
|
|
340
|
+
const { fortress } = await setup({ prefix: 'test', routes: true });
|
|
341
|
+
const res = await fortress.handleRequest(new Request('http://localhost/api-key/keys', {
|
|
342
|
+
method: 'POST',
|
|
343
|
+
headers: { 'content-type': 'application/json' },
|
|
344
|
+
body: JSON.stringify({ name: 'Key' }),
|
|
345
|
+
}));
|
|
346
|
+
expect(res.status).toBe(401);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('denies API-key credentials from minting broader keys via self-service routes', async () => {
|
|
350
|
+
const { fortress, methods, userId } = await setup({
|
|
351
|
+
prefix: 'test',
|
|
352
|
+
routes: true,
|
|
353
|
+
});
|
|
354
|
+
const scoped = await methods.createKey({
|
|
355
|
+
subject: userSubject(userId),
|
|
356
|
+
name: 'Scoped caller',
|
|
357
|
+
scopes: ['report:read'],
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
const res = await fortress.handleRequest(new Request('http://localhost/api-key/keys', {
|
|
361
|
+
method: 'POST',
|
|
362
|
+
headers: {
|
|
363
|
+
'authorization': `ApiKey ${scoped.key}`,
|
|
364
|
+
'content-type': 'application/json',
|
|
365
|
+
},
|
|
366
|
+
body: JSON.stringify({ name: 'Escalated', scopes: ['*'] }),
|
|
367
|
+
}));
|
|
368
|
+
|
|
369
|
+
expect(res.status).toBe(403);
|
|
370
|
+
const keys = await methods.listKeys({ subject: userSubject(userId) });
|
|
371
|
+
expect(keys.some(k => k.name === 'Escalated')).toBe(false);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('denies API-key credentials from listing keys via self-service routes', async () => {
|
|
375
|
+
const { fortress, methods, userId } = await setup({ prefix: 'test', routes: true });
|
|
376
|
+
const caller = await methods.createKey({
|
|
377
|
+
subject: userSubject(userId),
|
|
378
|
+
name: 'Caller key',
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
const res = await fortress.handleRequest(new Request('http://localhost/api-key/keys', {
|
|
382
|
+
headers: { authorization: `ApiKey ${caller.key}` },
|
|
383
|
+
}));
|
|
384
|
+
|
|
385
|
+
expect(res.status).toBe(403);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it('denies API-key credentials from revoking keys via self-service routes', async () => {
|
|
389
|
+
const { fortress, methods, userId } = await setup({ prefix: 'test', routes: true });
|
|
390
|
+
const caller = await methods.createKey({ subject: userSubject(userId), name: 'Caller key' });
|
|
391
|
+
const target = await methods.createKey({ subject: userSubject(userId), name: 'Target key' });
|
|
392
|
+
|
|
393
|
+
const res = await fortress.handleRequest(new Request(`http://localhost/api-key/keys/${target.id}`, {
|
|
394
|
+
method: 'DELETE',
|
|
395
|
+
headers: { authorization: `ApiKey ${caller.key}` },
|
|
396
|
+
}));
|
|
397
|
+
|
|
398
|
+
expect(res.status).toBe(403);
|
|
399
|
+
expect(await methods.resolveKey(target.key)).not.toBeNull();
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('denies API-key credentials from rotating keys via self-service routes', async () => {
|
|
403
|
+
const { fortress, methods, userId } = await setup({ prefix: 'test', routes: true });
|
|
404
|
+
const caller = await methods.createKey({ subject: userSubject(userId), name: 'Caller key' });
|
|
405
|
+
const target = await methods.createKey({ subject: userSubject(userId), name: 'Target key' });
|
|
406
|
+
|
|
407
|
+
const res = await fortress.handleRequest(new Request(`http://localhost/api-key/keys/${target.id}/rotate`, {
|
|
408
|
+
method: 'POST',
|
|
409
|
+
headers: { authorization: `ApiKey ${caller.key}` },
|
|
410
|
+
}));
|
|
411
|
+
|
|
412
|
+
expect(res.status).toBe(403);
|
|
413
|
+
expect(await methods.resolveKey(target.key)).not.toBeNull();
|
|
414
|
+
const keys = await methods.listKeys({ subject: userSubject(userId) });
|
|
415
|
+
expect(keys.filter(k => k.name === 'Target key')).toHaveLength(1);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('creates a key for the authenticated subject and ignores body.subject on POST', async () => {
|
|
419
|
+
const { fortress, methods, userId, otherUserId, accessToken } = await setup({
|
|
420
|
+
prefix: 'test',
|
|
421
|
+
routes: true,
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// Caller tries to forge a key for another user via body.subject — should be ignored.
|
|
425
|
+
const res = await fortress.handleRequest(new Request('http://localhost/api-key/keys', {
|
|
426
|
+
method: 'POST',
|
|
427
|
+
headers: {
|
|
428
|
+
'authorization': `Bearer ${accessToken}`,
|
|
429
|
+
'content-type': 'application/json',
|
|
430
|
+
},
|
|
431
|
+
body: JSON.stringify({ subject: userSubject(otherUserId), name: 'Forged via HTTP' }),
|
|
432
|
+
}));
|
|
433
|
+
expect(res.status).toBe(201);
|
|
434
|
+
const body = await res.json() as { key: string; id: string };
|
|
435
|
+
expect(body.key).toMatch(/^test_sk_/);
|
|
436
|
+
|
|
437
|
+
const aliceKeys = await methods.listKeys({ subject: userSubject(userId) });
|
|
438
|
+
expect(aliceKeys.map(k => k.id)).toContain(body.id);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it('returns only the caller keys on GET /api-key/keys', async () => {
|
|
442
|
+
const { fortress, methods, userId, otherUserId, accessToken } = await setup({
|
|
443
|
+
prefix: 'test',
|
|
444
|
+
routes: true,
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
await methods.createKey({ subject: userSubject(userId), name: 'Alice HTTP' });
|
|
448
|
+
await methods.createKey({ subject: userSubject(otherUserId), name: 'Bob Silent' });
|
|
449
|
+
|
|
450
|
+
const res = await fortress.handleRequest(new Request('http://localhost/api-key/keys', {
|
|
451
|
+
headers: { authorization: `Bearer ${accessToken}` },
|
|
452
|
+
}));
|
|
453
|
+
expect(res.status).toBe(200);
|
|
454
|
+
const body = await res.json() as { id: string; name: string }[];
|
|
455
|
+
// Route returns array shape from listKeys method (not wrapped in { keys: [...] })
|
|
456
|
+
expect(Array.isArray(body)).toBe(true);
|
|
457
|
+
expect(body.some(k => k.name === 'Alice HTTP')).toBe(true);
|
|
458
|
+
expect(body.some(k => k.name === 'Bob Silent')).toBe(false);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it('refuses to revoke another user key on DELETE /api-key/keys/:id', async () => {
|
|
462
|
+
const { fortress, methods, otherUserId, accessToken } = await setup({
|
|
463
|
+
prefix: 'test',
|
|
464
|
+
routes: true,
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
const { id } = await methods.createKey({ subject: userSubject(otherUserId), name: 'Bob Key' });
|
|
468
|
+
|
|
469
|
+
const res = await fortress.handleRequest(new Request(`http://localhost/api-key/keys/${id}`, {
|
|
470
|
+
method: 'DELETE',
|
|
471
|
+
headers: { authorization: `Bearer ${accessToken}` },
|
|
472
|
+
}));
|
|
473
|
+
expect(res.status).toBe(404);
|
|
474
|
+
|
|
475
|
+
// Key still resolves — not actually revoked
|
|
476
|
+
const bobKeys = await methods.listKeys({ subject: userSubject(otherUserId) });
|
|
477
|
+
expect(bobKeys.some(k => k.id === id)).toBe(true);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it('rotates a key and revokes the old one on POST /api-key/keys/:id/rotate', async () => {
|
|
481
|
+
const { fortress, methods, userId, accessToken } = await setup({
|
|
482
|
+
prefix: 'test',
|
|
483
|
+
routes: true,
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
const original = await methods.createKey({ subject: userSubject(userId), name: 'Rotate Me' });
|
|
487
|
+
|
|
488
|
+
const res = await fortress.handleRequest(new Request(`http://localhost/api-key/keys/${original.id}/rotate`, {
|
|
489
|
+
method: 'POST',
|
|
490
|
+
headers: {
|
|
491
|
+
'authorization': `Bearer ${accessToken}`,
|
|
492
|
+
'content-type': 'application/json',
|
|
493
|
+
},
|
|
494
|
+
}));
|
|
495
|
+
expect(res.status).toBe(200);
|
|
496
|
+
const body = await res.json() as { key: string; id: string };
|
|
497
|
+
expect(body.key).toMatch(/^test_sk_/);
|
|
498
|
+
expect(body.id).not.toBe(original.id);
|
|
499
|
+
|
|
500
|
+
// Old key no longer resolves
|
|
501
|
+
expect(await methods.resolveKey(original.key)).toBeNull();
|
|
502
|
+
// New key does
|
|
503
|
+
const newResolved = await methods.resolveKey(body.key);
|
|
504
|
+
expect(newResolved!.subject).toEqual({ type: 'USER', id: userId });
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
// ── User lifecycle gates ───────────────────────────────────────────
|
|
510
|
+
|
|
511
|
+
describe('api-key plugin — USER lifecycle', () => {
|
|
512
|
+
it('rejects a deactivated user key and does not retain cached permissions', async () => {
|
|
513
|
+
const fortress = createFortress({
|
|
514
|
+
jwt: { key: SECRET },
|
|
515
|
+
database: createTestAdapter(),
|
|
516
|
+
rbac: { cache: { ttlSeconds: 300 } },
|
|
517
|
+
plugins: [apiKey({ prefix: 'test' })],
|
|
518
|
+
});
|
|
519
|
+
const methods = fortress.plugins['api-key'] as unknown as ApiKeyMethods;
|
|
520
|
+
const user = await fortress.auth.createUser({
|
|
521
|
+
email: 'disabled-key@example.com',
|
|
522
|
+
name: 'Disabled Key',
|
|
523
|
+
password: 'password-123456',
|
|
524
|
+
});
|
|
525
|
+
const role = await fortress.iam.createRole('cached-reader', [
|
|
526
|
+
{ resource: 'report', action: 'read' },
|
|
527
|
+
]);
|
|
528
|
+
await fortress.iam.bindRoleToUser(user.id, role.id);
|
|
529
|
+
const { key } = await methods.createKey({ subject: userSubject(user.id), name: 'Before disable' });
|
|
530
|
+
|
|
531
|
+
// Warm the permission cache before changing activation state.
|
|
532
|
+
await expect(fortress.iam.checkPermission(userSubject(user.id), 'report', 'read')).resolves.toBe(true);
|
|
533
|
+
await fortress.auth.updateUser(user.id, { isActive: false });
|
|
534
|
+
|
|
535
|
+
await expect(methods.resolveKey(key)).resolves.toBeNull();
|
|
536
|
+
await expect(fortress.iam.checkPermission(userSubject(user.id), 'report', 'read')).resolves.toBe(false);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it('rejects a key whose user owner was deleted', async () => {
|
|
540
|
+
const { fortress, methods, userId } = await setup();
|
|
541
|
+
const { key } = await methods.createKey({ subject: userSubject(userId), name: 'Before delete' });
|
|
542
|
+
|
|
543
|
+
await fortress.auth.deleteUser(userId);
|
|
544
|
+
|
|
545
|
+
await expect(methods.resolveKey(key)).resolves.toBeNull();
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
// ── Service account pipeline ────────────────────────────────────────
|
|
550
|
+
|
|
551
|
+
describe('api-key plugin — SERVICE_ACCOUNT subjects', () => {
|
|
552
|
+
it('createKey for a SERVICE_ACCOUNT subject works programmatically', async () => {
|
|
553
|
+
const { fortress, methods } = await setup();
|
|
554
|
+
const sa = await fortress.iam.createServiceAccount({ name: 'ci-deploy' });
|
|
555
|
+
|
|
556
|
+
const result = await methods.createKey({
|
|
557
|
+
subject: { type: 'SERVICE_ACCOUNT', id: sa.id },
|
|
558
|
+
name: 'CI Deploy Key',
|
|
559
|
+
});
|
|
560
|
+
expect(result.key).toMatch(/^test_sk_/);
|
|
561
|
+
|
|
562
|
+
const resolved = await methods.resolveKey(result.key);
|
|
563
|
+
expect(resolved!.subject).toEqual({ type: 'SERVICE_ACCOUNT', id: sa.id });
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it('listKeys scopes by (subjectType, subjectId)', async () => {
|
|
567
|
+
const { fortress, methods, userId } = await setup();
|
|
568
|
+
const sa = await fortress.iam.createServiceAccount({ name: 'iso' });
|
|
569
|
+
|
|
570
|
+
// Create a USER key with the same numeric id would require a user with that id.
|
|
571
|
+
// Use the existing user but also create a SA key.
|
|
572
|
+
await methods.createKey({ subject: userSubject(userId), name: 'User Key' });
|
|
573
|
+
await methods.createKey({
|
|
574
|
+
subject: { type: 'SERVICE_ACCOUNT', id: sa.id },
|
|
575
|
+
name: 'SA Key',
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
const userKeys = await methods.listKeys({ subject: userSubject(userId) });
|
|
579
|
+
const saKeys = await methods.listKeys({ subject: { type: 'SERVICE_ACCOUNT', id: sa.id } });
|
|
580
|
+
|
|
581
|
+
expect(userKeys.map(k => k.name)).toEqual(['User Key']);
|
|
582
|
+
expect(saKeys.map(k => k.name)).toEqual(['SA Key']);
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
it('resolveKey returns null for an inactive service account', async () => {
|
|
586
|
+
const { fortress, methods } = await setup();
|
|
587
|
+
const sa = await fortress.iam.createServiceAccount({ name: 'disabled' });
|
|
588
|
+
|
|
589
|
+
const { key } = await methods.createKey({
|
|
590
|
+
subject: { type: 'SERVICE_ACCOUNT', id: sa.id },
|
|
591
|
+
name: 'Disabled Key',
|
|
592
|
+
});
|
|
593
|
+
expect(await methods.resolveKey(key)).not.toBeNull();
|
|
594
|
+
|
|
595
|
+
await fortress.iam.updateServiceAccount(sa.id, { isActive: false });
|
|
596
|
+
expect(await methods.resolveKey(key)).toBeNull();
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
it('cascade: deleting a service account hard-deletes its api keys', async () => {
|
|
600
|
+
const { fortress, methods } = await setup();
|
|
601
|
+
const sa = await fortress.iam.createServiceAccount({ name: 'to-delete' });
|
|
602
|
+
|
|
603
|
+
const { key, id } = await methods.createKey({
|
|
604
|
+
subject: { type: 'SERVICE_ACCOUNT', id: sa.id },
|
|
605
|
+
name: 'Cascade Me',
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
await fortress.iam.deleteServiceAccount(sa.id);
|
|
609
|
+
|
|
610
|
+
// Key is gone entirely — not just revoked.
|
|
611
|
+
const remaining = await fortress.config.database.findMany({
|
|
612
|
+
model: 'api_key',
|
|
613
|
+
where: [{ field: 'id', operator: '=', value: id }],
|
|
614
|
+
});
|
|
615
|
+
expect(remaining).toHaveLength(0);
|
|
616
|
+
|
|
617
|
+
// resolveKey returns null for the now-deleted key
|
|
618
|
+
expect(await methods.resolveKey(key)).toBeNull();
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
it('authorization: ApiKey header authenticates a service account request', async () => {
|
|
622
|
+
const { fortress, methods } = await setup({ prefix: 'test', routes: true });
|
|
623
|
+
const sa = await fortress.iam.createServiceAccount({ name: 'ci-authed' });
|
|
624
|
+
|
|
625
|
+
// Bind the service account to a role that grants fortress:viewServiceAccounts
|
|
626
|
+
// so it can hit a real fortress-managed route.
|
|
627
|
+
const role = await fortress.iam.createRole('ci-viewer', [
|
|
628
|
+
{ resource: 'fortress', action: 'viewServiceAccounts' },
|
|
629
|
+
]);
|
|
630
|
+
await fortress.iam.bindRoleToServiceAccount(sa.id, role.id);
|
|
631
|
+
|
|
632
|
+
const { key } = await methods.createKey({
|
|
633
|
+
subject: { type: 'SERVICE_ACCOUNT', id: sa.id },
|
|
634
|
+
name: 'ci-key',
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
const res = await fortress.handleRequest(
|
|
638
|
+
new Request('http://localhost/iam/service-accounts', {
|
|
639
|
+
headers: { authorization: `ApiKey ${key}` },
|
|
640
|
+
}),
|
|
641
|
+
);
|
|
642
|
+
expect(res.status).toBe(200);
|
|
643
|
+
const body = await res.json() as { serviceAccounts: unknown[]; total: number };
|
|
644
|
+
expect(body.total).toBeGreaterThanOrEqual(1);
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it('rejects a service account request without the bound role via 403', async () => {
|
|
648
|
+
const { fortress, methods } = await setup({ prefix: 'test', routes: true });
|
|
649
|
+
const sa = await fortress.iam.createServiceAccount({ name: 'unbound' });
|
|
650
|
+
|
|
651
|
+
const { key } = await methods.createKey({
|
|
652
|
+
subject: { type: 'SERVICE_ACCOUNT', id: sa.id },
|
|
653
|
+
name: 'unbound-key',
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
const res = await fortress.handleRequest(
|
|
657
|
+
new Request('http://localhost/iam/service-accounts', {
|
|
658
|
+
headers: { authorization: `ApiKey ${key}` },
|
|
659
|
+
}),
|
|
660
|
+
);
|
|
661
|
+
expect(res.status).toBe(403);
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
it('x-api-key header is accepted as an alternative to Authorization', async () => {
|
|
665
|
+
const { fortress, methods } = await setup();
|
|
666
|
+
const sa = await fortress.iam.createServiceAccount({ name: 'xheader' });
|
|
667
|
+
const role = await fortress.iam.createRole('xh-role', [
|
|
668
|
+
{ resource: 'fortress', action: 'viewServiceAccounts' },
|
|
669
|
+
]);
|
|
670
|
+
await fortress.iam.bindRoleToServiceAccount(sa.id, role.id);
|
|
671
|
+
|
|
672
|
+
const { key } = await methods.createKey({
|
|
673
|
+
subject: { type: 'SERVICE_ACCOUNT', id: sa.id },
|
|
674
|
+
name: 'xheader-key',
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
const res = await fortress.handleRequest(
|
|
678
|
+
new Request('http://localhost/iam/service-accounts', {
|
|
679
|
+
headers: { 'x-api-key': key },
|
|
680
|
+
}),
|
|
681
|
+
);
|
|
682
|
+
expect(res.status).toBe(200);
|
|
683
|
+
});
|
|
684
|
+
});
|