@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,715 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Social login (OAuth/OIDC consumer) plugin for fortress.
|
|
3
|
+
*
|
|
4
|
+
* Supports Microsoft, Google, GitHub, Discord, Apple, and any generic OIDC
|
|
5
|
+
* provider. Handles the authorization-code flow, exchanges tokens, links
|
|
6
|
+
* provider accounts to fortress users, and issues fortress refresh tokens
|
|
7
|
+
* on success.
|
|
8
|
+
*
|
|
9
|
+
* @module
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { JWTPayload } from 'jose';
|
|
13
|
+
import type { FortressPlugin } from '../../core/plugin';
|
|
14
|
+
import type { FortressUser } from '../../core/types';
|
|
15
|
+
import type { ProviderConfig, ProviderDefinition, ProviderProfile, SocialLoginConfig } from './types';
|
|
16
|
+
import { object, string, union } from '@bajustone/fetcher/schema';
|
|
17
|
+
import { createRemoteJWKSet, importPKCS8, jwtVerify, SignJWT } from 'jose';
|
|
18
|
+
import { normalizeEmail } from '../../core/auth/email';
|
|
19
|
+
import { Errors } from '../../core/errors';
|
|
20
|
+
import { outboundClient } from '../../core/http/outbound';
|
|
21
|
+
import { builtInProviders, createMicrosoftProvider } from './providers';
|
|
22
|
+
import { createOidcProvider } from './providers/oidc';
|
|
23
|
+
|
|
24
|
+
/** Response shape guard — the upstream returned a JSON object (not array/null/scalar). */
|
|
25
|
+
const jsonObjectSchema = object({});
|
|
26
|
+
/** OAuth token response: `access_token` must be a present string; other fields pass through. */
|
|
27
|
+
const tokenResponseSchema = union([object({ access_token: string() }), string()]);
|
|
28
|
+
|
|
29
|
+
interface SocialAccountRecord {
|
|
30
|
+
id: string;
|
|
31
|
+
userId: string;
|
|
32
|
+
provider: string;
|
|
33
|
+
providerAccountId: string;
|
|
34
|
+
email: string | null;
|
|
35
|
+
accessToken: string | null;
|
|
36
|
+
refreshToken: string | null;
|
|
37
|
+
tokenExpiresAt: Date | null;
|
|
38
|
+
profile: string | null;
|
|
39
|
+
createdAt: Date;
|
|
40
|
+
updatedAt: Date;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface ResolvedProviderDefinition extends ProviderDefinition {
|
|
44
|
+
issuer?: string;
|
|
45
|
+
jwksUri?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const encoder = new TextEncoder();
|
|
49
|
+
const HEX_32_BYTE_KEY = /^[0-9a-f]+$/i;
|
|
50
|
+
const discoveryCache = new Map<string, Promise<ResolvedProviderDefinition>>();
|
|
51
|
+
const jwksCache = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
|
|
52
|
+
const appleSecretCache = new Map<string, { value: string; expiresAt: number }>();
|
|
53
|
+
const MICROSOFT_DISCOVERY_ISSUER_PATTERN = /^https:\/\/login\.microsoftonline\.com\/(?:\{tenantid\}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/v2\.0$/i;
|
|
54
|
+
const MICROSOFT_TOKEN_ISSUER_PATTERN = /^https:\/\/login\.microsoftonline\.com\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/v2\.0$/i;
|
|
55
|
+
|
|
56
|
+
function randomBase64Url(bytes = 32): string {
|
|
57
|
+
const data = new Uint8Array(bytes);
|
|
58
|
+
crypto.getRandomValues(data);
|
|
59
|
+
return btoa(String.fromCharCode(...data))
|
|
60
|
+
.replace(/\+/g, '-')
|
|
61
|
+
.replace(/\//g, '_')
|
|
62
|
+
.replace(/=+$/g, '');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function timingSafeEqual(a: string, b: string): boolean {
|
|
66
|
+
const left = encoder.encode(a);
|
|
67
|
+
const right = encoder.encode(b);
|
|
68
|
+
const len = Math.max(left.length, right.length);
|
|
69
|
+
let diff = left.length ^ right.length;
|
|
70
|
+
for (let i = 0; i < len; i++)
|
|
71
|
+
diff |= (left[i] ?? 0) ^ (right[i] ?? 0);
|
|
72
|
+
return diff === 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function decodeKeyMaterial(key: string): Uint8Array {
|
|
76
|
+
const trimmed = key.trim();
|
|
77
|
+
if (HEX_32_BYTE_KEY.test(trimmed) && trimmed.length === 64) {
|
|
78
|
+
return new Uint8Array(trimmed.match(/.{2}/g)!.map(byte => Number.parseInt(byte, 16)));
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
const normalized = trimmed.replace(/-/g, '+').replace(/_/g, '/');
|
|
82
|
+
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=');
|
|
83
|
+
const raw = atob(padded);
|
|
84
|
+
const bytes = new Uint8Array(raw.length);
|
|
85
|
+
for (let i = 0; i < raw.length; i++)
|
|
86
|
+
bytes[i] = raw.charCodeAt(i);
|
|
87
|
+
if (bytes.length >= 32)
|
|
88
|
+
return bytes;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// Fall through to UTF-8.
|
|
92
|
+
}
|
|
93
|
+
return encoder.encode(key);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function importTokenEncryptionKey(key: string): Promise<CryptoKey> {
|
|
97
|
+
const bytes = decodeKeyMaterial(key);
|
|
98
|
+
if (bytes.length !== 32) {
|
|
99
|
+
throw Errors.badRequest('socialLogin tokenEncryptionKey must decode to exactly 32 bytes for AES-256-GCM');
|
|
100
|
+
}
|
|
101
|
+
const raw = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
|
102
|
+
return crypto.subtle.importKey('raw', raw, 'AES-GCM', false, ['encrypt', 'decrypt']);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function encryptToken(keyPromise: Promise<CryptoKey> | null, value: string | undefined | null): Promise<string | null> {
|
|
106
|
+
if (!value)
|
|
107
|
+
return null;
|
|
108
|
+
if (!keyPromise)
|
|
109
|
+
throw Errors.badRequest('socialLogin token persistence requires tokenEncryptionKey');
|
|
110
|
+
const key = await keyPromise;
|
|
111
|
+
const iv = new Uint8Array(12);
|
|
112
|
+
crypto.getRandomValues(iv);
|
|
113
|
+
const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoder.encode(value)));
|
|
114
|
+
return `v1.${base64UrlFromBytes(iv)}.${base64UrlFromBytes(ciphertext)}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function decryptToken(keyPromise: Promise<CryptoKey> | null, value: string | undefined | null): Promise<string | null> {
|
|
118
|
+
if (!value)
|
|
119
|
+
return null;
|
|
120
|
+
if (!keyPromise)
|
|
121
|
+
throw Errors.badRequest('socialLogin token persistence requires tokenEncryptionKey');
|
|
122
|
+
const [version, ivEncoded, ciphertextEncoded] = value.split('.');
|
|
123
|
+
if (version !== 'v1' || !ivEncoded || !ciphertextEncoded)
|
|
124
|
+
throw Errors.badRequest('Invalid encrypted social-login token format');
|
|
125
|
+
const key = await keyPromise;
|
|
126
|
+
const iv = base64UrlToBytes(ivEncoded);
|
|
127
|
+
const ciphertext = base64UrlToBytes(ciphertextEncoded);
|
|
128
|
+
const ivBuffer = iv.buffer.slice(iv.byteOffset, iv.byteOffset + iv.byteLength) as ArrayBuffer;
|
|
129
|
+
const ciphertextBuffer = ciphertext.buffer.slice(ciphertext.byteOffset, ciphertext.byteOffset + ciphertext.byteLength) as ArrayBuffer;
|
|
130
|
+
const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: ivBuffer }, key, ciphertextBuffer);
|
|
131
|
+
return new TextDecoder().decode(plaintext);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function base64UrlFromBytes(data: Uint8Array): string {
|
|
135
|
+
return btoa(String.fromCharCode(...data)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function base64UrlToBytes(value: string): Uint8Array {
|
|
139
|
+
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
|
140
|
+
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=');
|
|
141
|
+
const raw = atob(padded);
|
|
142
|
+
const bytes = new Uint8Array(raw.length);
|
|
143
|
+
for (let i = 0; i < raw.length; i++)
|
|
144
|
+
bytes[i] = raw.charCodeAt(i);
|
|
145
|
+
return bytes;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Resolve built-in or custom OIDC provider definition from config */
|
|
149
|
+
function resolveProviderDefinition(providerConfig: ProviderConfig): ProviderDefinition {
|
|
150
|
+
if (providerConfig.name === 'microsoft' && providerConfig.tenant) {
|
|
151
|
+
return createMicrosoftProvider({ tenant: providerConfig.tenant });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (providerConfig.issuer) {
|
|
155
|
+
const base = createOidcProvider(providerConfig.name, providerConfig.issuer);
|
|
156
|
+
return {
|
|
157
|
+
...base,
|
|
158
|
+
authorizationUrl: providerConfig.authorizationUrl ?? base.authorizationUrl,
|
|
159
|
+
tokenUrl: providerConfig.tokenUrl ?? base.tokenUrl,
|
|
160
|
+
userInfoUrl: providerConfig.userInfoUrl ?? base.userInfoUrl,
|
|
161
|
+
jwksUri: providerConfig.jwksUri,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const builtIn = builtInProviders[providerConfig.name];
|
|
166
|
+
if (builtIn)
|
|
167
|
+
return builtIn;
|
|
168
|
+
|
|
169
|
+
throw Errors.badRequest(`Unknown social login provider: ${providerConfig.name}. Provide an 'issuer' URL for custom OIDC providers.`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function isAbsoluteUrl(value: unknown): value is string {
|
|
173
|
+
if (typeof value !== 'string' || value.length === 0)
|
|
174
|
+
return false;
|
|
175
|
+
try {
|
|
176
|
+
return new URL(value).protocol === 'https:';
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function isMicrosoftMultiTenant(providerConfig: ProviderConfig): boolean {
|
|
184
|
+
return providerConfig.name === 'microsoft'
|
|
185
|
+
&& ['common', 'organizations', 'consumers'].includes(providerConfig.tenant ?? 'common');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Resolve OIDC discovery once, caching only a complete and trusted document.
|
|
190
|
+
* Failed or semantically invalid discovery is deliberately not cached so a
|
|
191
|
+
* later login can recover after a transient provider outage.
|
|
192
|
+
*/
|
|
193
|
+
async function resolveDiscoveredDefinition(definition: ProviderDefinition, providerConfig: ProviderConfig): Promise<ResolvedProviderDefinition> {
|
|
194
|
+
if (!definition.discoveryUrl || (definition.issuer && definition.jwksUri && !providerConfig.issuer)) {
|
|
195
|
+
return definition;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const cacheKey = `${providerConfig.name}:${definition.discoveryUrl}`;
|
|
199
|
+
const existing = discoveryCache.get(cacheKey);
|
|
200
|
+
if (existing)
|
|
201
|
+
return existing;
|
|
202
|
+
|
|
203
|
+
const pending = (async () => {
|
|
204
|
+
const res = await outboundClient.get(definition.discoveryUrl!, { responseSchema: jsonObjectSchema }).result();
|
|
205
|
+
if (!res.ok)
|
|
206
|
+
return null;
|
|
207
|
+
|
|
208
|
+
const discovered = res.data as Record<string, unknown>;
|
|
209
|
+
const discoveredIssuer = discovered.issuer;
|
|
210
|
+
const expectedIssuer = definition.issuer ?? providerConfig.issuer;
|
|
211
|
+
const trustedIssuer = isMicrosoftMultiTenant(providerConfig)
|
|
212
|
+
? (typeof discoveredIssuer === 'string'
|
|
213
|
+
&& (discoveredIssuer === expectedIssuer
|
|
214
|
+
|| MICROSOFT_DISCOVERY_ISSUER_PATTERN.test(discoveredIssuer)))
|
|
215
|
+
: typeof discoveredIssuer === 'string' && discoveredIssuer === expectedIssuer;
|
|
216
|
+
|
|
217
|
+
// The issuer and JWKS URI are security-critical. Do not trust a discovery
|
|
218
|
+
// response that omits them, changes the configured issuer, or supplies a
|
|
219
|
+
// non-HTTPS endpoint. Endpoint overrides remain explicitly trusted config.
|
|
220
|
+
if (!trustedIssuer || !isAbsoluteUrl(discovered.jwks_uri))
|
|
221
|
+
return null;
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
...definition,
|
|
225
|
+
// Multi-tenant Microsoft authorities advertise a {tenantid} issuer in
|
|
226
|
+
// discovery; the concrete tenant is checked against the ID token below.
|
|
227
|
+
issuer: isMicrosoftMultiTenant(providerConfig) ? definition.issuer : discoveredIssuer as string,
|
|
228
|
+
authorizationUrl: providerConfig.authorizationUrl ?? (isAbsoluteUrl(discovered.authorization_endpoint) ? discovered.authorization_endpoint : definition.authorizationUrl),
|
|
229
|
+
tokenUrl: providerConfig.tokenUrl ?? (isAbsoluteUrl(discovered.token_endpoint) ? discovered.token_endpoint : definition.tokenUrl),
|
|
230
|
+
userInfoUrl: providerConfig.userInfoUrl ?? (isAbsoluteUrl(discovered.userinfo_endpoint) ? discovered.userinfo_endpoint : definition.userInfoUrl),
|
|
231
|
+
jwksUri: providerConfig.jwksUri ?? discovered.jwks_uri,
|
|
232
|
+
};
|
|
233
|
+
})();
|
|
234
|
+
|
|
235
|
+
const cached = pending.then((result) => {
|
|
236
|
+
if (result)
|
|
237
|
+
return result;
|
|
238
|
+
// Never retain a degraded/invalid result. The next request retries.
|
|
239
|
+
if (discoveryCache.get(cacheKey) === cached)
|
|
240
|
+
discoveryCache.delete(cacheKey);
|
|
241
|
+
return definition;
|
|
242
|
+
}, (error) => {
|
|
243
|
+
if (discoveryCache.get(cacheKey) === cached)
|
|
244
|
+
discoveryCache.delete(cacheKey);
|
|
245
|
+
throw error;
|
|
246
|
+
});
|
|
247
|
+
discoveryCache.set(cacheKey, cached);
|
|
248
|
+
return cached;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function verifyIdToken(
|
|
252
|
+
definition: ResolvedProviderDefinition,
|
|
253
|
+
providerConfig: ProviderConfig,
|
|
254
|
+
idToken: string | undefined,
|
|
255
|
+
nonce: string,
|
|
256
|
+
): Promise<JWTPayload | null> {
|
|
257
|
+
const shouldVerify = Boolean(definition.discoveryUrl || definition.jwksUri || idToken);
|
|
258
|
+
if (!shouldVerify)
|
|
259
|
+
return null;
|
|
260
|
+
if (!idToken)
|
|
261
|
+
throw Errors.unauthorized(`Provider ${providerConfig.name} did not return an ID token`);
|
|
262
|
+
if (!definition.jwksUri)
|
|
263
|
+
throw Errors.unauthorized(`Provider ${providerConfig.name} has no JWKS URI for ID token verification`);
|
|
264
|
+
if (!definition.issuer)
|
|
265
|
+
throw Errors.unauthorized(`Provider ${providerConfig.name} has no issuer for ID token verification`);
|
|
266
|
+
if (!nonce)
|
|
267
|
+
throw Errors.unauthorized(`Missing stored OIDC nonce for ${providerConfig.name}`);
|
|
268
|
+
|
|
269
|
+
let jwks = jwksCache.get(definition.jwksUri);
|
|
270
|
+
if (!jwks) {
|
|
271
|
+
jwks = createRemoteJWKSet(new URL(definition.jwksUri));
|
|
272
|
+
jwksCache.set(definition.jwksUri, jwks);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const multiTenantMicrosoft = isMicrosoftMultiTenant(providerConfig);
|
|
276
|
+
const { payload } = await jwtVerify(idToken, jwks, {
|
|
277
|
+
...(multiTenantMicrosoft ? {} : { issuer: definition.issuer }),
|
|
278
|
+
audience: providerConfig.clientId,
|
|
279
|
+
// Accept asymmetric OIDC signing algorithms while excluding `none` and
|
|
280
|
+
// every shared-secret HS* algorithm (which could enable key-confusion).
|
|
281
|
+
algorithms: ['RS256', 'PS256', 'ES256', 'EdDSA'],
|
|
282
|
+
}).catch(() => {
|
|
283
|
+
throw Errors.unauthorized(`Invalid ID token from ${providerConfig.name}`);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
if (multiTenantMicrosoft) {
|
|
287
|
+
const issuer = typeof payload.iss === 'string' ? payload.iss : '';
|
|
288
|
+
const tenantId = typeof payload.tid === 'string' ? payload.tid : '';
|
|
289
|
+
const match = MICROSOFT_TOKEN_ISSUER_PATTERN.exec(issuer);
|
|
290
|
+
if (!match || !tenantId || match[1]!.toLowerCase() !== tenantId.toLowerCase())
|
|
291
|
+
throw Errors.unauthorized(`Invalid ID token issuer from ${providerConfig.name}`);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (payload.nonce !== nonce)
|
|
295
|
+
throw Errors.unauthorized(`Invalid ID token nonce from ${providerConfig.name}`);
|
|
296
|
+
return payload;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function buildClientSecret(providerConfig: ProviderConfig, definition: ProviderDefinition): Promise<string> {
|
|
300
|
+
if (providerConfig.name !== 'apple' || !providerConfig.privateKey)
|
|
301
|
+
return providerConfig.clientSecret;
|
|
302
|
+
if (!providerConfig.teamId || !providerConfig.keyId)
|
|
303
|
+
throw Errors.badRequest('Apple social login requires teamId, keyId, and privateKey');
|
|
304
|
+
|
|
305
|
+
const cacheKey = `${providerConfig.teamId}:${providerConfig.clientId}:${providerConfig.keyId}`;
|
|
306
|
+
const cached = appleSecretCache.get(cacheKey);
|
|
307
|
+
if (cached && cached.expiresAt > Date.now() + 60_000)
|
|
308
|
+
return cached.value;
|
|
309
|
+
|
|
310
|
+
const now = Math.floor(Date.now() / 1000);
|
|
311
|
+
const expiresAt = now + 60 * 60;
|
|
312
|
+
const key = await importPKCS8(providerConfig.privateKey, 'ES256');
|
|
313
|
+
const issuer = definition.issuer ?? 'https://appleid.apple.com';
|
|
314
|
+
const value = await new SignJWT({})
|
|
315
|
+
.setProtectedHeader({ alg: 'ES256', kid: providerConfig.keyId })
|
|
316
|
+
.setIssuer(providerConfig.teamId)
|
|
317
|
+
.setSubject(providerConfig.clientId)
|
|
318
|
+
.setAudience(issuer)
|
|
319
|
+
.setIssuedAt(now)
|
|
320
|
+
.setExpirationTime(expiresAt)
|
|
321
|
+
.sign(key);
|
|
322
|
+
appleSecretCache.set(cacheKey, { value, expiresAt: expiresAt * 1000 });
|
|
323
|
+
return value;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Generate PKCE code verifier and S256 challenge */
|
|
327
|
+
async function generatePKCE(): Promise<{ codeVerifier: string; codeChallenge: string }> {
|
|
328
|
+
const bytes = new Uint8Array(32);
|
|
329
|
+
crypto.getRandomValues(bytes);
|
|
330
|
+
const codeVerifier = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
|
|
331
|
+
|
|
332
|
+
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier));
|
|
333
|
+
const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(hash)))
|
|
334
|
+
.replace(/\+/g, '-')
|
|
335
|
+
.replace(/\//g, '_')
|
|
336
|
+
.replace(/=/g, '');
|
|
337
|
+
|
|
338
|
+
return { codeVerifier, codeChallenge };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export interface SocialLoginMethods {
|
|
342
|
+
getAuthorizationUrl: (providerName: string, redirectUri: string) => Promise<{ url: string; state: string; codeVerifier: string; nonce: string }>;
|
|
343
|
+
handleCallback: (providerName: string, code: string, redirectUri: string, codeVerifier: string, returnedState: string, storedState: string, storedNonce: string) => Promise<{ user: FortressUser; profile: ProviderProfile; isNewUser: boolean }>;
|
|
344
|
+
getLinkedAccounts: (userId: string) => Promise<{ provider: string; providerAccountId: string; email: string | null }[]>;
|
|
345
|
+
getProviderTokens: (userId: string, provider: string) => Promise<{ accessToken: string | null; refreshToken: string | null; tokenExpiresAt: Date | null }>;
|
|
346
|
+
unlinkAccount: (userId: string, provider: string) => Promise<void>;
|
|
347
|
+
getProviders: () => string[];
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Social login plugin factory. Returns a {@link FortressPlugin} that handles
|
|
351
|
+
* the OAuth/OIDC authorization-code flow against the configured providers,
|
|
352
|
+
* links provider accounts to fortress users, and issues fortress tokens.
|
|
353
|
+
*/
|
|
354
|
+
export function socialLogin(config: SocialLoginConfig): FortressPlugin & { readonly name: 'social-login' } {
|
|
355
|
+
const autoRegister = config.autoRegister ?? true;
|
|
356
|
+
const linkAccounts = config.linkAccounts ?? true;
|
|
357
|
+
// Default OFF: persisting provider tokens requires an explicit
|
|
358
|
+
// 32-byte AES-256-GCM key, so we don't silently store them at rest.
|
|
359
|
+
// Consumers that want server-side refresh-token storage opt in by
|
|
360
|
+
// setting `persistTokens: true` and supplying `tokenEncryptionKey`.
|
|
361
|
+
const persistTokens = config.persistTokens ?? false;
|
|
362
|
+
if (persistTokens && !config.tokenEncryptionKey)
|
|
363
|
+
throw Errors.badRequest('socialLogin requires tokenEncryptionKey when persistTokens is enabled');
|
|
364
|
+
if (persistTokens && config.tokenEncryptionKey && decodeKeyMaterial(config.tokenEncryptionKey).length !== 32)
|
|
365
|
+
throw Errors.badRequest('socialLogin tokenEncryptionKey must decode to exactly 32 bytes for AES-256-GCM');
|
|
366
|
+
const tokenEncryptionKey = persistTokens && config.tokenEncryptionKey
|
|
367
|
+
? importTokenEncryptionKey(config.tokenEncryptionKey)
|
|
368
|
+
: null;
|
|
369
|
+
|
|
370
|
+
// Pre-resolve all provider definitions
|
|
371
|
+
const providerMap = new Map<string, { definition: ProviderDefinition; config: ProviderConfig }>();
|
|
372
|
+
for (const pc of config.providers) {
|
|
373
|
+
providerMap.set(pc.name, { definition: resolveProviderDefinition(pc), config: pc });
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return {
|
|
377
|
+
name: 'social-login',
|
|
378
|
+
|
|
379
|
+
models: [{
|
|
380
|
+
name: 'social_account',
|
|
381
|
+
fields: {
|
|
382
|
+
id: { type: 'number', required: true },
|
|
383
|
+
userId: { type: 'number', required: true, references: { model: 'user', field: 'id' } },
|
|
384
|
+
provider: { type: 'string', required: true },
|
|
385
|
+
providerAccountId: { type: 'string', required: true },
|
|
386
|
+
email: { type: 'string' },
|
|
387
|
+
accessToken: { type: 'string' },
|
|
388
|
+
refreshToken: { type: 'string' },
|
|
389
|
+
tokenExpiresAt: { type: 'date' },
|
|
390
|
+
profile: { type: 'string' },
|
|
391
|
+
createdAt: { type: 'date', required: true },
|
|
392
|
+
updatedAt: { type: 'date', required: true },
|
|
393
|
+
},
|
|
394
|
+
constraints: [
|
|
395
|
+
{ type: 'unique', fields: ['userId', 'provider'] },
|
|
396
|
+
{ type: 'unique', fields: ['provider', 'providerAccountId'] },
|
|
397
|
+
],
|
|
398
|
+
}],
|
|
399
|
+
|
|
400
|
+
methods: ctx => ({
|
|
401
|
+
/**
|
|
402
|
+
* Get the authorization URL to redirect the user to for a given provider.
|
|
403
|
+
* Returns the URL and state that must be stored (e.g., in session) for callback verification.
|
|
404
|
+
*/
|
|
405
|
+
async getAuthorizationUrl(
|
|
406
|
+
providerName: string,
|
|
407
|
+
redirectUri: string,
|
|
408
|
+
): Promise<{ url: string; state: string; codeVerifier: string; nonce: string }> {
|
|
409
|
+
const entry = providerMap.get(providerName);
|
|
410
|
+
if (!entry)
|
|
411
|
+
throw Errors.badRequest(`Provider '${providerName}' is not configured`);
|
|
412
|
+
|
|
413
|
+
const { definition, config: pc } = entry;
|
|
414
|
+
const resolvedDefinition = await resolveDiscoveredDefinition(definition, pc);
|
|
415
|
+
const { codeVerifier, codeChallenge } = await generatePKCE();
|
|
416
|
+
const state = randomBase64Url();
|
|
417
|
+
const nonce = randomBase64Url();
|
|
418
|
+
|
|
419
|
+
const scopes = pc.scopes ?? resolvedDefinition.defaultScopes;
|
|
420
|
+
|
|
421
|
+
const params = new URLSearchParams({
|
|
422
|
+
client_id: pc.clientId,
|
|
423
|
+
redirect_uri: redirectUri,
|
|
424
|
+
response_type: 'code',
|
|
425
|
+
scope: scopes.join(' '),
|
|
426
|
+
state,
|
|
427
|
+
code_challenge: codeChallenge,
|
|
428
|
+
code_challenge_method: 'S256',
|
|
429
|
+
});
|
|
430
|
+
if (scopes.includes('openid') || resolvedDefinition.discoveryUrl)
|
|
431
|
+
params.set('nonce', nonce);
|
|
432
|
+
|
|
433
|
+
const url = `${resolvedDefinition.authorizationUrl}?${params.toString()}`;
|
|
434
|
+
|
|
435
|
+
return { url, state, codeVerifier, nonce };
|
|
436
|
+
},
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Handle the OAuth callback. Exchanges the authorization code for tokens,
|
|
440
|
+
* fetches the user profile, and performs JIT provisioning / account linking.
|
|
441
|
+
*
|
|
442
|
+
* Returns the Fortress user (existing or newly created) and the provider profile.
|
|
443
|
+
*/
|
|
444
|
+
async handleCallback(
|
|
445
|
+
providerName: string,
|
|
446
|
+
code: string,
|
|
447
|
+
redirectUri: string,
|
|
448
|
+
codeVerifier: string,
|
|
449
|
+
returnedState: string,
|
|
450
|
+
storedState: string,
|
|
451
|
+
storedNonce: string,
|
|
452
|
+
): Promise<{ user: FortressUser; profile: ProviderProfile; isNewUser: boolean }> {
|
|
453
|
+
if (!returnedState || !storedState || !timingSafeEqual(returnedState, storedState))
|
|
454
|
+
throw Errors.unauthorized('Invalid OAuth state');
|
|
455
|
+
|
|
456
|
+
const entry = providerMap.get(providerName);
|
|
457
|
+
if (!entry)
|
|
458
|
+
throw Errors.badRequest(`Provider '${providerName}' is not configured`);
|
|
459
|
+
|
|
460
|
+
const { definition, config: pc } = entry;
|
|
461
|
+
const resolvedDefinition = await resolveDiscoveredDefinition(definition, pc);
|
|
462
|
+
const clientSecret = await buildClientSecret(pc, resolvedDefinition);
|
|
463
|
+
|
|
464
|
+
// Exchange code for tokens. POST is never retried (RFC 9110); the
|
|
465
|
+
// shared client adds a timeout and validates `access_token` is present.
|
|
466
|
+
const tokenRes = await outboundClient.post(resolvedDefinition.tokenUrl, {
|
|
467
|
+
headers: {
|
|
468
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
469
|
+
// GitHub defaults to form-urlencoded; explicitly requesting JSON
|
|
470
|
+
// makes its response interoperable while retaining the fallback.
|
|
471
|
+
'Accept': 'application/json',
|
|
472
|
+
},
|
|
473
|
+
body: new URLSearchParams({
|
|
474
|
+
grant_type: 'authorization_code',
|
|
475
|
+
code,
|
|
476
|
+
redirect_uri: redirectUri,
|
|
477
|
+
client_id: pc.clientId,
|
|
478
|
+
client_secret: clientSecret,
|
|
479
|
+
code_verifier: codeVerifier,
|
|
480
|
+
}),
|
|
481
|
+
responseSchema: tokenResponseSchema,
|
|
482
|
+
}).result();
|
|
483
|
+
|
|
484
|
+
if (!tokenRes.ok) {
|
|
485
|
+
throw Errors.unauthorized(`Failed to exchange authorization code with ${providerName}`);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const tokenData = tokenRes.data;
|
|
489
|
+
const tokens = typeof tokenData === 'string'
|
|
490
|
+
? (() => {
|
|
491
|
+
const form = new URLSearchParams(tokenData);
|
|
492
|
+
return {
|
|
493
|
+
access_token: form.get('access_token') ?? '',
|
|
494
|
+
refresh_token: form.get('refresh_token') ?? undefined,
|
|
495
|
+
expires_in: form.has('expires_in') ? Number(form.get('expires_in')) : undefined,
|
|
496
|
+
id_token: form.get('id_token') ?? undefined,
|
|
497
|
+
};
|
|
498
|
+
})()
|
|
499
|
+
: tokenData as {
|
|
500
|
+
access_token: string;
|
|
501
|
+
refresh_token?: string;
|
|
502
|
+
expires_in?: number;
|
|
503
|
+
id_token?: string;
|
|
504
|
+
};
|
|
505
|
+
if (!tokens.access_token)
|
|
506
|
+
throw Errors.unauthorized(`Failed to exchange authorization code with ${providerName}`);
|
|
507
|
+
|
|
508
|
+
const idTokenClaims = await verifyIdToken(resolvedDefinition, pc, tokens.id_token, storedNonce);
|
|
509
|
+
|
|
510
|
+
// Fetch user profile. OIDC ID-token claims are merged with userinfo so
|
|
511
|
+
// `sub`, `email_verified`, and provider-specific claims are preserved.
|
|
512
|
+
let rawProfile: Record<string, unknown>;
|
|
513
|
+
|
|
514
|
+
if (resolvedDefinition.fetchProfile) {
|
|
515
|
+
rawProfile = await resolvedDefinition.fetchProfile(tokens.access_token).catch(() => {
|
|
516
|
+
throw Errors.unauthorized(`Failed to fetch profile from ${providerName}`);
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
else if (resolvedDefinition.userInfoUrl) {
|
|
520
|
+
const profileRes = await outboundClient.get(resolvedDefinition.userInfoUrl, {
|
|
521
|
+
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
|
522
|
+
responseSchema: jsonObjectSchema,
|
|
523
|
+
}).result();
|
|
524
|
+
|
|
525
|
+
if (!profileRes.ok) {
|
|
526
|
+
throw Errors.unauthorized(`Failed to fetch profile from ${providerName}`);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
rawProfile = profileRes.data as Record<string, unknown>;
|
|
530
|
+
}
|
|
531
|
+
else if (idTokenClaims) {
|
|
532
|
+
rawProfile = { ...idTokenClaims };
|
|
533
|
+
}
|
|
534
|
+
else {
|
|
535
|
+
throw Errors.unauthorized(`Provider ${providerName} returned no profile data`);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (idTokenClaims)
|
|
539
|
+
rawProfile = { ...idTokenClaims, ...rawProfile };
|
|
540
|
+
|
|
541
|
+
const profile = resolvedDefinition.mapProfile(rawProfile);
|
|
542
|
+
if (!profile.id)
|
|
543
|
+
throw Errors.unauthorized(`Provider ${providerName} returned no subject`);
|
|
544
|
+
const canonicalProfileEmail = normalizeEmail(profile.email);
|
|
545
|
+
|
|
546
|
+
// Validate email domain restriction case-insensitively.
|
|
547
|
+
if (pc.allowedDomains && pc.allowedDomains.length > 0) {
|
|
548
|
+
const domain = canonicalProfileEmail.split('@')[1];
|
|
549
|
+
const allowedDomains = pc.allowedDomains.map(value => value.normalize('NFC').toLowerCase());
|
|
550
|
+
if (!domain || !allowedDomains.includes(domain)) {
|
|
551
|
+
throw Errors.unauthorized(`Email domain '${domain ?? ''}' is not allowed for ${providerName}`);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return ctx.db.transaction(async (tx) => {
|
|
556
|
+
const encryptedAccessToken = persistTokens ? await encryptToken(tokenEncryptionKey, tokens.access_token) : null;
|
|
557
|
+
const encryptedRefreshToken = persistTokens ? await encryptToken(tokenEncryptionKey, tokens.refresh_token) : null;
|
|
558
|
+
|
|
559
|
+
// Look up existing social account
|
|
560
|
+
const socialAccount = await tx.findOne<SocialAccountRecord>({
|
|
561
|
+
model: 'social_account',
|
|
562
|
+
where: [
|
|
563
|
+
{ field: 'provider', operator: '=', value: providerName },
|
|
564
|
+
{ field: 'providerAccountId', operator: '=', value: profile.id },
|
|
565
|
+
],
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
let user: FortressUser | null = null;
|
|
569
|
+
let isNewUser = false;
|
|
570
|
+
|
|
571
|
+
if (socialAccount) {
|
|
572
|
+
// Existing social account — update tokens and profile
|
|
573
|
+
user = await tx.findOne<FortressUser>({
|
|
574
|
+
model: 'user',
|
|
575
|
+
where: [{ field: 'id', operator: '=', value: socialAccount.userId }],
|
|
576
|
+
});
|
|
577
|
+
if (!user || !user.isActive)
|
|
578
|
+
throw Errors.unauthorized('User account not found or disabled');
|
|
579
|
+
|
|
580
|
+
await tx.update({
|
|
581
|
+
model: 'social_account',
|
|
582
|
+
where: [{ field: 'id', operator: '=', value: socialAccount.id }],
|
|
583
|
+
data: {
|
|
584
|
+
accessToken: encryptedAccessToken,
|
|
585
|
+
refreshToken: encryptedRefreshToken ?? socialAccount.refreshToken,
|
|
586
|
+
tokenExpiresAt: tokens.expires_in
|
|
587
|
+
? new Date(Date.now() + tokens.expires_in * 1000)
|
|
588
|
+
: null,
|
|
589
|
+
profile: JSON.stringify(profile.raw),
|
|
590
|
+
email: canonicalProfileEmail,
|
|
591
|
+
updatedAt: new Date(),
|
|
592
|
+
},
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
else {
|
|
596
|
+
// No social account — try linking by verified email only.
|
|
597
|
+
if (linkAccounts && canonicalProfileEmail && profile.emailVerified) {
|
|
598
|
+
user = await tx.findOne<FortressUser>({
|
|
599
|
+
model: 'user',
|
|
600
|
+
where: [{ field: 'email', operator: '=', value: canonicalProfileEmail }],
|
|
601
|
+
});
|
|
602
|
+
if (user && !user.isActive)
|
|
603
|
+
throw Errors.unauthorized('User account not found or disabled');
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (!user) {
|
|
607
|
+
// JIT provisioning
|
|
608
|
+
if (!autoRegister) {
|
|
609
|
+
throw Errors.unauthorized('Auto-registration is disabled');
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const mapped = config.mapProfile
|
|
613
|
+
? config.mapProfile(providerName, profile)
|
|
614
|
+
: null;
|
|
615
|
+
const email = normalizeEmail(mapped?.email ?? canonicalProfileEmail);
|
|
616
|
+
const name = mapped?.name ?? profile.name ?? profile.email;
|
|
617
|
+
|
|
618
|
+
user = await tx.create<FortressUser>({
|
|
619
|
+
model: 'user',
|
|
620
|
+
data: {
|
|
621
|
+
email,
|
|
622
|
+
name,
|
|
623
|
+
passwordHash: null, // Social-only user
|
|
624
|
+
isActive: true,
|
|
625
|
+
emailVerified: profile.emailVerified,
|
|
626
|
+
},
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
isNewUser = true;
|
|
630
|
+
|
|
631
|
+
if (config.onFirstLogin) {
|
|
632
|
+
await config.onFirstLogin({ id: user.id }, providerName, profile);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Link social account to user
|
|
637
|
+
await tx.create({
|
|
638
|
+
model: 'social_account',
|
|
639
|
+
data: {
|
|
640
|
+
userId: user.id,
|
|
641
|
+
provider: providerName,
|
|
642
|
+
providerAccountId: profile.id,
|
|
643
|
+
email: canonicalProfileEmail,
|
|
644
|
+
accessToken: encryptedAccessToken,
|
|
645
|
+
refreshToken: encryptedRefreshToken,
|
|
646
|
+
tokenExpiresAt: tokens.expires_in
|
|
647
|
+
? new Date(Date.now() + tokens.expires_in * 1000)
|
|
648
|
+
: null,
|
|
649
|
+
profile: JSON.stringify(profile.raw),
|
|
650
|
+
},
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
return { user, profile, isNewUser };
|
|
655
|
+
});
|
|
656
|
+
},
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* List social accounts linked to a user.
|
|
660
|
+
*/
|
|
661
|
+
async getLinkedAccounts(userId: string): Promise<{ provider: string; providerAccountId: string; email: string | null }[]> {
|
|
662
|
+
const accounts = await ctx.db.findMany<SocialAccountRecord>({
|
|
663
|
+
model: 'social_account',
|
|
664
|
+
where: [{ field: 'userId', operator: '=', value: userId }],
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
return accounts.map(a => ({
|
|
668
|
+
provider: a.provider,
|
|
669
|
+
providerAccountId: a.providerAccountId,
|
|
670
|
+
email: a.email,
|
|
671
|
+
}));
|
|
672
|
+
},
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Return decrypted provider tokens for a linked account.
|
|
676
|
+
*/
|
|
677
|
+
async getProviderTokens(userId: string, provider: string): Promise<{ accessToken: string | null; refreshToken: string | null; tokenExpiresAt: Date | null }> {
|
|
678
|
+
const account = await ctx.db.findOne<SocialAccountRecord>({
|
|
679
|
+
model: 'social_account',
|
|
680
|
+
where: [
|
|
681
|
+
{ field: 'userId', operator: '=', value: userId },
|
|
682
|
+
{ field: 'provider', operator: '=', value: provider },
|
|
683
|
+
],
|
|
684
|
+
});
|
|
685
|
+
if (!account)
|
|
686
|
+
throw Errors.notFound('Linked social account not found');
|
|
687
|
+
return {
|
|
688
|
+
accessToken: await decryptToken(tokenEncryptionKey, account.accessToken),
|
|
689
|
+
refreshToken: await decryptToken(tokenEncryptionKey, account.refreshToken),
|
|
690
|
+
tokenExpiresAt: account.tokenExpiresAt,
|
|
691
|
+
};
|
|
692
|
+
},
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Unlink a social account from a user.
|
|
696
|
+
*/
|
|
697
|
+
async unlinkAccount(userId: string, provider: string): Promise<void> {
|
|
698
|
+
await ctx.db.delete({
|
|
699
|
+
model: 'social_account',
|
|
700
|
+
where: [
|
|
701
|
+
{ field: 'userId', operator: '=', value: userId },
|
|
702
|
+
{ field: 'provider', operator: '=', value: provider },
|
|
703
|
+
],
|
|
704
|
+
});
|
|
705
|
+
},
|
|
706
|
+
|
|
707
|
+
/** Get list of configured provider names */
|
|
708
|
+
getProviders(): string[] {
|
|
709
|
+
return Array.from(providerMap.keys());
|
|
710
|
+
},
|
|
711
|
+
}),
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
export type { ProviderConfig, ProviderProfile, SocialLoginConfig } from './types';
|