@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,1464 @@
|
|
|
1
|
+
import type { DatabaseAdapter } from '../../adapters/database';
|
|
2
|
+
import type { FortressConfig, PasswordHasher } from '../config';
|
|
3
|
+
import type { StoredContinuation, StoredRefreshToken } from '../internal-adapter';
|
|
4
|
+
import type { Unsubscribe } from '../observability/listener-list';
|
|
5
|
+
import type { FortressLogger } from '../observability/logger';
|
|
6
|
+
import type { Histogram, TelemetryProvider } from '../observability/types';
|
|
7
|
+
import type {
|
|
8
|
+
AfterHookContext,
|
|
9
|
+
FortressPlugin,
|
|
10
|
+
HookContext,
|
|
11
|
+
HookResult,
|
|
12
|
+
} from '../plugin';
|
|
13
|
+
import type {
|
|
14
|
+
AuthMethod,
|
|
15
|
+
AuthResult,
|
|
16
|
+
AuthSuccess,
|
|
17
|
+
AuthTokenPair,
|
|
18
|
+
CreateUserInput,
|
|
19
|
+
FortressUser,
|
|
20
|
+
LoginIdentifier,
|
|
21
|
+
LoginIdentifierType,
|
|
22
|
+
PendingReason,
|
|
23
|
+
RequestMeta,
|
|
24
|
+
SessionInfo,
|
|
25
|
+
TokenClaims,
|
|
26
|
+
} from '../types';
|
|
27
|
+
import type { JwtKeyMaterial } from './jwt';
|
|
28
|
+
import type { PasswordPolicyObserver } from './password-policy';
|
|
29
|
+
import { Errors, FortressError } from '../errors';
|
|
30
|
+
import { evaluatePermissions } from '../iam/permission-evaluator';
|
|
31
|
+
import { createInternalAdapter } from '../internal-adapter';
|
|
32
|
+
import { createListenerList } from '../observability/listener-list';
|
|
33
|
+
import { SILENT_LOGGER } from '../observability/logger';
|
|
34
|
+
import { normalizeEmail } from './email';
|
|
35
|
+
import { signAccessToken, verifyAccessToken } from './jwt';
|
|
36
|
+
import { createDefaultHasher, normalizePasswordInput } from './password';
|
|
37
|
+
import { validatePassword } from './password-policy';
|
|
38
|
+
import { consumeAuthContinuation, runPostAuthGates, verifyAuthContinuation } from './post-auth-gate';
|
|
39
|
+
import { deriveRefreshTokenSuccessor, generateRefreshToken, generateTokenFamily, hashRefreshFingerprint, hashToken } from './refresh-token';
|
|
40
|
+
|
|
41
|
+
const NUMERIC_SUBJECT_ID_RE = /^\d+$/;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Lifecycle event emitted by the auth service. Mirrors the IAM observer
|
|
45
|
+
* pattern (`addIamObserver`) so consumers — audit log, SIEM webhooks,
|
|
46
|
+
* telemetry plugins, Datadog adapters — can subscribe to auth events
|
|
47
|
+
* without reimplementing every hook individually.
|
|
48
|
+
*/
|
|
49
|
+
export interface AuthEvent {
|
|
50
|
+
eventType:
|
|
51
|
+
| 'LOGIN_SUCCESS' | 'LOGIN_FAILURE' | 'LOGIN_PENDING'
|
|
52
|
+
| 'LOGOUT' | 'REGISTER'
|
|
53
|
+
| 'TOKEN_REFRESH' | 'TOKEN_REUSE_DETECTED' | 'TOKEN_REUSE_GRACED' | 'TOKEN_FINGERPRINT_MISMATCH'
|
|
54
|
+
| 'MFA_VERIFY_SUCCESS' | 'MFA_VERIFY_FAILURE'
|
|
55
|
+
| 'SESSION_EXPIRED_IDLE' | 'SESSION_EXPIRED_ABSOLUTE'
|
|
56
|
+
| 'PASSWORD_BREACH_CHECK_DEGRADED'
|
|
57
|
+
| 'IMPERSONATE';
|
|
58
|
+
actorId?: string;
|
|
59
|
+
identifier?: string;
|
|
60
|
+
method?: 'password' | 'oauth' | 'magic_link' | 'webauthn' | '2fa' | 'api_key';
|
|
61
|
+
ipAddress?: string;
|
|
62
|
+
userAgent?: string;
|
|
63
|
+
outcome?: 'success' | 'failure' | 'pending';
|
|
64
|
+
/** Set on `LOGIN_PENDING` — which additional step the sign-in is waiting on. */
|
|
65
|
+
pendingReason?: PendingReason;
|
|
66
|
+
/** Free-form sub-action label (e.g. the specific fingerprint/session action that fired the event). */
|
|
67
|
+
action?: string;
|
|
68
|
+
error?: { message: string; code?: string };
|
|
69
|
+
metadata?: Record<string, unknown>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Async auth event listener. May return a Promise — if you `return` it, any
|
|
74
|
+
* rejection is routed to `config.logger.error`. Firing work via
|
|
75
|
+
* `void asyncWork()` inside a sync body is an explicit opt-out of that
|
|
76
|
+
* safety net; rejections will escape to the runtime's unhandled-rejection
|
|
77
|
+
* handler instead.
|
|
78
|
+
*/
|
|
79
|
+
export type AuthEventListener = (event: AuthEvent) => void | Promise<void>;
|
|
80
|
+
|
|
81
|
+
interface ResolvedConfig {
|
|
82
|
+
key: JwtKeyMaterial;
|
|
83
|
+
issuer: string;
|
|
84
|
+
audience?: string | string[];
|
|
85
|
+
accessTokenExpiry: number;
|
|
86
|
+
refreshTokenExpiry: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function resolveConfig(config: FortressConfig): ResolvedConfig {
|
|
90
|
+
return {
|
|
91
|
+
key: config.jwt.key,
|
|
92
|
+
issuer: config.jwt.issuer ?? 'fortress',
|
|
93
|
+
audience: config.jwt.audience,
|
|
94
|
+
accessTokenExpiry: config.jwt.accessTokenExpirySeconds ?? 900,
|
|
95
|
+
refreshTokenExpiry: config.jwt.refreshTokenExpirySeconds ?? 604800,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface AuthService {
|
|
100
|
+
login: (identifier: string, password: string, meta?: RequestMeta) => Promise<AuthResult>;
|
|
101
|
+
/** Consume a verified factor's continuation, rerun remaining gates, then issue tokens. */
|
|
102
|
+
completePendingAuth: (continuationToken: string, completion: unknown, meta?: RequestMeta) => Promise<AuthResult>;
|
|
103
|
+
/** Finish a trusted plugin-owned primary credential (for example a magic link). */
|
|
104
|
+
completePluginAuth: (
|
|
105
|
+
userId: string,
|
|
106
|
+
method: Exclude<AuthMethod, 'refresh' | 'impersonation'>,
|
|
107
|
+
meta?: RequestMeta,
|
|
108
|
+
) => Promise<AuthResult>;
|
|
109
|
+
refresh: (refreshToken: string, meta?: RequestMeta) => Promise<AuthTokenPair>;
|
|
110
|
+
logout: (refreshToken: string) => Promise<void>;
|
|
111
|
+
me: (userId: string) => Promise<FortressUser>;
|
|
112
|
+
createUser: (data: CreateUserInput) => Promise<FortressUser>;
|
|
113
|
+
verifyToken: (token: string) => Promise<TokenClaims>;
|
|
114
|
+
signToken: (claims: Omit<TokenClaims, 'iat' | 'exp' | 'aud'>) => Promise<string>;
|
|
115
|
+
listSessions: (userId: string) => Promise<SessionInfo[]>;
|
|
116
|
+
revokeSession: (userId: string, tokenId: string) => Promise<void>;
|
|
117
|
+
revokeAllOtherSessions: (userId: string, currentTokenId: string) => Promise<void>;
|
|
118
|
+
addLoginIdentifier: (userId: string, type: LoginIdentifierType, value: string) => Promise<void>;
|
|
119
|
+
removeLoginIdentifier: (userId: string, type: LoginIdentifierType, value: string) => Promise<void>;
|
|
120
|
+
getLoginIdentifiers: (userId: string) => Promise<LoginIdentifier[]>;
|
|
121
|
+
/**
|
|
122
|
+
* Issue a short-lived, non-renewable access token that lets an admin act as another user.
|
|
123
|
+
* The token carries an RFC 8693 `act` claim identifying the real admin.
|
|
124
|
+
*
|
|
125
|
+
* Requires the admin user to hold `fortress:impersonate`. The built-in HTTP route also enforces this via endpoint metadata.
|
|
126
|
+
*/
|
|
127
|
+
impersonate: (adminUserId: string, targetUserId: string, options?: { reason?: string; expirySeconds?: number }) => Promise<AuthResult>;
|
|
128
|
+
|
|
129
|
+
// ── Admin user management ──────────────────────────────────────────
|
|
130
|
+
listUsers: (options: { limit?: number; offset?: number; search?: string; sortBy?: string; sortDirection?: 'asc' | 'desc' }) => Promise<{ users: FortressUser[]; total: number }>;
|
|
131
|
+
getUserById: (userId: string) => Promise<FortressUser>;
|
|
132
|
+
updateUser: (userId: string, data: { name?: string; email?: string; isActive?: boolean; password?: string }) => Promise<FortressUser>;
|
|
133
|
+
deleteUser: (userId: string) => Promise<void>;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Register a listener for auth lifecycle events. Multiple listeners are
|
|
137
|
+
* supported — each is invoked in registration order. Listener failures
|
|
138
|
+
* never break auth operations; they are routed to the configured logger
|
|
139
|
+
* at `error` level. Returns an unsubscribe function.
|
|
140
|
+
*/
|
|
141
|
+
addAuthObserver: (listener: AuthEventListener) => Unsubscribe;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface AuthServiceDeps {
|
|
145
|
+
logger: FortressLogger;
|
|
146
|
+
telemetry: TelemetryProvider;
|
|
147
|
+
/**
|
|
148
|
+
* Optional histogram instrument for token-verify latency. Provided by
|
|
149
|
+
* `createFortress` from the resolved telemetry provider — pulled in as a
|
|
150
|
+
* dep rather than built inside `auth-service` so the metric catalog
|
|
151
|
+
* stays centralized in one file.
|
|
152
|
+
*/
|
|
153
|
+
tokenVerifyDuration?: Histogram;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function createAuthService(
|
|
157
|
+
db: DatabaseAdapter,
|
|
158
|
+
config: FortressConfig,
|
|
159
|
+
plugins: readonly FortressPlugin[] = [],
|
|
160
|
+
deps?: AuthServiceDeps,
|
|
161
|
+
): AuthService {
|
|
162
|
+
const resolved = resolveConfig(config);
|
|
163
|
+
const evaluationMode = config.rbac?.evaluationMode ?? 'allow-only';
|
|
164
|
+
const hasher: PasswordHasher = config.passwordHasher ?? createDefaultHasher();
|
|
165
|
+
// Real, well-formed Argon2 reference hash used as the timing-oracle dummy in
|
|
166
|
+
// the login "user not found / no password" branch. Computing a real hash here
|
|
167
|
+
// (rather than a hard-coded malformed PHC string) guarantees that the
|
|
168
|
+
// verify() call in that branch runs the *full* KDF and matches the cost of
|
|
169
|
+
// verifying a real user's password, so attackers can't distinguish
|
|
170
|
+
// "user exists" from "user not found" via response timing. Lazily computed
|
|
171
|
+
// once on first miss to avoid paying ~100ms at service construction.
|
|
172
|
+
let dummyHashPromise: Promise<string> | null = null;
|
|
173
|
+
const getDummyHash = (): Promise<string> => {
|
|
174
|
+
if (!dummyHashPromise) {
|
|
175
|
+
// Random input -> the hash is never going to verify against any real
|
|
176
|
+
// password the attacker controls, and is never persisted.
|
|
177
|
+
dummyHashPromise = hasher.hash(`fortress-timing-dummy-${crypto.randomUUID()}`);
|
|
178
|
+
}
|
|
179
|
+
return dummyHashPromise;
|
|
180
|
+
};
|
|
181
|
+
const adapter = createInternalAdapter(db);
|
|
182
|
+
const logger = deps?.logger;
|
|
183
|
+
const tokenVerifyDuration = deps?.tokenVerifyDuration;
|
|
184
|
+
|
|
185
|
+
const authEventListeners = createListenerList<AuthEvent>({
|
|
186
|
+
kind: 'async',
|
|
187
|
+
eventLabel: 'auth',
|
|
188
|
+
logger: () => logger ?? SILENT_LOGGER,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const passwordPolicyObserver: PasswordPolicyObserver = (event) => {
|
|
192
|
+
logger?.warn(
|
|
193
|
+
{ failureMode: event.failureMode, status: event.status, error: event.error },
|
|
194
|
+
'password breach check degraded',
|
|
195
|
+
);
|
|
196
|
+
authEventListeners.emit({
|
|
197
|
+
eventType: 'PASSWORD_BREACH_CHECK_DEGRADED',
|
|
198
|
+
outcome: 'failure',
|
|
199
|
+
metadata: {
|
|
200
|
+
failureMode: event.failureMode,
|
|
201
|
+
...(event.status !== undefined && { status: event.status }),
|
|
202
|
+
...(event.error instanceof Error && { error: event.error.message }),
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
async function runBeforeHooks<T extends Record<string, unknown>>(
|
|
208
|
+
hookName: 'beforeLogin' | 'beforeRegister' | 'beforeTokenRefresh' | 'beforeLogout',
|
|
209
|
+
ctx: HookContext & T,
|
|
210
|
+
): Promise<HookResult | void> {
|
|
211
|
+
for (const plugin of plugins) {
|
|
212
|
+
const hook = plugin.hooks?.[hookName] as ((ctx: HookContext & T) => Promise<HookResult | void>) | undefined;
|
|
213
|
+
if (hook) {
|
|
214
|
+
const result = await hook(ctx);
|
|
215
|
+
if (result?.stop)
|
|
216
|
+
return result;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// After-* hooks run once the authentication decision is committed and the
|
|
222
|
+
// session tokens have already been issued/persisted. A failing side-effect
|
|
223
|
+
// hook (audit, webhook, analytics) must not roll back a completed login —
|
|
224
|
+
// doing so would return an error to the caller while leaving a live session
|
|
225
|
+
// behind, and would let any plugin brick authentication by throwing. So a
|
|
226
|
+
// throwing after-hook is logged and skipped (keeping the last good result),
|
|
227
|
+
// mirroring runOnLoginFailureHooks. Plugins that need to *veto* a login must
|
|
228
|
+
// use a pre-issuance hook (beforeLogin / postAuthGate), not afterLogin.
|
|
229
|
+
async function runAfterLoginHooks(
|
|
230
|
+
ctx: AfterHookContext,
|
|
231
|
+
result: AuthSuccess,
|
|
232
|
+
): Promise<AuthSuccess> {
|
|
233
|
+
let current = result;
|
|
234
|
+
for (const plugin of plugins) {
|
|
235
|
+
if (plugin.hooks?.afterLogin) {
|
|
236
|
+
try {
|
|
237
|
+
current = await plugin.hooks.afterLogin(ctx, current);
|
|
238
|
+
}
|
|
239
|
+
catch (hookError) {
|
|
240
|
+
logger?.error({ plugin: plugin.name, error: hookError }, 'afterLogin hook failed');
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return current;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function runAfterRefreshHooks(
|
|
248
|
+
ctx: AfterHookContext,
|
|
249
|
+
result: AuthTokenPair,
|
|
250
|
+
): Promise<AuthTokenPair> {
|
|
251
|
+
let current = result;
|
|
252
|
+
for (const plugin of plugins) {
|
|
253
|
+
if (plugin.hooks?.afterTokenRefresh) {
|
|
254
|
+
try {
|
|
255
|
+
current = await plugin.hooks.afterTokenRefresh(ctx, current);
|
|
256
|
+
}
|
|
257
|
+
catch (hookError) {
|
|
258
|
+
logger?.error({ plugin: plugin.name, error: hookError }, 'afterTokenRefresh hook failed');
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return current;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function runOnLoginFailureHooks(identifier: string, error: Error): Promise<void> {
|
|
266
|
+
for (const plugin of plugins) {
|
|
267
|
+
if (!plugin.hooks?.onLoginFailure)
|
|
268
|
+
continue;
|
|
269
|
+
try {
|
|
270
|
+
await plugin.hooks.onLoginFailure({ db, config, identifier, error });
|
|
271
|
+
}
|
|
272
|
+
catch (hookError) {
|
|
273
|
+
logger?.error(
|
|
274
|
+
{ plugin: plugin.name, error: hookError },
|
|
275
|
+
'onLoginFailure hook failed',
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function enrichClaims(userId: string): Promise<Record<string, unknown>> {
|
|
282
|
+
const customClaims: Record<string, unknown> = {};
|
|
283
|
+
for (const plugin of plugins) {
|
|
284
|
+
if (plugin.enrichTokenClaims) {
|
|
285
|
+
const claims = await plugin.enrichTokenClaims(userId, { db, config });
|
|
286
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
287
|
+
for (const key of Object.keys(claims)) {
|
|
288
|
+
if (key in customClaims) {
|
|
289
|
+
logger?.warn(
|
|
290
|
+
{ plugin: plugin.name, claim: key },
|
|
291
|
+
`plugin overwrites token claim '${key}'`,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
Object.assign(customClaims, claims);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return customClaims;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const getUserGroups = adapter.getUserGroups;
|
|
303
|
+
|
|
304
|
+
const jwtSecrets = Array.isArray(resolved.key) ? resolved.key : [resolved.key];
|
|
305
|
+
async function computeFingerprintHash(meta: RequestMeta, secret = jwtSecrets[0]!): Promise<string | null> {
|
|
306
|
+
return meta.userAgent
|
|
307
|
+
? hashRefreshFingerprint(meta.userAgent, meta.ipAddress, secret)
|
|
308
|
+
: null;
|
|
309
|
+
}
|
|
310
|
+
async function fingerprintMatches(stored: string, meta: RequestMeta): Promise<boolean> {
|
|
311
|
+
if (!meta.userAgent)
|
|
312
|
+
return false;
|
|
313
|
+
for (const secret of jwtSecrets) {
|
|
314
|
+
if (await hashRefreshFingerprint(meta.userAgent, meta.ipAddress, secret) === stored)
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function issueAccessToken(
|
|
321
|
+
user: FortressUser,
|
|
322
|
+
resolveGroups: (userId: string) => Promise<string[]>,
|
|
323
|
+
): Promise<string> {
|
|
324
|
+
const groups = await resolveGroups(user.id);
|
|
325
|
+
const customClaims = await enrichClaims(user.id);
|
|
326
|
+
return signAccessToken(
|
|
327
|
+
{
|
|
328
|
+
sub: user.id,
|
|
329
|
+
subjectType: 'USER',
|
|
330
|
+
name: user.name,
|
|
331
|
+
groups,
|
|
332
|
+
iss: resolved.issuer,
|
|
333
|
+
customClaims: Object.keys(customClaims).length > 0 ? customClaims : undefined,
|
|
334
|
+
},
|
|
335
|
+
resolved.key,
|
|
336
|
+
resolved.accessTokenExpiry,
|
|
337
|
+
{ audience: resolved.audience },
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function issueTokens(
|
|
342
|
+
user: FortressUser,
|
|
343
|
+
meta?: RequestMeta,
|
|
344
|
+
): Promise<{ accessToken: string; refreshToken: string }> {
|
|
345
|
+
const accessToken = await issueAccessToken(user, getUserGroups);
|
|
346
|
+
const { raw, hash } = await generateRefreshToken();
|
|
347
|
+
const family = generateTokenFamily();
|
|
348
|
+
const issuedAt = new Date();
|
|
349
|
+
|
|
350
|
+
const fingerprintHash = meta ? await computeFingerprintHash(meta) : null;
|
|
351
|
+
|
|
352
|
+
const maxSessions = config.jwt.session?.maxSessionsPerUser;
|
|
353
|
+
const persistSession = async (target: DatabaseAdapter): Promise<void> => {
|
|
354
|
+
if (maxSessions != null && maxSessions > 0) {
|
|
355
|
+
// PostgreSQL needs a per-user lock; transaction isolation alone lets
|
|
356
|
+
// concurrent logins both observe spare capacity. A no-op update takes
|
|
357
|
+
// that row lock without bypassing DatabaseAdapter model/table mapping.
|
|
358
|
+
// SQLite's BEGIN IMMEDIATE transaction already serializes writers.
|
|
359
|
+
if (target.dialect === 'pg') {
|
|
360
|
+
const locked = await target.update({
|
|
361
|
+
model: 'user',
|
|
362
|
+
where: [{ field: 'id', operator: '=', value: user.id }],
|
|
363
|
+
// Reassign the immutable primary key to itself: this acquires the
|
|
364
|
+
// row lock without risking stale mutable-field writeback.
|
|
365
|
+
data: { id: user.id },
|
|
366
|
+
});
|
|
367
|
+
if (!locked)
|
|
368
|
+
throw Errors.notFound('User not found');
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const active = (await target.findMany<StoredRefreshToken>({
|
|
372
|
+
model: 'refresh_token',
|
|
373
|
+
where: [
|
|
374
|
+
{ field: 'userId', operator: '=', value: user.id },
|
|
375
|
+
{ field: 'isRevoked', operator: '=', value: false },
|
|
376
|
+
],
|
|
377
|
+
sortBy: { field: 'familyCreatedAt', direction: 'asc' },
|
|
378
|
+
})).filter(token => token.expiresAt > issuedAt);
|
|
379
|
+
const overflow = active.length - maxSessions + 1;
|
|
380
|
+
for (const token of active.slice(0, Math.max(0, overflow))) {
|
|
381
|
+
await target.update({
|
|
382
|
+
model: 'refresh_token',
|
|
383
|
+
where: [{ field: 'tokenFamily', operator: '=', value: token.tokenFamily }],
|
|
384
|
+
data: { isRevoked: true },
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
await target.create({
|
|
390
|
+
model: 'refresh_token',
|
|
391
|
+
data: {
|
|
392
|
+
userId: user.id,
|
|
393
|
+
tokenHash: hash,
|
|
394
|
+
tokenFamily: family,
|
|
395
|
+
familyCreatedAt: issuedAt,
|
|
396
|
+
successorTokenHash: null,
|
|
397
|
+
rotatedAt: null,
|
|
398
|
+
isRevoked: false,
|
|
399
|
+
expiresAt: new Date(issuedAt.getTime() + resolved.refreshTokenExpiry * 1000),
|
|
400
|
+
ipAddress: meta?.ipAddress ?? null,
|
|
401
|
+
userAgent: meta?.userAgent ?? null,
|
|
402
|
+
deviceName: meta?.deviceName ?? null,
|
|
403
|
+
lastActiveAt: issuedAt,
|
|
404
|
+
fingerprintHash,
|
|
405
|
+
},
|
|
406
|
+
});
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
if (maxSessions != null)
|
|
410
|
+
await db.transaction(persistSession);
|
|
411
|
+
else
|
|
412
|
+
await persistSession(db);
|
|
413
|
+
|
|
414
|
+
return { accessToken, refreshToken: raw };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
type CompletedAuthMethod = Exclude<AuthMethod, 'refresh' | 'impersonation'>;
|
|
418
|
+
|
|
419
|
+
function eventMethodFor(method: CompletedAuthMethod): AuthEvent['method'] {
|
|
420
|
+
switch (method) {
|
|
421
|
+
case 'two-factor':
|
|
422
|
+
return '2fa';
|
|
423
|
+
case 'magic-link':
|
|
424
|
+
return 'magic_link';
|
|
425
|
+
default:
|
|
426
|
+
return method;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async function finishAuthentication(
|
|
431
|
+
user: FortressUser,
|
|
432
|
+
method: CompletedAuthMethod,
|
|
433
|
+
meta?: RequestMeta,
|
|
434
|
+
identifier?: string,
|
|
435
|
+
completedReasons: readonly PendingReason[] = [],
|
|
436
|
+
pluginData?: Record<string, unknown>,
|
|
437
|
+
): Promise<AuthResult> {
|
|
438
|
+
const hold = await runPostAuthGates(plugins, db, config, user, meta, completedReasons);
|
|
439
|
+
if (hold) {
|
|
440
|
+
const pending: AuthResult = {
|
|
441
|
+
status: 'pending',
|
|
442
|
+
user,
|
|
443
|
+
pending: hold.challenge,
|
|
444
|
+
pluginData: { ...hold.pluginData, ...pluginData },
|
|
445
|
+
};
|
|
446
|
+
if (authEventListeners.size() > 0) {
|
|
447
|
+
authEventListeners.emit({
|
|
448
|
+
eventType: 'LOGIN_PENDING',
|
|
449
|
+
actorId: user.id,
|
|
450
|
+
identifier,
|
|
451
|
+
method: eventMethodFor(method),
|
|
452
|
+
ipAddress: meta?.ipAddress,
|
|
453
|
+
userAgent: meta?.userAgent,
|
|
454
|
+
outcome: 'pending',
|
|
455
|
+
pendingReason: hold.challenge.reason,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
return pending;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const tokens = await issueTokens(user, meta);
|
|
462
|
+
let response: AuthSuccess = {
|
|
463
|
+
status: 'success',
|
|
464
|
+
user,
|
|
465
|
+
method,
|
|
466
|
+
accessToken: tokens.accessToken,
|
|
467
|
+
refreshToken: tokens.refreshToken,
|
|
468
|
+
...(pluginData && Object.keys(pluginData).length > 0 ? { pluginData } : {}),
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
const afterCtx: AfterHookContext = { db, config, meta, responseHeaders: new Headers(), identifier };
|
|
472
|
+
response = await runAfterLoginHooks(afterCtx, response);
|
|
473
|
+
|
|
474
|
+
if (authEventListeners.size() > 0) {
|
|
475
|
+
authEventListeners.emit({
|
|
476
|
+
eventType: 'LOGIN_SUCCESS',
|
|
477
|
+
actorId: user.id,
|
|
478
|
+
identifier,
|
|
479
|
+
method: eventMethodFor(method),
|
|
480
|
+
ipAddress: meta?.ipAddress,
|
|
481
|
+
userAgent: meta?.userAgent,
|
|
482
|
+
outcome: 'success',
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return response;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return {
|
|
490
|
+
async login(identifier: string, password: string, meta?: RequestMeta): Promise<AuthResult> {
|
|
491
|
+
const normalizedPassword = normalizePasswordInput(password);
|
|
492
|
+
const hookCtx: HookContext & { email: string } = { db, config, meta, email: identifier };
|
|
493
|
+
const beforeResult = await runBeforeHooks('beforeLogin', hookCtx);
|
|
494
|
+
if (beforeResult?.stop) {
|
|
495
|
+
return beforeResult.response as unknown as AuthResult;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Resolve user via login_identifier first, fall back to email on user table
|
|
499
|
+
let user: (FortressUser & { passwordHash: string | null }) | null;
|
|
500
|
+
try {
|
|
501
|
+
user = await adapter.findUserByIdentifier(identifier);
|
|
502
|
+
|
|
503
|
+
if (!user || !user.passwordHash) {
|
|
504
|
+
// Run a real Argon2 verify against a well-formed reference hash so
|
|
505
|
+
// this branch takes the same wall-clock time as verifying a real
|
|
506
|
+
// user's password. The previous hard-coded PHC string was malformed
|
|
507
|
+
// (`$...$dummy`), so hash-wasm's parser threw before running the
|
|
508
|
+
// KDF and the branch completed in ~0.3ms instead of ~100ms — a
|
|
509
|
+
// trivial timing oracle for user enumeration.
|
|
510
|
+
await hasher.verify(await getDummyHash(), normalizedPassword).catch(() => {});
|
|
511
|
+
throw Errors.unauthorized('Invalid credentials');
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Constant-time + constant-message handling for disabled accounts:
|
|
515
|
+
// still run the Argon2 verify so an attacker can't distinguish
|
|
516
|
+
// "account exists but disabled" from "wrong password" via timing
|
|
517
|
+
// *or* via error message. Both paths return the generic
|
|
518
|
+
// `Invalid credentials` (M3).
|
|
519
|
+
const valid = await hasher.verify(user.passwordHash, normalizedPassword);
|
|
520
|
+
if (!user.isActive || !valid) {
|
|
521
|
+
throw Errors.unauthorized('Invalid credentials');
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
catch (error) {
|
|
525
|
+
await runOnLoginFailureHooks(identifier, error as Error);
|
|
526
|
+
if (authEventListeners.size() > 0) {
|
|
527
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
528
|
+
authEventListeners.emit({
|
|
529
|
+
eventType: 'LOGIN_FAILURE',
|
|
530
|
+
identifier,
|
|
531
|
+
method: 'password',
|
|
532
|
+
ipAddress: meta?.ipAddress,
|
|
533
|
+
userAgent: meta?.userAgent,
|
|
534
|
+
outcome: 'failure',
|
|
535
|
+
error: {
|
|
536
|
+
message: err.message,
|
|
537
|
+
code: err instanceof FortressError ? err.code : undefined,
|
|
538
|
+
},
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
throw error;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const { passwordHash: _, ...safeUser } = user;
|
|
545
|
+
return finishAuthentication(safeUser, 'password', meta, identifier);
|
|
546
|
+
},
|
|
547
|
+
|
|
548
|
+
async completePluginAuth(
|
|
549
|
+
userId: string,
|
|
550
|
+
method: CompletedAuthMethod,
|
|
551
|
+
meta?: RequestMeta,
|
|
552
|
+
): Promise<AuthResult> {
|
|
553
|
+
const storedUser = await db.findOne<FortressUser & { passwordHash?: string | null }>({
|
|
554
|
+
model: 'user',
|
|
555
|
+
where: [{ field: 'id', operator: '=', value: userId }],
|
|
556
|
+
});
|
|
557
|
+
if (!storedUser || !storedUser.isActive)
|
|
558
|
+
throw Errors.unauthorized('User not found or disabled');
|
|
559
|
+
const { passwordHash: _, ...user } = storedUser;
|
|
560
|
+
return finishAuthentication(user, method, meta);
|
|
561
|
+
},
|
|
562
|
+
|
|
563
|
+
async completePendingAuth(continuationToken: string, completion: unknown, meta?: RequestMeta): Promise<AuthResult> {
|
|
564
|
+
let attempted: { reason: PendingReason; userId: string } | undefined;
|
|
565
|
+
let verificationData: Record<string, unknown> | undefined;
|
|
566
|
+
let continuation: StoredContinuation;
|
|
567
|
+
try {
|
|
568
|
+
continuation = await consumeAuthContinuation(db, continuationToken, async (tx, pending) => {
|
|
569
|
+
attempted = { reason: pending.reason, userId: pending.userId };
|
|
570
|
+
const storedUser = await tx.findOne<FortressUser & { passwordHash?: string | null }>({
|
|
571
|
+
model: 'user',
|
|
572
|
+
where: [{ field: 'id', operator: '=', value: pending.userId }],
|
|
573
|
+
});
|
|
574
|
+
if (!storedUser || !storedUser.isActive)
|
|
575
|
+
throw Errors.unauthorized('User not found or disabled');
|
|
576
|
+
const { passwordHash: _, ...user } = storedUser;
|
|
577
|
+
verificationData = (await verifyAuthContinuation(plugins, tx, config, user, pending, completion, meta)) ?? undefined;
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
catch (error) {
|
|
581
|
+
if (attempted && (attempted.reason === 'two-factor' || attempted.reason === 'webauthn')) {
|
|
582
|
+
authEventListeners.emit({
|
|
583
|
+
eventType: 'MFA_VERIFY_FAILURE',
|
|
584
|
+
actorId: attempted.userId,
|
|
585
|
+
method: attempted.reason === 'two-factor' ? '2fa' : 'webauthn',
|
|
586
|
+
ipAddress: meta?.ipAddress,
|
|
587
|
+
userAgent: meta?.userAgent,
|
|
588
|
+
outcome: 'failure',
|
|
589
|
+
error: { message: error instanceof Error ? error.message : String(error) },
|
|
590
|
+
});
|
|
591
|
+
// A wrong second factor is a failed authentication attempt: feed it
|
|
592
|
+
// into the onLoginFailure hooks (account-lockout, audit, webhook)
|
|
593
|
+
// keyed by the user's identifier so a brute-forced 2FA/WebAuthn step
|
|
594
|
+
// is throttled and locked out like a password failure, not just
|
|
595
|
+
// capped per continuation. Guarded so a lookup/hook error can never
|
|
596
|
+
// mask the original verification error.
|
|
597
|
+
try {
|
|
598
|
+
const failedUser = await db.findOne<FortressUser>({
|
|
599
|
+
model: 'user',
|
|
600
|
+
where: [{ field: 'id', operator: '=', value: attempted.userId }],
|
|
601
|
+
});
|
|
602
|
+
if (failedUser?.email)
|
|
603
|
+
await runOnLoginFailureHooks(failedUser.email, error as Error);
|
|
604
|
+
}
|
|
605
|
+
catch (lockoutError) {
|
|
606
|
+
logger?.error({ error: lockoutError }, 'failed to record MFA failure for account lockout');
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
throw error;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
if (continuation.reason === 'two-factor' || continuation.reason === 'webauthn') {
|
|
613
|
+
authEventListeners.emit({
|
|
614
|
+
eventType: 'MFA_VERIFY_SUCCESS',
|
|
615
|
+
actorId: continuation.userId,
|
|
616
|
+
method: continuation.reason === 'two-factor' ? '2fa' : 'webauthn',
|
|
617
|
+
ipAddress: meta?.ipAddress,
|
|
618
|
+
userAgent: meta?.userAgent,
|
|
619
|
+
outcome: 'success',
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const storedUser = await db.findOne<FortressUser & { passwordHash?: string | null }>({
|
|
624
|
+
model: 'user',
|
|
625
|
+
where: [{ field: 'id', operator: '=', value: continuation.userId }],
|
|
626
|
+
});
|
|
627
|
+
if (!storedUser || !storedUser.isActive)
|
|
628
|
+
throw Errors.unauthorized('User not found or disabled');
|
|
629
|
+
const { passwordHash: _, ...refreshedUser } = storedUser;
|
|
630
|
+
|
|
631
|
+
const method: CompletedAuthMethod = continuation.reason === 'two-factor'
|
|
632
|
+
? 'two-factor'
|
|
633
|
+
: continuation.reason === 'magic-link'
|
|
634
|
+
? 'magic-link'
|
|
635
|
+
: continuation.reason === 'webauthn'
|
|
636
|
+
? 'webauthn'
|
|
637
|
+
: 'password';
|
|
638
|
+
|
|
639
|
+
return finishAuthentication(refreshedUser, method, meta, undefined, [continuation.reason], verificationData);
|
|
640
|
+
},
|
|
641
|
+
|
|
642
|
+
async refresh(refreshToken: string, meta?: RequestMeta): Promise<AuthTokenPair> {
|
|
643
|
+
const hookCtx: HookContext & { token: string } = { db, config, meta, token: refreshToken };
|
|
644
|
+
const beforeResult = await runBeforeHooks('beforeTokenRefresh', hookCtx);
|
|
645
|
+
if (beforeResult?.stop) {
|
|
646
|
+
return beforeResult.response as unknown as AuthTokenPair;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const tokenHash = await hashToken(refreshToken);
|
|
650
|
+
|
|
651
|
+
const txResult = await db.transaction(async (tx) => {
|
|
652
|
+
const txAdapter = createInternalAdapter(tx);
|
|
653
|
+
|
|
654
|
+
// Atomic compare-and-set claim: exactly one concurrent refresh can
|
|
655
|
+
// flip isRevoked=false → true for this token hash. Losers see null
|
|
656
|
+
// and take the replay path below.
|
|
657
|
+
const stored = await tx.update<StoredRefreshToken>({
|
|
658
|
+
model: 'refresh_token',
|
|
659
|
+
where: [
|
|
660
|
+
{ field: 'tokenHash', operator: '=', value: tokenHash },
|
|
661
|
+
{ field: 'isRevoked', operator: '=', value: false },
|
|
662
|
+
],
|
|
663
|
+
data: { isRevoked: true },
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
if (!stored) {
|
|
667
|
+
const reused = await txAdapter.findRefreshTokenByHash(tokenHash);
|
|
668
|
+
if (!reused)
|
|
669
|
+
throw Errors.unauthorized('Invalid refresh token');
|
|
670
|
+
|
|
671
|
+
const graceSeconds = config.jwt.session?.refreshGraceSeconds;
|
|
672
|
+
const now = new Date();
|
|
673
|
+
const withinGrace = graceSeconds != null
|
|
674
|
+
&& graceSeconds > 0
|
|
675
|
+
&& reused.rotatedAt != null
|
|
676
|
+
&& now.getTime() - reused.rotatedAt.getTime() <= graceSeconds * 1000;
|
|
677
|
+
if (withinGrace && reused.successorTokenHash) {
|
|
678
|
+
const fingerprintMismatch = reused.fingerprintHash != null
|
|
679
|
+
&& (!meta || !(await fingerprintMatches(reused.fingerprintHash, meta)));
|
|
680
|
+
const fingerprintAllowsGrace = !fingerprintMismatch
|
|
681
|
+
|| config.jwt.validateRefreshFingerprint !== true;
|
|
682
|
+
if (fingerprintMismatch && config.jwt.validateRefreshFingerprint === 'warn') {
|
|
683
|
+
logger?.warn(
|
|
684
|
+
{ tokenFamily: reused.tokenFamily },
|
|
685
|
+
'refresh token fingerprint mismatch during grace recovery',
|
|
686
|
+
);
|
|
687
|
+
authEventListeners.emit({
|
|
688
|
+
eventType: 'TOKEN_FINGERPRINT_MISMATCH',
|
|
689
|
+
actorId: reused.userId,
|
|
690
|
+
ipAddress: meta?.ipAddress,
|
|
691
|
+
userAgent: meta?.userAgent,
|
|
692
|
+
metadata: { tokenFamily: reused.tokenFamily, action: 'grace-recovery' },
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
if (fingerprintAllowsGrace) {
|
|
696
|
+
const secrets = Array.isArray(resolved.key) ? resolved.key : [resolved.key];
|
|
697
|
+
let successorToken: Awaited<ReturnType<typeof deriveRefreshTokenSuccessor>> | null = null;
|
|
698
|
+
for (const secret of secrets) {
|
|
699
|
+
const candidate = await deriveRefreshTokenSuccessor(refreshToken, secret);
|
|
700
|
+
if (candidate.hash === reused.successorTokenHash) {
|
|
701
|
+
successorToken = candidate;
|
|
702
|
+
break;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
const successor = successorToken
|
|
706
|
+
? await tx.findOne<StoredRefreshToken>({
|
|
707
|
+
model: 'refresh_token',
|
|
708
|
+
where: [
|
|
709
|
+
{ field: 'tokenHash', operator: '=', value: successorToken.hash },
|
|
710
|
+
{ field: 'isRevoked', operator: '=', value: false },
|
|
711
|
+
{ field: 'expiresAt', operator: 'gt', value: now },
|
|
712
|
+
],
|
|
713
|
+
})
|
|
714
|
+
: null;
|
|
715
|
+
if (successor) {
|
|
716
|
+
const absoluteTimeout = config.jwt.session?.absoluteTimeoutSeconds;
|
|
717
|
+
if (absoluteTimeout != null && absoluteTimeout > 0 && now.getTime() - successor.familyCreatedAt.getTime() > absoluteTimeout * 1000) {
|
|
718
|
+
await tx.update({
|
|
719
|
+
model: 'refresh_token',
|
|
720
|
+
where: [{ field: 'tokenFamily', operator: '=', value: successor.tokenFamily }],
|
|
721
|
+
data: { isRevoked: true },
|
|
722
|
+
});
|
|
723
|
+
return {
|
|
724
|
+
sessionExpired: 'absolute' as const,
|
|
725
|
+
userId: successor.userId,
|
|
726
|
+
tokenFamily: successor.tokenFamily,
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const idleTimeout = config.jwt.session?.idleTimeoutSeconds;
|
|
731
|
+
const lastActivity = successor.lastActiveAt ?? successor.familyCreatedAt;
|
|
732
|
+
if (idleTimeout != null && idleTimeout > 0 && now.getTime() - lastActivity.getTime() > idleTimeout * 1000) {
|
|
733
|
+
await tx.update({
|
|
734
|
+
model: 'refresh_token',
|
|
735
|
+
where: [{ field: 'tokenFamily', operator: '=', value: successor.tokenFamily }],
|
|
736
|
+
data: { isRevoked: true },
|
|
737
|
+
});
|
|
738
|
+
return {
|
|
739
|
+
sessionExpired: 'idle' as const,
|
|
740
|
+
userId: successor.userId,
|
|
741
|
+
tokenFamily: successor.tokenFamily,
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const user = await tx.findOne<FortressUser>({
|
|
746
|
+
model: 'user',
|
|
747
|
+
where: [{ field: 'id', operator: '=', value: successor.userId }],
|
|
748
|
+
});
|
|
749
|
+
if (user?.isActive) {
|
|
750
|
+
return {
|
|
751
|
+
graced: true as const,
|
|
752
|
+
userId: user.id,
|
|
753
|
+
tokenFamily: reused.tokenFamily,
|
|
754
|
+
tokens: {
|
|
755
|
+
accessToken: await issueAccessToken(user, txAdapter.getUserGroups),
|
|
756
|
+
refreshToken: successorToken!.raw,
|
|
757
|
+
} satisfies AuthTokenPair,
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
await tx.update({
|
|
765
|
+
model: 'refresh_token',
|
|
766
|
+
where: [{ field: 'tokenFamily', operator: '=', value: reused.tokenFamily }],
|
|
767
|
+
data: { isRevoked: true },
|
|
768
|
+
});
|
|
769
|
+
// Do not throw inside the transaction: throwing would roll back
|
|
770
|
+
// the family revocation. Return a sentinel, commit, then throw.
|
|
771
|
+
return {
|
|
772
|
+
replayDetected: true as const,
|
|
773
|
+
userId: reused.userId,
|
|
774
|
+
tokenFamily: reused.tokenFamily,
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const now = new Date();
|
|
779
|
+
if (stored.expiresAt < now)
|
|
780
|
+
throw Errors.unauthorized('Refresh token expired');
|
|
781
|
+
|
|
782
|
+
const absoluteTimeout = config.jwt.session?.absoluteTimeoutSeconds;
|
|
783
|
+
if (absoluteTimeout != null && absoluteTimeout > 0 && now.getTime() - stored.familyCreatedAt.getTime() > absoluteTimeout * 1000) {
|
|
784
|
+
await tx.update({
|
|
785
|
+
model: 'refresh_token',
|
|
786
|
+
where: [{ field: 'tokenFamily', operator: '=', value: stored.tokenFamily }],
|
|
787
|
+
data: { isRevoked: true },
|
|
788
|
+
});
|
|
789
|
+
return {
|
|
790
|
+
sessionExpired: 'absolute' as const,
|
|
791
|
+
userId: stored.userId,
|
|
792
|
+
tokenFamily: stored.tokenFamily,
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
const idleTimeout = config.jwt.session?.idleTimeoutSeconds;
|
|
797
|
+
const lastActivity = stored.lastActiveAt ?? stored.familyCreatedAt;
|
|
798
|
+
if (idleTimeout != null && idleTimeout > 0 && now.getTime() - lastActivity.getTime() > idleTimeout * 1000) {
|
|
799
|
+
await tx.update({
|
|
800
|
+
model: 'refresh_token',
|
|
801
|
+
where: [{ field: 'tokenFamily', operator: '=', value: stored.tokenFamily }],
|
|
802
|
+
data: { isRevoked: true },
|
|
803
|
+
});
|
|
804
|
+
return {
|
|
805
|
+
sessionExpired: 'idle' as const,
|
|
806
|
+
userId: stored.userId,
|
|
807
|
+
tokenFamily: stored.tokenFamily,
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// Token fingerprint validation
|
|
812
|
+
if (config.jwt.validateRefreshFingerprint && stored.fingerprintHash) {
|
|
813
|
+
const currentFingerprintMatches = meta
|
|
814
|
+
? await fingerprintMatches(stored.fingerprintHash, meta)
|
|
815
|
+
: false;
|
|
816
|
+
|
|
817
|
+
if (!currentFingerprintMatches) {
|
|
818
|
+
if (config.jwt.validateRefreshFingerprint === true) {
|
|
819
|
+
// Do not throw inside the transaction: the family revocation
|
|
820
|
+
// must commit before the caller receives the rejection.
|
|
821
|
+
await tx.update({
|
|
822
|
+
model: 'refresh_token',
|
|
823
|
+
where: [{ field: 'tokenFamily', operator: '=', value: stored.tokenFamily }],
|
|
824
|
+
data: { isRevoked: true },
|
|
825
|
+
});
|
|
826
|
+
return {
|
|
827
|
+
fingerprintMismatch: true as const,
|
|
828
|
+
userId: stored.userId,
|
|
829
|
+
tokenFamily: stored.tokenFamily,
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
else {
|
|
833
|
+
// Warn mode: log but allow
|
|
834
|
+
logger?.warn(
|
|
835
|
+
{ tokenFamily: stored.tokenFamily },
|
|
836
|
+
'refresh token fingerprint mismatch',
|
|
837
|
+
);
|
|
838
|
+
if (authEventListeners.size() > 0) {
|
|
839
|
+
authEventListeners.emit({
|
|
840
|
+
eventType: 'TOKEN_FINGERPRINT_MISMATCH',
|
|
841
|
+
actorId: stored.userId,
|
|
842
|
+
ipAddress: meta?.ipAddress,
|
|
843
|
+
userAgent: meta?.userAgent,
|
|
844
|
+
metadata: { tokenFamily: stored.tokenFamily },
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// Get user
|
|
852
|
+
const user = await tx.findOne<FortressUser>({
|
|
853
|
+
model: 'user',
|
|
854
|
+
where: [{ field: 'id', operator: '=', value: stored.userId }],
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
if (!user || !user.isActive) {
|
|
858
|
+
throw Errors.unauthorized('User not found or disabled');
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// Issue new tokens with same family. Grace-enabled rotations derive
|
|
862
|
+
// the successor so a benign retry can recompute the raw token while
|
|
863
|
+
// the database stores only its hash.
|
|
864
|
+
const accessToken = await issueAccessToken(user, txAdapter.getUserGroups);
|
|
865
|
+
const graceSeconds = config.jwt.session?.refreshGraceSeconds;
|
|
866
|
+
const newToken = graceSeconds != null && graceSeconds > 0
|
|
867
|
+
? await deriveRefreshTokenSuccessor(
|
|
868
|
+
refreshToken,
|
|
869
|
+
Array.isArray(resolved.key) ? resolved.key[0] : resolved.key,
|
|
870
|
+
)
|
|
871
|
+
: await generateRefreshToken();
|
|
872
|
+
|
|
873
|
+
const newFingerprintHash = meta
|
|
874
|
+
? (await computeFingerprintHash(meta) ?? stored.fingerprintHash)
|
|
875
|
+
: stored.fingerprintHash;
|
|
876
|
+
|
|
877
|
+
const rotatedAt = new Date();
|
|
878
|
+
await tx.update({
|
|
879
|
+
model: 'refresh_token',
|
|
880
|
+
where: [{ field: 'id', operator: '=', value: stored.id }],
|
|
881
|
+
data: { successorTokenHash: newToken.hash, rotatedAt },
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
await tx.create({
|
|
885
|
+
model: 'refresh_token',
|
|
886
|
+
data: {
|
|
887
|
+
userId: stored.userId,
|
|
888
|
+
tokenHash: newToken.hash,
|
|
889
|
+
tokenFamily: stored.tokenFamily, // same family for rotation tracking
|
|
890
|
+
familyCreatedAt: stored.familyCreatedAt,
|
|
891
|
+
successorTokenHash: null,
|
|
892
|
+
rotatedAt: null,
|
|
893
|
+
isRevoked: false,
|
|
894
|
+
expiresAt: new Date(rotatedAt.getTime() + resolved.refreshTokenExpiry * 1000),
|
|
895
|
+
ipAddress: meta?.ipAddress ?? stored.ipAddress,
|
|
896
|
+
userAgent: meta?.userAgent ?? stored.userAgent,
|
|
897
|
+
deviceName: meta?.deviceName ?? stored.deviceName,
|
|
898
|
+
lastActiveAt: rotatedAt,
|
|
899
|
+
fingerprintHash: newFingerprintHash,
|
|
900
|
+
},
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
return {
|
|
904
|
+
userId: user.id,
|
|
905
|
+
tokens: {
|
|
906
|
+
accessToken,
|
|
907
|
+
refreshToken: newToken.raw,
|
|
908
|
+
} satisfies AuthTokenPair,
|
|
909
|
+
};
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
if ('fingerprintMismatch' in txResult) {
|
|
913
|
+
authEventListeners.emit({
|
|
914
|
+
eventType: 'TOKEN_FINGERPRINT_MISMATCH',
|
|
915
|
+
actorId: txResult.userId,
|
|
916
|
+
ipAddress: meta?.ipAddress,
|
|
917
|
+
userAgent: meta?.userAgent,
|
|
918
|
+
outcome: 'failure',
|
|
919
|
+
metadata: { tokenFamily: txResult.tokenFamily, action: 'family-revoked' },
|
|
920
|
+
});
|
|
921
|
+
throw Errors.unauthorized('Refresh token fingerprint mismatch');
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
if ('sessionExpired' in txResult) {
|
|
925
|
+
const eventType = txResult.sessionExpired === 'idle'
|
|
926
|
+
? 'SESSION_EXPIRED_IDLE' as const
|
|
927
|
+
: 'SESSION_EXPIRED_ABSOLUTE' as const;
|
|
928
|
+
authEventListeners.emit({
|
|
929
|
+
eventType,
|
|
930
|
+
actorId: txResult.userId,
|
|
931
|
+
ipAddress: meta?.ipAddress,
|
|
932
|
+
userAgent: meta?.userAgent,
|
|
933
|
+
outcome: 'failure',
|
|
934
|
+
metadata: { tokenFamily: txResult.tokenFamily },
|
|
935
|
+
});
|
|
936
|
+
throw txResult.sessionExpired === 'idle'
|
|
937
|
+
? Errors.sessionIdleTimeout()
|
|
938
|
+
: Errors.sessionAbsoluteTimeout();
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
if ('replayDetected' in txResult) {
|
|
942
|
+
if (authEventListeners.size() > 0) {
|
|
943
|
+
authEventListeners.emit({
|
|
944
|
+
eventType: 'TOKEN_REUSE_DETECTED',
|
|
945
|
+
actorId: txResult.userId,
|
|
946
|
+
ipAddress: meta?.ipAddress,
|
|
947
|
+
userAgent: meta?.userAgent,
|
|
948
|
+
metadata: { tokenFamily: txResult.tokenFamily },
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
throw Errors.tokenReuse();
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
if ('graced' in txResult) {
|
|
955
|
+
authEventListeners.emit({
|
|
956
|
+
eventType: 'TOKEN_REUSE_GRACED',
|
|
957
|
+
actorId: txResult.userId,
|
|
958
|
+
ipAddress: meta?.ipAddress,
|
|
959
|
+
userAgent: meta?.userAgent,
|
|
960
|
+
outcome: 'success',
|
|
961
|
+
metadata: { tokenFamily: txResult.tokenFamily },
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
let result: AuthTokenPair = txResult.tokens;
|
|
966
|
+
|
|
967
|
+
const afterCtx: AfterHookContext = { db, config, meta, responseHeaders: new Headers() };
|
|
968
|
+
result = await runAfterRefreshHooks(afterCtx, result);
|
|
969
|
+
|
|
970
|
+
if (authEventListeners.size() > 0) {
|
|
971
|
+
authEventListeners.emit({
|
|
972
|
+
eventType: 'TOKEN_REFRESH',
|
|
973
|
+
actorId: txResult.userId,
|
|
974
|
+
ipAddress: meta?.ipAddress,
|
|
975
|
+
userAgent: meta?.userAgent,
|
|
976
|
+
outcome: 'success',
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
return result;
|
|
981
|
+
},
|
|
982
|
+
|
|
983
|
+
async logout(refreshToken: string): Promise<void> {
|
|
984
|
+
const hookCtx: HookContext & { token: string } = { db, config, token: refreshToken };
|
|
985
|
+
await runBeforeHooks('beforeLogout', hookCtx);
|
|
986
|
+
|
|
987
|
+
const tokenHash = await hashToken(refreshToken);
|
|
988
|
+
|
|
989
|
+
// Look up the user the token belongs to so the LOGOUT event can
|
|
990
|
+
// carry an actorId. If lookup fails (token unknown), emit with
|
|
991
|
+
// actorId undefined — observers still fire.
|
|
992
|
+
let actorId: string | undefined;
|
|
993
|
+
if (authEventListeners.size() > 0) {
|
|
994
|
+
const stored = await adapter.findRefreshTokenByHash(tokenHash);
|
|
995
|
+
if (stored) {
|
|
996
|
+
actorId = stored.userId;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
await db.update({
|
|
1001
|
+
model: 'refresh_token',
|
|
1002
|
+
where: [{ field: 'tokenHash', operator: '=', value: tokenHash }],
|
|
1003
|
+
data: { isRevoked: true },
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
if (authEventListeners.size() > 0) {
|
|
1007
|
+
authEventListeners.emit({
|
|
1008
|
+
eventType: 'LOGOUT',
|
|
1009
|
+
actorId,
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
},
|
|
1013
|
+
|
|
1014
|
+
async me(userId: string): Promise<FortressUser> {
|
|
1015
|
+
const user = await db.findOne<FortressUser>({
|
|
1016
|
+
model: 'user',
|
|
1017
|
+
where: [{ field: 'id', operator: '=', value: userId }],
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
if (!user) {
|
|
1021
|
+
throw Errors.notFound('User not found');
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
const { passwordHash: _, ...safeUser } = user as FortressUser & { passwordHash?: string };
|
|
1025
|
+
return safeUser;
|
|
1026
|
+
},
|
|
1027
|
+
|
|
1028
|
+
async createUser(data: CreateUserInput): Promise<FortressUser> {
|
|
1029
|
+
const normalizedData: CreateUserInput = { ...data, email: normalizeEmail(data.email) };
|
|
1030
|
+
const hookCtx: HookContext & { data: CreateUserInput } = { db, config, data: normalizedData };
|
|
1031
|
+
const beforeResult = await runBeforeHooks('beforeRegister', hookCtx);
|
|
1032
|
+
if (beforeResult?.stop) {
|
|
1033
|
+
return beforeResult.response as unknown as FortressUser;
|
|
1034
|
+
}
|
|
1035
|
+
// Hooks may mutate their data object; restore the canonical identity at
|
|
1036
|
+
// the final persistence boundary rather than trusting hook discipline.
|
|
1037
|
+
normalizedData.email = normalizeEmail(normalizedData.email);
|
|
1038
|
+
|
|
1039
|
+
// Preserve the established friendly duplicate error before spending on
|
|
1040
|
+
// password validation/hash; the transaction repeats this check and the
|
|
1041
|
+
// database constraints remain authoritative for races and aliases.
|
|
1042
|
+
const existing = await db.findOne<{ id: string }>({
|
|
1043
|
+
model: 'user',
|
|
1044
|
+
where: [{ field: 'email', operator: '=', value: normalizedData.email }],
|
|
1045
|
+
});
|
|
1046
|
+
if (existing)
|
|
1047
|
+
throw Errors.conflict('A user with this email already exists');
|
|
1048
|
+
|
|
1049
|
+
const normalizedPassword = normalizedData.password && normalizedData.password.length > 0
|
|
1050
|
+
? normalizePasswordInput(normalizedData.password)
|
|
1051
|
+
: undefined;
|
|
1052
|
+
if (normalizedPassword !== undefined) {
|
|
1053
|
+
await validatePassword(normalizedPassword, config.passwordPolicy, passwordPolicyObserver);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
const passwordHash = normalizedPassword !== undefined ? await hasher.hash(normalizedPassword) : null;
|
|
1057
|
+
|
|
1058
|
+
const user = await db.transaction(async (tx) => {
|
|
1059
|
+
// The user and its canonical login identifier are one identity write:
|
|
1060
|
+
// either both persist or neither does.
|
|
1061
|
+
const existing = await tx.findOne<{ id: string }>({
|
|
1062
|
+
model: 'user',
|
|
1063
|
+
where: [{ field: 'email', operator: '=', value: normalizedData.email }],
|
|
1064
|
+
});
|
|
1065
|
+
if (existing)
|
|
1066
|
+
throw Errors.conflict('A user with this email already exists');
|
|
1067
|
+
|
|
1068
|
+
const created = await tx.create<FortressUser>({
|
|
1069
|
+
model: 'user',
|
|
1070
|
+
data: {
|
|
1071
|
+
email: normalizedData.email,
|
|
1072
|
+
name: normalizedData.name,
|
|
1073
|
+
passwordHash,
|
|
1074
|
+
isActive: normalizedData.isActive ?? true,
|
|
1075
|
+
emailVerified: false,
|
|
1076
|
+
},
|
|
1077
|
+
});
|
|
1078
|
+
await tx.create({
|
|
1079
|
+
model: 'login_identifier',
|
|
1080
|
+
data: { userId: created.id, type: 'email', value: normalizedData.email },
|
|
1081
|
+
});
|
|
1082
|
+
return created;
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
const afterCtx: AfterHookContext = { db, config, responseHeaders: new Headers() };
|
|
1086
|
+
for (const plugin of plugins) {
|
|
1087
|
+
if (plugin.hooks?.afterRegister) {
|
|
1088
|
+
// Fail-open: a throwing afterRegister side-effect must not undo a
|
|
1089
|
+
// committed user creation (see runAfterLoginHooks).
|
|
1090
|
+
try {
|
|
1091
|
+
await plugin.hooks.afterRegister(afterCtx, user);
|
|
1092
|
+
}
|
|
1093
|
+
catch (hookError) {
|
|
1094
|
+
logger?.error({ plugin: plugin.name, error: hookError }, 'afterRegister hook failed');
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
if (authEventListeners.size() > 0) {
|
|
1100
|
+
authEventListeners.emit({
|
|
1101
|
+
eventType: 'REGISTER',
|
|
1102
|
+
actorId: user.id,
|
|
1103
|
+
identifier: normalizedData.email,
|
|
1104
|
+
method: 'password',
|
|
1105
|
+
outcome: 'success',
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
return user;
|
|
1110
|
+
},
|
|
1111
|
+
|
|
1112
|
+
async verifyToken(token: string): Promise<TokenClaims> {
|
|
1113
|
+
const start = performance.now();
|
|
1114
|
+
try {
|
|
1115
|
+
const claims = await verifyAccessToken(token, resolved.key, {
|
|
1116
|
+
issuer: resolved.issuer,
|
|
1117
|
+
...(resolved.audience !== undefined ? { audience: resolved.audience } : {}),
|
|
1118
|
+
});
|
|
1119
|
+
tokenVerifyDuration?.record(
|
|
1120
|
+
(performance.now() - start) / 1000,
|
|
1121
|
+
{ result: 'ok' },
|
|
1122
|
+
);
|
|
1123
|
+
return claims;
|
|
1124
|
+
}
|
|
1125
|
+
catch (err) {
|
|
1126
|
+
tokenVerifyDuration?.record(
|
|
1127
|
+
(performance.now() - start) / 1000,
|
|
1128
|
+
{ result: 'invalid' },
|
|
1129
|
+
);
|
|
1130
|
+
throw err;
|
|
1131
|
+
}
|
|
1132
|
+
},
|
|
1133
|
+
|
|
1134
|
+
async signToken(claims: Omit<TokenClaims, 'iat' | 'exp' | 'aud'>): Promise<string> {
|
|
1135
|
+
return signAccessToken(claims, resolved.key, resolved.accessTokenExpiry, {
|
|
1136
|
+
audience: resolved.audience,
|
|
1137
|
+
});
|
|
1138
|
+
},
|
|
1139
|
+
|
|
1140
|
+
async listSessions(userId: string): Promise<SessionInfo[]> {
|
|
1141
|
+
const tokens = await db.findMany<{
|
|
1142
|
+
id: string;
|
|
1143
|
+
ipAddress: string | null;
|
|
1144
|
+
userAgent: string | null;
|
|
1145
|
+
deviceName: string | null;
|
|
1146
|
+
createdAt: Date;
|
|
1147
|
+
lastActiveAt: Date | null;
|
|
1148
|
+
}>({
|
|
1149
|
+
model: 'refresh_token',
|
|
1150
|
+
where: [
|
|
1151
|
+
{ field: 'userId', operator: '=', value: userId },
|
|
1152
|
+
{ field: 'isRevoked', operator: '=', value: false },
|
|
1153
|
+
{ field: 'expiresAt', operator: 'gt', value: new Date() },
|
|
1154
|
+
],
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
return tokens.map(t => ({
|
|
1158
|
+
id: t.id,
|
|
1159
|
+
ipAddress: t.ipAddress,
|
|
1160
|
+
userAgent: t.userAgent,
|
|
1161
|
+
deviceName: t.deviceName,
|
|
1162
|
+
createdAt: t.createdAt,
|
|
1163
|
+
lastActiveAt: t.lastActiveAt,
|
|
1164
|
+
}));
|
|
1165
|
+
},
|
|
1166
|
+
|
|
1167
|
+
async revokeSession(userId: string, tokenId: string): Promise<void> {
|
|
1168
|
+
await db.update({
|
|
1169
|
+
model: 'refresh_token',
|
|
1170
|
+
where: [
|
|
1171
|
+
{ field: 'id', operator: '=', value: tokenId },
|
|
1172
|
+
{ field: 'userId', operator: '=', value: userId },
|
|
1173
|
+
],
|
|
1174
|
+
data: { isRevoked: true },
|
|
1175
|
+
});
|
|
1176
|
+
},
|
|
1177
|
+
|
|
1178
|
+
async revokeAllOtherSessions(userId: string, currentTokenId: string): Promise<void> {
|
|
1179
|
+
// L-tier: replace the read-then-loop with a single conditional UPDATE.
|
|
1180
|
+
// The original N+1 path opened a window where new refresh tokens issued
|
|
1181
|
+
// during the loop would slip through unrevoked, and added unnecessary
|
|
1182
|
+
// adapter round-trips. The adapter performs `AND` of where clauses, so
|
|
1183
|
+
// `id != currentTokenId` filters out the keep-token in one statement.
|
|
1184
|
+
await db.update({
|
|
1185
|
+
model: 'refresh_token',
|
|
1186
|
+
where: [
|
|
1187
|
+
{ field: 'userId', operator: '=', value: userId },
|
|
1188
|
+
{ field: 'isRevoked', operator: '=', value: false },
|
|
1189
|
+
{ field: 'id', operator: '!=', value: currentTokenId },
|
|
1190
|
+
],
|
|
1191
|
+
data: { isRevoked: true },
|
|
1192
|
+
});
|
|
1193
|
+
},
|
|
1194
|
+
|
|
1195
|
+
async addLoginIdentifier(userId: string, type: LoginIdentifierType, value: string): Promise<void> {
|
|
1196
|
+
await db.create({
|
|
1197
|
+
model: 'login_identifier',
|
|
1198
|
+
data: { userId, type, value: type === 'email' ? normalizeEmail(value) : value },
|
|
1199
|
+
});
|
|
1200
|
+
},
|
|
1201
|
+
|
|
1202
|
+
async removeLoginIdentifier(userId: string, type: LoginIdentifierType, value: string): Promise<void> {
|
|
1203
|
+
const normalizedValue = type === 'email' ? normalizeEmail(value) : value;
|
|
1204
|
+
await db.delete({
|
|
1205
|
+
model: 'login_identifier',
|
|
1206
|
+
where: [
|
|
1207
|
+
{ field: 'userId', operator: '=', value: userId },
|
|
1208
|
+
{ field: 'type', operator: '=', value: type },
|
|
1209
|
+
{ field: 'value', operator: '=', value: normalizedValue },
|
|
1210
|
+
],
|
|
1211
|
+
});
|
|
1212
|
+
},
|
|
1213
|
+
|
|
1214
|
+
async getLoginIdentifiers(userId: string): Promise<LoginIdentifier[]> {
|
|
1215
|
+
return db.findMany<LoginIdentifier>({
|
|
1216
|
+
model: 'login_identifier',
|
|
1217
|
+
where: [{ field: 'userId', operator: '=', value: userId }],
|
|
1218
|
+
});
|
|
1219
|
+
},
|
|
1220
|
+
|
|
1221
|
+
async impersonate(
|
|
1222
|
+
adminUserId: string,
|
|
1223
|
+
targetUserId: string,
|
|
1224
|
+
options?: { reason?: string; expirySeconds?: number },
|
|
1225
|
+
): Promise<AuthResult> {
|
|
1226
|
+
// Defense in depth for programmatic callers. The HTTP route also
|
|
1227
|
+
// enforces this via endpoint metadata before dispatch, but direct
|
|
1228
|
+
// service calls must fail closed too.
|
|
1229
|
+
const adminPermissions = await adapter.getSubjectPermissions({ type: 'USER', id: adminUserId });
|
|
1230
|
+
if (!evaluatePermissions(adminPermissions, 'fortress', 'impersonate', evaluationMode)) {
|
|
1231
|
+
throw Errors.forbidden('Insufficient permissions');
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
const targetUser = await db.findOne<FortressUser>({
|
|
1235
|
+
model: 'user',
|
|
1236
|
+
where: [{ field: 'id', operator: '=', value: targetUserId }],
|
|
1237
|
+
});
|
|
1238
|
+
|
|
1239
|
+
if (!targetUser || !targetUser.isActive) {
|
|
1240
|
+
throw Errors.notFound('Target user not found');
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
// RFC 8693-style act tokens must be short-lived; cap caller-supplied
|
|
1244
|
+
// expiry at MAX_IMPERSONATION_TTL_SECONDS (default 3600s, configurable
|
|
1245
|
+
// via `config.impersonation.maxTtlSeconds`). An admin with elevated
|
|
1246
|
+
// privileges can still cause damage, but not for the next 10 years.
|
|
1247
|
+
const requestedExpiry = options?.expirySeconds ?? 3600;
|
|
1248
|
+
const maxExpiry = config.impersonation?.maxTtlSeconds ?? 3600;
|
|
1249
|
+
const expirySeconds = Math.max(1, Math.min(requestedExpiry, maxExpiry));
|
|
1250
|
+
const groups = await getUserGroups(targetUser.id);
|
|
1251
|
+
const customClaims = await enrichClaims(targetUser.id);
|
|
1252
|
+
|
|
1253
|
+
const accessToken = await signAccessToken(
|
|
1254
|
+
{
|
|
1255
|
+
sub: targetUser.id,
|
|
1256
|
+
subjectType: 'USER',
|
|
1257
|
+
name: targetUser.name,
|
|
1258
|
+
groups,
|
|
1259
|
+
iss: resolved.issuer,
|
|
1260
|
+
act: { sub: adminUserId, subjectType: 'USER' },
|
|
1261
|
+
customClaims: Object.keys(customClaims).length > 0 ? customClaims : undefined,
|
|
1262
|
+
},
|
|
1263
|
+
resolved.key,
|
|
1264
|
+
expirySeconds,
|
|
1265
|
+
{ audience: resolved.audience },
|
|
1266
|
+
);
|
|
1267
|
+
|
|
1268
|
+
// Do NOT issue a refresh token for impersonation — non-renewable
|
|
1269
|
+
const { passwordHash: _, ...safeUser } = targetUser as FortressUser & { passwordHash?: string };
|
|
1270
|
+
|
|
1271
|
+
// Audit trail for impersonation (P2.4 / L-tier follow-up). Emits
|
|
1272
|
+
// through the standard auth observer pipeline so audit-log plugin,
|
|
1273
|
+
// SIEM hooks, and metrics counters all see it.
|
|
1274
|
+
if (authEventListeners.size() > 0) {
|
|
1275
|
+
authEventListeners.emit({
|
|
1276
|
+
eventType: 'IMPERSONATE' as AuthEvent['eventType'],
|
|
1277
|
+
actorId: adminUserId,
|
|
1278
|
+
identifier: String(targetUserId),
|
|
1279
|
+
outcome: 'success',
|
|
1280
|
+
metadata: {
|
|
1281
|
+
targetUserId,
|
|
1282
|
+
reason: options?.reason ?? null,
|
|
1283
|
+
expirySeconds,
|
|
1284
|
+
clamped: expirySeconds !== requestedExpiry,
|
|
1285
|
+
},
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
return {
|
|
1290
|
+
status: 'impersonation' as const,
|
|
1291
|
+
user: safeUser,
|
|
1292
|
+
accessToken,
|
|
1293
|
+
refreshToken: null,
|
|
1294
|
+
pluginData: {
|
|
1295
|
+
impersonation: {
|
|
1296
|
+
adminUserId,
|
|
1297
|
+
reason: options?.reason ?? null,
|
|
1298
|
+
expiresInSeconds: expirySeconds,
|
|
1299
|
+
},
|
|
1300
|
+
},
|
|
1301
|
+
};
|
|
1302
|
+
},
|
|
1303
|
+
|
|
1304
|
+
// ── Admin user management ──────────────────────────────────────
|
|
1305
|
+
|
|
1306
|
+
async listUsers(options: {
|
|
1307
|
+
limit?: number;
|
|
1308
|
+
offset?: number;
|
|
1309
|
+
search?: string;
|
|
1310
|
+
sortBy?: string;
|
|
1311
|
+
sortDirection?: 'asc' | 'desc';
|
|
1312
|
+
}): Promise<{ users: FortressUser[]; total: number }> {
|
|
1313
|
+
const where: { field: string; operator: string; value: unknown }[] = [];
|
|
1314
|
+
|
|
1315
|
+
if (options.search) {
|
|
1316
|
+
where.push({ field: 'email', operator: 'like', value: `%${options.search}%` });
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
const [users, total] = await Promise.all([
|
|
1320
|
+
db.findMany<FortressUser & { passwordHash?: string }>({
|
|
1321
|
+
model: 'user',
|
|
1322
|
+
where: where.length > 0 ? where : undefined,
|
|
1323
|
+
limit: options.limit ?? 50,
|
|
1324
|
+
offset: options.offset ?? 0,
|
|
1325
|
+
sortBy: options.sortBy
|
|
1326
|
+
? { field: options.sortBy, direction: options.sortDirection ?? 'asc' }
|
|
1327
|
+
: { field: 'id', direction: 'asc' },
|
|
1328
|
+
}),
|
|
1329
|
+
db.count({
|
|
1330
|
+
model: 'user',
|
|
1331
|
+
where: where.length > 0 ? where : undefined,
|
|
1332
|
+
}),
|
|
1333
|
+
]);
|
|
1334
|
+
|
|
1335
|
+
return {
|
|
1336
|
+
users: users.map(({ passwordHash: _, ...u }) => u),
|
|
1337
|
+
total,
|
|
1338
|
+
};
|
|
1339
|
+
},
|
|
1340
|
+
|
|
1341
|
+
async getUserById(userId: string): Promise<FortressUser> {
|
|
1342
|
+
const user = await db.findOne<FortressUser & { passwordHash?: string }>({
|
|
1343
|
+
model: 'user',
|
|
1344
|
+
where: [{ field: 'id', operator: '=', value: userId }],
|
|
1345
|
+
});
|
|
1346
|
+
|
|
1347
|
+
if (!user) {
|
|
1348
|
+
throw Errors.notFound('User not found');
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
const { passwordHash: _, ...safeUser } = user;
|
|
1352
|
+
return safeUser;
|
|
1353
|
+
},
|
|
1354
|
+
|
|
1355
|
+
async updateUser(
|
|
1356
|
+
userId: string,
|
|
1357
|
+
data: { name?: string; email?: string; isActive?: boolean; password?: string },
|
|
1358
|
+
): Promise<FortressUser> {
|
|
1359
|
+
const normalizedEmail = data.email === undefined ? undefined : normalizeEmail(data.email);
|
|
1360
|
+
|
|
1361
|
+
const updateData: Record<string, unknown> = {};
|
|
1362
|
+
if (data.name !== undefined)
|
|
1363
|
+
updateData.name = data.name;
|
|
1364
|
+
if (normalizedEmail !== undefined)
|
|
1365
|
+
updateData.email = normalizedEmail;
|
|
1366
|
+
if (data.isActive !== undefined)
|
|
1367
|
+
updateData.isActive = data.isActive;
|
|
1368
|
+
let credentialChanged = false;
|
|
1369
|
+
if (data.password !== undefined) {
|
|
1370
|
+
// Match createUser semantics: validate unconditionally and await.
|
|
1371
|
+
// The original code missed the await on a Promise — a breached or
|
|
1372
|
+
// weak password would surface as an unhandled rejection while the
|
|
1373
|
+
// hash was happily persisted (M1).
|
|
1374
|
+
const normalizedPassword = normalizePasswordInput(data.password);
|
|
1375
|
+
await validatePassword(normalizedPassword, config.passwordPolicy, passwordPolicyObserver);
|
|
1376
|
+
updateData.passwordHash = await hasher.hash(normalizedPassword);
|
|
1377
|
+
credentialChanged = true;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
const updated = await db.transaction(async (tx) => {
|
|
1381
|
+
// Serialize the read/modify/write across PostgreSQL processes. SQLite's
|
|
1382
|
+
// adapter transaction chain already serializes writers on its single
|
|
1383
|
+
// connection. Re-reading only after the lock prevents a stale old
|
|
1384
|
+
// email from missing the identifier row during concurrent updates.
|
|
1385
|
+
if (tx.dialect === 'pg' && tx.rawQuery && NUMERIC_SUBJECT_ID_RE.test(userId)) {
|
|
1386
|
+
await tx.rawQuery(
|
|
1387
|
+
'SELECT pg_advisory_xact_lock(117993, CAST(? AS integer))',
|
|
1388
|
+
[userId],
|
|
1389
|
+
);
|
|
1390
|
+
}
|
|
1391
|
+
const existing = await tx.findOne<FortressUser>({
|
|
1392
|
+
model: 'user',
|
|
1393
|
+
where: [{ field: 'id', operator: '=', value: userId }],
|
|
1394
|
+
});
|
|
1395
|
+
if (!existing)
|
|
1396
|
+
throw Errors.notFound('User not found');
|
|
1397
|
+
|
|
1398
|
+
if (normalizedEmail !== undefined && normalizedEmail !== existing.email) {
|
|
1399
|
+
const duplicate = await tx.findOne<{ id: string }>({
|
|
1400
|
+
model: 'user',
|
|
1401
|
+
where: [{ field: 'email', operator: '=', value: normalizedEmail }],
|
|
1402
|
+
});
|
|
1403
|
+
if (duplicate)
|
|
1404
|
+
throw Errors.conflict('A user with this email already exists');
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
const persisted = await tx.update<FortressUser & { passwordHash?: string }>({
|
|
1408
|
+
model: 'user',
|
|
1409
|
+
where: [{ field: 'id', operator: '=', value: userId }],
|
|
1410
|
+
data: updateData,
|
|
1411
|
+
});
|
|
1412
|
+
if (!persisted)
|
|
1413
|
+
throw Errors.notFound('User not found');
|
|
1414
|
+
|
|
1415
|
+
// Update the primary email identifier atomically with the user row.
|
|
1416
|
+
if (normalizedEmail !== undefined && normalizedEmail !== existing.email) {
|
|
1417
|
+
await tx.update({
|
|
1418
|
+
model: 'login_identifier',
|
|
1419
|
+
where: [
|
|
1420
|
+
{ field: 'userId', operator: '=', value: userId },
|
|
1421
|
+
{ field: 'type', operator: '=', value: 'email' },
|
|
1422
|
+
{ field: 'value', operator: '=', value: existing.email },
|
|
1423
|
+
],
|
|
1424
|
+
data: { value: normalizedEmail },
|
|
1425
|
+
});
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
if (credentialChanged) {
|
|
1429
|
+
await tx.update({
|
|
1430
|
+
model: 'refresh_token',
|
|
1431
|
+
where: [
|
|
1432
|
+
{ field: 'userId', operator: '=', value: userId },
|
|
1433
|
+
{ field: 'isRevoked', operator: '=', value: false },
|
|
1434
|
+
],
|
|
1435
|
+
data: { isRevoked: true },
|
|
1436
|
+
});
|
|
1437
|
+
}
|
|
1438
|
+
return persisted;
|
|
1439
|
+
});
|
|
1440
|
+
|
|
1441
|
+
const { passwordHash: _, ...safeUser } = updated;
|
|
1442
|
+
return safeUser;
|
|
1443
|
+
},
|
|
1444
|
+
|
|
1445
|
+
async deleteUser(userId: string): Promise<void> {
|
|
1446
|
+
const existing = await db.findOne<{ id: string }>({
|
|
1447
|
+
model: 'user',
|
|
1448
|
+
where: [{ field: 'id', operator: '=', value: userId }],
|
|
1449
|
+
});
|
|
1450
|
+
if (!existing) {
|
|
1451
|
+
throw Errors.notFound('User not found');
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
await db.delete({
|
|
1455
|
+
model: 'user',
|
|
1456
|
+
where: [{ field: 'id', operator: '=', value: userId }],
|
|
1457
|
+
});
|
|
1458
|
+
},
|
|
1459
|
+
|
|
1460
|
+
addAuthObserver(listener: AuthEventListener): Unsubscribe {
|
|
1461
|
+
return authEventListeners.add(listener);
|
|
1462
|
+
},
|
|
1463
|
+
};
|
|
1464
|
+
}
|