@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,675 @@
|
|
|
1
|
+
import type { DatabaseAdapter } from '../../adapters/database';
|
|
2
|
+
import type { FortressUser } from '../../core/types';
|
|
3
|
+
import type { ProviderProfile } from './types';
|
|
4
|
+
import { exportJWK, generateKeyPair, SignJWT } from 'jose';
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import { createTestAdapter } from '../../testing';
|
|
7
|
+
import { socialLogin } from './index';
|
|
8
|
+
|
|
9
|
+
// We test the social-login plugin methods directly (not through createFortress)
|
|
10
|
+
// since handleCallback requires mocking fetch for OAuth token exchange.
|
|
11
|
+
|
|
12
|
+
interface SocialLoginMethods {
|
|
13
|
+
getAuthorizationUrl: (provider: string, redirectUri: string) => Promise<{ url: string; state: string; codeVerifier: string; nonce: string }>;
|
|
14
|
+
handleCallback: (provider: string, code: string, redirectUri: string, codeVerifier: string, returnedState: string, storedState: string, storedNonce: string) => Promise<{ user: FortressUser; profile: ProviderProfile; isNewUser: boolean }>;
|
|
15
|
+
getLinkedAccounts: (userId: string) => Promise<{ provider: string; providerAccountId: string; email: string | null }[]>;
|
|
16
|
+
getProviderTokens: (userId: string, provider: string) => Promise<{ accessToken: string | null; refreshToken: string | null; tokenExpiresAt: Date | null }>;
|
|
17
|
+
unlinkAccount: (userId: string, provider: string) => Promise<void>;
|
|
18
|
+
getProviders: () => string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Stub a full custom-OIDC callback: discovery + a validly-signed id_token +
|
|
22
|
+
// the matching token/userinfo/jwks responses. Each call MUST use a distinct
|
|
23
|
+
// `issuer` — the plugin caches the remote JWKS by jwksUri at module scope, so
|
|
24
|
+
// reusing an issuer across tests would verify a fresh keypair against a stale
|
|
25
|
+
// cached key. `emailVerified` is set on both the id_token and userinfo claims
|
|
26
|
+
// so the merged profile carries it faithfully.
|
|
27
|
+
async function stubOidcProvider(opts: { issuer: string; email: string; emailVerified: boolean; sub: string }): Promise<void> {
|
|
28
|
+
const { publicKey, privateKey } = await generateKeyPair('RS256');
|
|
29
|
+
const jwk = await exportJWK(publicKey);
|
|
30
|
+
const kid = 'oidc-kid';
|
|
31
|
+
const idToken = await new SignJWT({ sub: opts.sub, email: opts.email, email_verified: opts.emailVerified, nonce: 'nonce' })
|
|
32
|
+
.setProtectedHeader({ alg: 'RS256', kid })
|
|
33
|
+
.setIssuer(opts.issuer)
|
|
34
|
+
.setAudience('oidc-client')
|
|
35
|
+
.setIssuedAt()
|
|
36
|
+
.setExpirationTime('5m')
|
|
37
|
+
.sign(privateKey);
|
|
38
|
+
|
|
39
|
+
vi.stubGlobal('fetch', vi.fn(async (input: Request | string | URL) => {
|
|
40
|
+
const href = input instanceof Request ? input.url : String(input);
|
|
41
|
+
if (href === `${opts.issuer}/.well-known/openid-configuration`) {
|
|
42
|
+
return Response.json({
|
|
43
|
+
issuer: opts.issuer,
|
|
44
|
+
authorization_endpoint: `${opts.issuer}/authorize`,
|
|
45
|
+
token_endpoint: `${opts.issuer}/token`,
|
|
46
|
+
userinfo_endpoint: `${opts.issuer}/userinfo`,
|
|
47
|
+
jwks_uri: `${opts.issuer}/jwks`,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
if (href === `${opts.issuer}/token`)
|
|
51
|
+
return Response.json({ access_token: 'provider-access-token', id_token: idToken });
|
|
52
|
+
if (href === `${opts.issuer}/userinfo`)
|
|
53
|
+
return Response.json({ sub: opts.sub, email: opts.email, email_verified: opts.emailVerified, name: 'Provider User' });
|
|
54
|
+
if (href === `${opts.issuer}/jwks`)
|
|
55
|
+
return Response.json({ keys: [{ ...jwk, kid, alg: 'RS256', use: 'sig' }] });
|
|
56
|
+
throw new Error(`unexpected fetch ${href}`);
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe('social-login plugin', () => {
|
|
61
|
+
let db: DatabaseAdapter;
|
|
62
|
+
let methods: SocialLoginMethods;
|
|
63
|
+
const onFirstLogin = vi.fn();
|
|
64
|
+
const tokenEncryptionKey = 'a'.repeat(32);
|
|
65
|
+
|
|
66
|
+
beforeEach(async () => {
|
|
67
|
+
db = createTestAdapter();
|
|
68
|
+
onFirstLogin.mockClear();
|
|
69
|
+
|
|
70
|
+
const plugin = socialLogin({
|
|
71
|
+
providers: [
|
|
72
|
+
{ name: 'google', clientId: 'google-id', clientSecret: 'google-secret' },
|
|
73
|
+
{ name: 'github', clientId: 'github-id', clientSecret: 'github-secret' },
|
|
74
|
+
],
|
|
75
|
+
autoRegister: true,
|
|
76
|
+
linkAccounts: true,
|
|
77
|
+
persistTokens: true,
|
|
78
|
+
tokenEncryptionKey,
|
|
79
|
+
onFirstLogin,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Get methods directly from plugin
|
|
83
|
+
methods = plugin.methods!({ db, config: { jwt: { key: 'x'.repeat(32) }, database: db } }) as unknown as SocialLoginMethods;
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
afterEach(() => {
|
|
87
|
+
vi.unstubAllGlobals();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('getProviders', () => {
|
|
91
|
+
it('returns configured provider names', () => {
|
|
92
|
+
expect(methods.getProviders()).toEqual(['google', 'github']);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('token exchange — response validation', () => {
|
|
97
|
+
it('accepts GitHub form-urlencoded token responses and requests JSON first', async () => {
|
|
98
|
+
const requests: Request[] = [];
|
|
99
|
+
vi.stubGlobal('fetch', vi.fn(async (input: Request | string | URL) => {
|
|
100
|
+
const request = input instanceof Request ? input : new Request(String(input));
|
|
101
|
+
requests.push(request);
|
|
102
|
+
if (request.url === 'https://github.com/login/oauth/access_token') {
|
|
103
|
+
return new Response('access_token=gho_test&token_type=bearer&scope=read%3Auser', {
|
|
104
|
+
status: 200,
|
|
105
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
if (request.url === 'https://api.github.com/user')
|
|
109
|
+
return Response.json({ id: 123, login: 'octocat', email: 'octocat@example.com', name: 'Octo Cat' });
|
|
110
|
+
throw new Error(`unexpected fetch ${request.url}`);
|
|
111
|
+
}));
|
|
112
|
+
|
|
113
|
+
const result = await methods.handleCallback(
|
|
114
|
+
'github',
|
|
115
|
+
'code',
|
|
116
|
+
'https://app.com/callback',
|
|
117
|
+
'verifier',
|
|
118
|
+
'state',
|
|
119
|
+
'state',
|
|
120
|
+
'nonce',
|
|
121
|
+
);
|
|
122
|
+
expect(result.profile.id).toBe('123');
|
|
123
|
+
expect(result.user.email).toBe('octocat@example.com');
|
|
124
|
+
expect(requests[0]?.headers.get('accept')).toBe('application/json');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('rejects a token response missing access_token (schema-validated via fetcher)', async () => {
|
|
128
|
+
vi.stubGlobal('fetch', vi.fn(async (input: Request | string | URL) => {
|
|
129
|
+
const href = input instanceof Request ? input.url : String(input);
|
|
130
|
+
if (href === 'https://oauth2.googleapis.com/token') {
|
|
131
|
+
// No access_token → fails the response schema → treated as a failed exchange.
|
|
132
|
+
return Response.json({ token_type: 'bearer' });
|
|
133
|
+
}
|
|
134
|
+
throw new Error(`unexpected fetch ${href}`);
|
|
135
|
+
}));
|
|
136
|
+
|
|
137
|
+
await expect(
|
|
138
|
+
methods.handleCallback('google', 'code', 'https://app.com/callback', 'verifier', 'state', 'state', 'nonce'),
|
|
139
|
+
).rejects.toThrow('Failed to exchange authorization code with google');
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('getAuthorizationUrl', () => {
|
|
144
|
+
it('generates URL with PKCE and state', async () => {
|
|
145
|
+
const result = await methods.getAuthorizationUrl('google', 'https://app.com/callback');
|
|
146
|
+
|
|
147
|
+
expect(result.url).toContain('accounts.google.com');
|
|
148
|
+
expect(result.url).toContain('client_id=google-id');
|
|
149
|
+
expect(result.url).toContain('code_challenge=');
|
|
150
|
+
expect(result.url).toContain('code_challenge_method=S256');
|
|
151
|
+
expect(result.state).toBeTruthy();
|
|
152
|
+
expect(result.codeVerifier).toBeTruthy();
|
|
153
|
+
expect(result.nonce).toBeTruthy();
|
|
154
|
+
expect(result.state).not.toBe(result.nonce);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('rejects unknown provider', async () => {
|
|
158
|
+
await expect(
|
|
159
|
+
methods.getAuthorizationUrl('unknown', 'https://app.com/callback'),
|
|
160
|
+
).rejects.toThrow('not configured');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('does not trust or cache discovery whose issuer differs from configured issuer', async () => {
|
|
164
|
+
const issuer = 'https://issuer-trust.example.com';
|
|
165
|
+
let discoveryAttempts = 0;
|
|
166
|
+
vi.stubGlobal('fetch', vi.fn(async (input: Request | string | URL) => {
|
|
167
|
+
const href = input instanceof Request ? input.url : String(input);
|
|
168
|
+
if (href !== `${issuer}/.well-known/openid-configuration`)
|
|
169
|
+
throw new Error(`unexpected fetch ${href}`);
|
|
170
|
+
discoveryAttempts++;
|
|
171
|
+
return Response.json({
|
|
172
|
+
issuer: discoveryAttempts === 1 ? 'https://attacker.example.com' : issuer,
|
|
173
|
+
authorization_endpoint: `${issuer}/discovered-authorize`,
|
|
174
|
+
token_endpoint: `${issuer}/token`,
|
|
175
|
+
jwks_uri: `${issuer}/jwks`,
|
|
176
|
+
});
|
|
177
|
+
}));
|
|
178
|
+
const plugin = socialLogin({
|
|
179
|
+
providers: [{ name: 'oidc-trust', clientId: 'trust-client', clientSecret: 'secret', issuer }],
|
|
180
|
+
});
|
|
181
|
+
const trustMethods = plugin.methods!({ db, config: { jwt: { key: 'x'.repeat(32) }, database: db } }) as unknown as SocialLoginMethods;
|
|
182
|
+
|
|
183
|
+
const degraded = await trustMethods.getAuthorizationUrl('oidc-trust', 'https://app.com/callback');
|
|
184
|
+
expect(degraded.url).toContain(`${issuer}/authorize`);
|
|
185
|
+
expect(degraded.url).not.toContain('discovered-authorize');
|
|
186
|
+
|
|
187
|
+
const recovered = await trustMethods.getAuthorizationUrl('oidc-trust', 'https://app.com/callback');
|
|
188
|
+
expect(recovered.url).toContain(`${issuer}/discovered-authorize`);
|
|
189
|
+
expect(discoveryAttempts).toBe(2);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe('getLinkedAccounts', () => {
|
|
194
|
+
it('returns linked social accounts for a user', async () => {
|
|
195
|
+
// Seed a user and social account directly
|
|
196
|
+
const user = await db.create<{ id: string }>({
|
|
197
|
+
model: 'user',
|
|
198
|
+
data: { email: 'alice@example.com', name: 'Alice', passwordHash: null, isActive: true },
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
await db.create({
|
|
202
|
+
model: 'social_account',
|
|
203
|
+
data: {
|
|
204
|
+
userId: user.id,
|
|
205
|
+
provider: 'google',
|
|
206
|
+
providerAccountId: 'google-123',
|
|
207
|
+
email: 'alice@gmail.com',
|
|
208
|
+
accessToken: null,
|
|
209
|
+
refreshToken: null,
|
|
210
|
+
tokenExpiresAt: null,
|
|
211
|
+
profile: null,
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const accounts = await methods.getLinkedAccounts(user.id);
|
|
216
|
+
expect(accounts).toHaveLength(1);
|
|
217
|
+
expect(accounts[0].provider).toBe('google');
|
|
218
|
+
expect(accounts[0].providerAccountId).toBe('google-123');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('returns empty array for user with no linked accounts', async () => {
|
|
222
|
+
const user = await db.create<{ id: string }>({
|
|
223
|
+
model: 'user',
|
|
224
|
+
data: { email: 'bob@example.com', name: 'Bob', passwordHash: null, isActive: true },
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const accounts = await methods.getLinkedAccounts(user.id);
|
|
228
|
+
expect(accounts).toEqual([]);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe('handleCallback', () => {
|
|
233
|
+
it('rejects a tampered OIDC id_token', async () => {
|
|
234
|
+
const { publicKey, privateKey } = await generateKeyPair('RS256');
|
|
235
|
+
const jwk = await exportJWK(publicKey);
|
|
236
|
+
const kid = 'test-kid';
|
|
237
|
+
const idToken = await new SignJWT({
|
|
238
|
+
sub: 'oidc-123',
|
|
239
|
+
email: 'oidc@example.com',
|
|
240
|
+
email_verified: true,
|
|
241
|
+
nonce: 'stored-nonce',
|
|
242
|
+
})
|
|
243
|
+
.setProtectedHeader({ alg: 'RS256', kid })
|
|
244
|
+
.setIssuer('https://issuer-a.example.com')
|
|
245
|
+
.setAudience('oidc-client')
|
|
246
|
+
.setIssuedAt()
|
|
247
|
+
.setExpirationTime('5m')
|
|
248
|
+
.sign(privateKey);
|
|
249
|
+
const parts = idToken.split('.');
|
|
250
|
+
parts[2] = `${parts[2].startsWith('a') ? 'b' : 'a'}${parts[2].slice(1)}`;
|
|
251
|
+
const tampered = parts.join('.');
|
|
252
|
+
|
|
253
|
+
vi.stubGlobal('fetch', vi.fn(async (input: Request | string | URL, init?: RequestInit) => {
|
|
254
|
+
// fetcher dispatches a single `Request`; jose passes a URL string.
|
|
255
|
+
const req = input instanceof Request ? input : undefined;
|
|
256
|
+
const href = req ? req.url : String(input);
|
|
257
|
+
const method = req ? req.method : init?.method;
|
|
258
|
+
if (href === 'https://issuer-a.example.com/.well-known/openid-configuration') {
|
|
259
|
+
return Response.json({
|
|
260
|
+
issuer: 'https://issuer-a.example.com',
|
|
261
|
+
authorization_endpoint: 'https://issuer-a.example.com/authorize',
|
|
262
|
+
token_endpoint: 'https://issuer-a.example.com/token',
|
|
263
|
+
userinfo_endpoint: 'https://issuer-a.example.com/userinfo',
|
|
264
|
+
jwks_uri: 'https://issuer-a.example.com/jwks',
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
if (href === 'https://issuer-a.example.com/token') {
|
|
268
|
+
expect(method).toBe('POST');
|
|
269
|
+
return Response.json({ access_token: 'provider-access', id_token: tampered });
|
|
270
|
+
}
|
|
271
|
+
if (href === 'https://issuer-a.example.com/jwks') {
|
|
272
|
+
return Response.json({ keys: [{ ...jwk, kid, alg: 'RS256', use: 'sig' }] });
|
|
273
|
+
}
|
|
274
|
+
throw new Error(`unexpected fetch ${href}`);
|
|
275
|
+
}));
|
|
276
|
+
const plugin = socialLogin({
|
|
277
|
+
providers: [{ name: 'oidc-a', clientId: 'oidc-client', clientSecret: 'secret', issuer: 'https://issuer-a.example.com' }],
|
|
278
|
+
tokenEncryptionKey,
|
|
279
|
+
});
|
|
280
|
+
const oidcMethods = plugin.methods!({ db, config: { jwt: { key: 'x'.repeat(32) }, database: db } }) as unknown as SocialLoginMethods;
|
|
281
|
+
|
|
282
|
+
await expect(
|
|
283
|
+
oidcMethods.handleCallback('oidc-a', 'code', 'https://app.com/callback', 'verifier', 'state', 'state', 'stored-nonce'),
|
|
284
|
+
).rejects.toThrow('Invalid ID token');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('rejects alg:none ID tokens before provider profile resolution', async () => {
|
|
288
|
+
const encode = (value: unknown): string => btoa(JSON.stringify(value))
|
|
289
|
+
.replace(/\+/g, '-')
|
|
290
|
+
.replace(/\//g, '_')
|
|
291
|
+
.replace(/=+$/g, '');
|
|
292
|
+
const unsigned = `${encode({ alg: 'none', typ: 'JWT' })}.${encode({
|
|
293
|
+
iss: 'https://issuer-none.example.com',
|
|
294
|
+
aud: 'oidc-client',
|
|
295
|
+
sub: 'attacker',
|
|
296
|
+
nonce: 'stored-nonce',
|
|
297
|
+
exp: Math.floor(Date.now() / 1000) + 300,
|
|
298
|
+
})}.`;
|
|
299
|
+
|
|
300
|
+
vi.stubGlobal('fetch', vi.fn(async (input: Request | string | URL) => {
|
|
301
|
+
const request = input instanceof Request ? input : undefined;
|
|
302
|
+
const href = request ? request.url : String(input);
|
|
303
|
+
if (href.endsWith('/.well-known/openid-configuration')) {
|
|
304
|
+
return Response.json({
|
|
305
|
+
issuer: 'https://issuer-none.example.com',
|
|
306
|
+
authorization_endpoint: 'https://issuer-none.example.com/authorize',
|
|
307
|
+
token_endpoint: 'https://issuer-none.example.com/token',
|
|
308
|
+
userinfo_endpoint: 'https://issuer-none.example.com/userinfo',
|
|
309
|
+
jwks_uri: 'https://issuer-none.example.com/jwks',
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
if (href.endsWith('/token'))
|
|
313
|
+
return Response.json({ access_token: 'provider-access', id_token: unsigned });
|
|
314
|
+
if (href.endsWith('/jwks'))
|
|
315
|
+
return Response.json({ keys: [] });
|
|
316
|
+
throw new Error(`unexpected fetch ${href}`);
|
|
317
|
+
}));
|
|
318
|
+
|
|
319
|
+
const plugin = socialLogin({
|
|
320
|
+
providers: [{ name: 'oidc-none', clientId: 'oidc-client', clientSecret: 'secret', issuer: 'https://issuer-none.example.com' }],
|
|
321
|
+
});
|
|
322
|
+
const methods = plugin.methods!({ db, config: { jwt: { key: 'x'.repeat(32) }, database: db } }) as unknown as SocialLoginMethods;
|
|
323
|
+
|
|
324
|
+
await expect(methods.handleCallback(
|
|
325
|
+
'oidc-none',
|
|
326
|
+
'code',
|
|
327
|
+
'https://app.com/callback',
|
|
328
|
+
'verifier',
|
|
329
|
+
'state',
|
|
330
|
+
'state',
|
|
331
|
+
'stored-nonce',
|
|
332
|
+
)).rejects.toThrow('Invalid ID token');
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('decrypts persisted provider tokens through getProviderTokens', async () => {
|
|
336
|
+
const { publicKey, privateKey } = await generateKeyPair('RS256');
|
|
337
|
+
const jwk = await exportJWK(publicKey);
|
|
338
|
+
const kid = 'token-kid';
|
|
339
|
+
const idToken = await new SignJWT({
|
|
340
|
+
sub: 'google-456',
|
|
341
|
+
email: 'tokens@example.com',
|
|
342
|
+
email_verified: true,
|
|
343
|
+
nonce: 'nonce',
|
|
344
|
+
})
|
|
345
|
+
.setProtectedHeader({ alg: 'RS256', kid })
|
|
346
|
+
.setIssuer('https://accounts.google.com')
|
|
347
|
+
.setAudience('google-id')
|
|
348
|
+
.setIssuedAt()
|
|
349
|
+
.setExpirationTime('5m')
|
|
350
|
+
.sign(privateKey);
|
|
351
|
+
|
|
352
|
+
vi.stubGlobal('fetch', vi.fn(async (input: Request | string | URL) => {
|
|
353
|
+
const href = input instanceof Request ? input.url : String(input);
|
|
354
|
+
if (href === 'https://oauth2.googleapis.com/token') {
|
|
355
|
+
return Response.json({
|
|
356
|
+
access_token: 'provider-access-token',
|
|
357
|
+
refresh_token: 'provider-refresh-token',
|
|
358
|
+
expires_in: 3600,
|
|
359
|
+
id_token: idToken,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
if (href === 'https://openidconnect.googleapis.com/v1/userinfo') {
|
|
363
|
+
return Response.json({ sub: 'google-456', email: 'tokens@example.com', email_verified: true, name: 'Token User' });
|
|
364
|
+
}
|
|
365
|
+
if (href === 'https://www.googleapis.com/oauth2/v3/certs') {
|
|
366
|
+
return Response.json({ keys: [{ ...jwk, kid, alg: 'RS256', use: 'sig' }] });
|
|
367
|
+
}
|
|
368
|
+
throw new Error(`unexpected fetch ${href}`);
|
|
369
|
+
}));
|
|
370
|
+
|
|
371
|
+
const result = await methods.handleCallback('google', 'code', 'https://app.com/callback', 'verifier', 'state', 'state', 'nonce');
|
|
372
|
+
const tokens = await methods.getProviderTokens(result.user.id, 'google');
|
|
373
|
+
expect(tokens.accessToken).toBe('provider-access-token');
|
|
374
|
+
expect(tokens.refreshToken).toBe('provider-refresh-token');
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('normalizes verified provider email before linking an existing user', async () => {
|
|
378
|
+
const issuer = 'https://issuer-normalized-link.example.com';
|
|
379
|
+
const existing = await db.create<{ id: string }>({
|
|
380
|
+
model: 'user',
|
|
381
|
+
data: { email: 'é.user@example.com', name: 'Existing', passwordHash: 'hashed', isActive: true },
|
|
382
|
+
});
|
|
383
|
+
await stubOidcProvider({
|
|
384
|
+
issuer,
|
|
385
|
+
email: 'E\u0301.User@EXAMPLE.COM',
|
|
386
|
+
emailVerified: true,
|
|
387
|
+
sub: 'normalized-link-sub',
|
|
388
|
+
});
|
|
389
|
+
const plugin = socialLogin({
|
|
390
|
+
providers: [{ name: 'oidc-normalized', clientId: 'oidc-client', clientSecret: 'secret', issuer }],
|
|
391
|
+
autoRegister: true,
|
|
392
|
+
linkAccounts: true,
|
|
393
|
+
tokenEncryptionKey,
|
|
394
|
+
});
|
|
395
|
+
const m = plugin.methods!({ db, config: { jwt: { key: 'x'.repeat(32) }, database: db } }) as unknown as SocialLoginMethods;
|
|
396
|
+
|
|
397
|
+
const result = await m.handleCallback('oidc-normalized', 'code', 'https://app.com/callback', 'verifier', 'state', 'state', 'nonce');
|
|
398
|
+
expect(result.user.id).toBe(existing.id);
|
|
399
|
+
expect(result.isNewUser).toBe(false);
|
|
400
|
+
expect(await m.getLinkedAccounts(existing.id)).toEqual([
|
|
401
|
+
expect.objectContaining({ email: 'é.user@example.com' }),
|
|
402
|
+
]);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('normalizes provider email for JIT-provisioned users', async () => {
|
|
406
|
+
const issuer = 'https://issuer-normalized-jit.example.com';
|
|
407
|
+
await stubOidcProvider({
|
|
408
|
+
issuer,
|
|
409
|
+
email: 'New.User@EXAMPLE.COM',
|
|
410
|
+
emailVerified: true,
|
|
411
|
+
sub: 'normalized-jit-sub',
|
|
412
|
+
});
|
|
413
|
+
const plugin = socialLogin({
|
|
414
|
+
providers: [{ name: 'oidc-jit', clientId: 'oidc-client', clientSecret: 'secret', issuer }],
|
|
415
|
+
autoRegister: true,
|
|
416
|
+
linkAccounts: true,
|
|
417
|
+
tokenEncryptionKey,
|
|
418
|
+
});
|
|
419
|
+
const m = plugin.methods!({ db, config: { jwt: { key: 'x'.repeat(32) }, database: db } }) as unknown as SocialLoginMethods;
|
|
420
|
+
|
|
421
|
+
const result = await m.handleCallback('oidc-jit', 'code', 'https://app.com/callback', 'verifier', 'state', 'state', 'nonce');
|
|
422
|
+
expect(result.user.email).toBe('new.user@example.com');
|
|
423
|
+
expect(result.isNewUser).toBe(true);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it('does not auto-link to an existing account when the provider reports email_verified:false (#5/#7)', async () => {
|
|
427
|
+
const issuer = 'https://issuer-unverified.example.com';
|
|
428
|
+
// A real account a verified-email auto-link would otherwise take over.
|
|
429
|
+
const victim = await db.create<{ id: string }>({
|
|
430
|
+
model: 'user',
|
|
431
|
+
data: { email: 'victim@example.com', name: 'Victim', passwordHash: 'hashed', isActive: true },
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
// Attacker controls a provider account asserting the victim's email, but unverified.
|
|
435
|
+
await stubOidcProvider({ issuer, email: 'victim@example.com', emailVerified: false, sub: 'attacker-sub-999' });
|
|
436
|
+
const plugin = socialLogin({
|
|
437
|
+
providers: [{ name: 'oidc-x', clientId: 'oidc-client', clientSecret: 'secret', issuer }],
|
|
438
|
+
autoRegister: true,
|
|
439
|
+
linkAccounts: true,
|
|
440
|
+
tokenEncryptionKey,
|
|
441
|
+
});
|
|
442
|
+
const m = plugin.methods!({ db, config: { jwt: { key: 'x'.repeat(32) }, database: db } }) as unknown as SocialLoginMethods;
|
|
443
|
+
|
|
444
|
+
// The id_token verifies, so we reach the link logic — but the by-email gate
|
|
445
|
+
// (emailVerified===true) is skipped, leaving only JIT provisioning, which
|
|
446
|
+
// collides with the victim's UNIQUE email and fails. The attacker's account
|
|
447
|
+
// must NOT attach to the victim either way.
|
|
448
|
+
await expect(
|
|
449
|
+
m.handleCallback('oidc-x', 'code', 'https://app.com/callback', 'verifier', 'state', 'state', 'nonce'),
|
|
450
|
+
).rejects.toThrow();
|
|
451
|
+
|
|
452
|
+
expect(await m.getLinkedAccounts(victim.id)).toEqual([]);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it('rejects the callback when the matched verified-email account is inactive (#5/#7 isActive guard)', async () => {
|
|
456
|
+
const issuer = 'https://issuer-inactive.example.com';
|
|
457
|
+
await db.create({
|
|
458
|
+
model: 'user',
|
|
459
|
+
data: { email: 'disabled@example.com', name: 'Disabled', passwordHash: 'hashed', isActive: false },
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
// Verified email → the link-by-email path runs and finds the disabled account.
|
|
463
|
+
await stubOidcProvider({ issuer, email: 'disabled@example.com', emailVerified: true, sub: 'provider-sub-1' });
|
|
464
|
+
const plugin = socialLogin({
|
|
465
|
+
providers: [{ name: 'oidc-x', clientId: 'oidc-client', clientSecret: 'secret', issuer }],
|
|
466
|
+
autoRegister: true,
|
|
467
|
+
linkAccounts: true,
|
|
468
|
+
tokenEncryptionKey,
|
|
469
|
+
});
|
|
470
|
+
const m = plugin.methods!({ db, config: { jwt: { key: 'x'.repeat(32) }, database: db } }) as unknown as SocialLoginMethods;
|
|
471
|
+
|
|
472
|
+
await expect(
|
|
473
|
+
m.handleCallback('oidc-x', 'code', 'https://app.com/callback', 'verifier', 'state', 'state', 'nonce'),
|
|
474
|
+
).rejects.toThrow('User account not found or disabled');
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it('accepts a Microsoft token from a concrete tenant with the default common authority', async () => {
|
|
478
|
+
const { publicKey, privateKey } = await generateKeyPair('RS256');
|
|
479
|
+
const jwk = await exportJWK(publicKey);
|
|
480
|
+
const tenantId = '11111111-2222-3333-4444-555555555555';
|
|
481
|
+
const issuer = `https://login.microsoftonline.com/${tenantId}/v2.0`;
|
|
482
|
+
const idToken = await new SignJWT({
|
|
483
|
+
sub: 'microsoft-sub',
|
|
484
|
+
tid: tenantId,
|
|
485
|
+
email: 'microsoft@example.com',
|
|
486
|
+
nonce: 'nonce',
|
|
487
|
+
})
|
|
488
|
+
.setProtectedHeader({ alg: 'RS256', kid: 'microsoft-kid' })
|
|
489
|
+
.setIssuer(issuer)
|
|
490
|
+
.setAudience('microsoft-client')
|
|
491
|
+
.setIssuedAt()
|
|
492
|
+
.setExpirationTime('5m')
|
|
493
|
+
.sign(privateKey);
|
|
494
|
+
|
|
495
|
+
vi.stubGlobal('fetch', vi.fn(async (input: Request | string | URL) => {
|
|
496
|
+
const href = input instanceof Request ? input.url : String(input);
|
|
497
|
+
if (href === 'https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration') {
|
|
498
|
+
return Response.json({
|
|
499
|
+
issuer: 'https://login.microsoftonline.com/{tenantid}/v2.0',
|
|
500
|
+
authorization_endpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
|
|
501
|
+
token_endpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
|
|
502
|
+
jwks_uri: 'https://login.microsoftonline.com/common/discovery/v2.0/keys',
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
if (href === 'https://login.microsoftonline.com/common/oauth2/v2.0/token')
|
|
506
|
+
return Response.json({ access_token: 'microsoft-access', id_token: idToken });
|
|
507
|
+
if (href === 'https://login.microsoftonline.com/common/discovery/v2.0/keys')
|
|
508
|
+
return Response.json({ keys: [{ ...jwk, kid: 'microsoft-kid', alg: 'RS256', use: 'sig' }] });
|
|
509
|
+
if (href === 'https://graph.microsoft.com/v1.0/me')
|
|
510
|
+
return Response.json({ id: 'microsoft-sub', mail: 'microsoft@example.com', displayName: 'Microsoft User' });
|
|
511
|
+
throw new Error(`unexpected fetch ${href}`);
|
|
512
|
+
}));
|
|
513
|
+
|
|
514
|
+
const plugin = socialLogin({
|
|
515
|
+
providers: [{ name: 'microsoft', clientId: 'microsoft-client', clientSecret: 'secret' }],
|
|
516
|
+
tokenEncryptionKey,
|
|
517
|
+
});
|
|
518
|
+
const microsoftMethods = plugin.methods!({ db, config: { jwt: { key: 'x'.repeat(32) }, database: db } }) as unknown as SocialLoginMethods;
|
|
519
|
+
|
|
520
|
+
const result = await microsoftMethods.handleCallback(
|
|
521
|
+
'microsoft',
|
|
522
|
+
'code',
|
|
523
|
+
'https://app.com/callback',
|
|
524
|
+
'verifier',
|
|
525
|
+
'state',
|
|
526
|
+
'state',
|
|
527
|
+
'nonce',
|
|
528
|
+
);
|
|
529
|
+
expect(result.user.email).toBe('microsoft@example.com');
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it('retries discovery after a transient failure instead of caching degradation', async () => {
|
|
533
|
+
const issuer = 'https://issuer-discovery-retry.example.com';
|
|
534
|
+
const { publicKey, privateKey } = await generateKeyPair('RS256');
|
|
535
|
+
const jwk = await exportJWK(publicKey);
|
|
536
|
+
const idToken = await new SignJWT({ sub: 'retry-sub', email: 'retry@example.com', email_verified: true, nonce: 'nonce' })
|
|
537
|
+
.setProtectedHeader({ alg: 'RS256', kid: 'retry-kid' })
|
|
538
|
+
.setIssuer(issuer)
|
|
539
|
+
.setAudience('retry-client')
|
|
540
|
+
.setIssuedAt()
|
|
541
|
+
.setExpirationTime('5m')
|
|
542
|
+
.sign(privateKey);
|
|
543
|
+
let discoveryAttempts = 0;
|
|
544
|
+
vi.stubGlobal('fetch', vi.fn(async (input: Request | string | URL) => {
|
|
545
|
+
const href = input instanceof Request ? input.url : String(input);
|
|
546
|
+
if (href === `${issuer}/.well-known/openid-configuration`) {
|
|
547
|
+
discoveryAttempts++;
|
|
548
|
+
if (discoveryAttempts === 1)
|
|
549
|
+
return Response.json({ error: 'temporarily unavailable' }, { status: 503 });
|
|
550
|
+
return Response.json({
|
|
551
|
+
issuer,
|
|
552
|
+
authorization_endpoint: `${issuer}/authorize`,
|
|
553
|
+
token_endpoint: `${issuer}/token`,
|
|
554
|
+
userinfo_endpoint: `${issuer}/userinfo`,
|
|
555
|
+
jwks_uri: `${issuer}/jwks`,
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
if (href === `${issuer}/token`)
|
|
559
|
+
return Response.json({ access_token: 'retry-access', id_token: idToken });
|
|
560
|
+
if (href === `${issuer}/jwks`)
|
|
561
|
+
return Response.json({ keys: [{ ...jwk, kid: 'retry-kid', alg: 'RS256', use: 'sig' }] });
|
|
562
|
+
if (href === `${issuer}/userinfo`)
|
|
563
|
+
return Response.json({ sub: 'retry-sub', email: 'retry@example.com', email_verified: true });
|
|
564
|
+
throw new Error(`unexpected fetch ${href}`);
|
|
565
|
+
}));
|
|
566
|
+
const plugin = socialLogin({
|
|
567
|
+
providers: [{ name: 'oidc-retry', clientId: 'retry-client', clientSecret: 'secret', issuer }],
|
|
568
|
+
tokenEncryptionKey,
|
|
569
|
+
});
|
|
570
|
+
const retryMethods = plugin.methods!({ db, config: { jwt: { key: 'x'.repeat(32) }, database: db } }) as unknown as SocialLoginMethods;
|
|
571
|
+
|
|
572
|
+
await expect(retryMethods.handleCallback(
|
|
573
|
+
'oidc-retry',
|
|
574
|
+
'code',
|
|
575
|
+
'https://app.com/callback',
|
|
576
|
+
'verifier',
|
|
577
|
+
'state',
|
|
578
|
+
'state',
|
|
579
|
+
'nonce',
|
|
580
|
+
)).rejects.toThrow(/JWKS URI|ID token/);
|
|
581
|
+
const result = await retryMethods.handleCallback(
|
|
582
|
+
'oidc-retry',
|
|
583
|
+
'code',
|
|
584
|
+
'https://app.com/callback',
|
|
585
|
+
'verifier',
|
|
586
|
+
'state',
|
|
587
|
+
'state',
|
|
588
|
+
'nonce',
|
|
589
|
+
);
|
|
590
|
+
expect(result.user.email).toBe('retry@example.com');
|
|
591
|
+
expect(discoveryAttempts).toBe(2);
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
it('fails closed when OIDC discovery degrades and the provider has no static jwksUri', async () => {
|
|
595
|
+
const issuer = 'https://issuer-degraded.example.com';
|
|
596
|
+
const { privateKey } = await generateKeyPair('RS256');
|
|
597
|
+
const idToken = await new SignJWT({ sub: 'x', email: 'x@example.com', email_verified: true, nonce: 'nonce' })
|
|
598
|
+
.setProtectedHeader({ alg: 'RS256', kid: 'k' })
|
|
599
|
+
.setIssuer(issuer)
|
|
600
|
+
.setAudience('oidc-client')
|
|
601
|
+
.setIssuedAt()
|
|
602
|
+
.setExpirationTime('5m')
|
|
603
|
+
.sign(privateKey);
|
|
604
|
+
|
|
605
|
+
// Discovery returns non-2xx → the plugin degrades to the static definition,
|
|
606
|
+
// which for a discovery-only OIDC provider carries NO jwksUri. id_token
|
|
607
|
+
// verification must then THROW (fail closed), never skip verification.
|
|
608
|
+
vi.stubGlobal('fetch', vi.fn(async (input: Request | string | URL) => {
|
|
609
|
+
const href = input instanceof Request ? input.url : String(input);
|
|
610
|
+
if (href === `${issuer}/.well-known/openid-configuration`)
|
|
611
|
+
return Response.json({ error: 'discovery down' }, { status: 503 });
|
|
612
|
+
if (href === `${issuer}/token`)
|
|
613
|
+
return Response.json({ access_token: 'provider-access-token', id_token: idToken });
|
|
614
|
+
throw new Error(`unexpected fetch ${href}`);
|
|
615
|
+
}));
|
|
616
|
+
|
|
617
|
+
const plugin = socialLogin({
|
|
618
|
+
providers: [{ name: 'oidc-degraded', clientId: 'oidc-client', clientSecret: 'secret', issuer }],
|
|
619
|
+
tokenEncryptionKey,
|
|
620
|
+
});
|
|
621
|
+
const m = plugin.methods!({ db, config: { jwt: { key: 'x'.repeat(32) }, database: db } }) as unknown as SocialLoginMethods;
|
|
622
|
+
|
|
623
|
+
await expect(
|
|
624
|
+
m.handleCallback('oidc-degraded', 'code', 'https://app.com/callback', 'verifier', 'state', 'state', 'nonce'),
|
|
625
|
+
).rejects.toThrow(/JWKS URI|ID token/);
|
|
626
|
+
});
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
describe('unlinkAccount', () => {
|
|
630
|
+
it('removes a linked social account', async () => {
|
|
631
|
+
const user = await db.create<{ id: string }>({
|
|
632
|
+
model: 'user',
|
|
633
|
+
data: { email: 'alice@example.com', name: 'Alice', passwordHash: null, isActive: true },
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
await db.create({
|
|
637
|
+
model: 'social_account',
|
|
638
|
+
data: {
|
|
639
|
+
userId: user.id,
|
|
640
|
+
provider: 'google',
|
|
641
|
+
providerAccountId: 'google-123',
|
|
642
|
+
email: 'alice@gmail.com',
|
|
643
|
+
accessToken: null,
|
|
644
|
+
refreshToken: null,
|
|
645
|
+
tokenExpiresAt: null,
|
|
646
|
+
profile: null,
|
|
647
|
+
},
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
await methods.unlinkAccount(user.id, 'google');
|
|
651
|
+
|
|
652
|
+
const accounts = await methods.getLinkedAccounts(user.id);
|
|
653
|
+
expect(accounts).toEqual([]);
|
|
654
|
+
});
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
describe('plugin configuration', () => {
|
|
658
|
+
it('rejects unknown provider in config', () => {
|
|
659
|
+
expect(() => socialLogin({
|
|
660
|
+
providers: [{ name: 'nonexistent', clientId: 'x', clientSecret: 'y' }],
|
|
661
|
+
tokenEncryptionKey,
|
|
662
|
+
})).toThrow('Unknown social login provider');
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
it('accepts custom OIDC provider with issuer', () => {
|
|
666
|
+
const plugin = socialLogin({
|
|
667
|
+
providers: [{ name: 'corporate-sso', clientId: 'x', clientSecret: 'y', issuer: 'https://sso.company.com' }],
|
|
668
|
+
tokenEncryptionKey,
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
const m = plugin.methods!({ db, config: { jwt: { key: 'x'.repeat(32) }, database: db } }) as unknown as SocialLoginMethods;
|
|
672
|
+
expect(m.getProviders()).toEqual(['corporate-sso']);
|
|
673
|
+
});
|
|
674
|
+
});
|
|
675
|
+
});
|