@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,880 @@
|
|
|
1
|
+
// src/plugins/social-login/index.ts
|
|
2
|
+
import { object as object2, string, union } from "@bajustone/fetcher/schema";
|
|
3
|
+
import { createRemoteJWKSet, importPKCS8, jwtVerify, SignJWT } from "jose";
|
|
4
|
+
|
|
5
|
+
// src/core/auth/email.ts
|
|
6
|
+
function normalizeEmail(email) {
|
|
7
|
+
return email.toLowerCase().normalize("NFC");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// src/core/errors.ts
|
|
11
|
+
var FortressError = class extends Error {
|
|
12
|
+
code;
|
|
13
|
+
statusCode;
|
|
14
|
+
retryAfter;
|
|
15
|
+
details;
|
|
16
|
+
/** RFC 6749 §5.2 / §4.1.2.1 machine code. Set by `Errors.oauth()`. */
|
|
17
|
+
oauthError;
|
|
18
|
+
/** Human-readable description for the OAuth `error_description` field. */
|
|
19
|
+
oauthDescription;
|
|
20
|
+
/** Optional URI for the OAuth `error_uri` field (§5.2). */
|
|
21
|
+
oauthErrorUri;
|
|
22
|
+
constructor(code, message, statusCode, options) {
|
|
23
|
+
super(message, { cause: options?.cause });
|
|
24
|
+
this.code = code;
|
|
25
|
+
this.statusCode = statusCode;
|
|
26
|
+
this.retryAfter = options?.retryAfter;
|
|
27
|
+
this.details = options?.details;
|
|
28
|
+
this.oauthError = options?.oauthError;
|
|
29
|
+
this.oauthDescription = options?.oauthDescription;
|
|
30
|
+
this.oauthErrorUri = options?.oauthErrorUri;
|
|
31
|
+
}
|
|
32
|
+
toJSON() {
|
|
33
|
+
return {
|
|
34
|
+
code: this.code,
|
|
35
|
+
message: this.message,
|
|
36
|
+
statusCode: this.statusCode,
|
|
37
|
+
...this.details !== void 0 && { details: this.details }
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
var Errors = {
|
|
42
|
+
unauthorized: (message = "Unauthorized") => new FortressError("UNAUTHORIZED", message, 401),
|
|
43
|
+
tokenReuse: () => new FortressError("TOKEN_REUSE", "Token reuse detected", 401),
|
|
44
|
+
sessionIdleTimeout: () => new FortressError("SESSION_IDLE_TIMEOUT", "Session idle timeout exceeded", 401),
|
|
45
|
+
sessionAbsoluteTimeout: () => new FortressError("SESSION_ABSOLUTE_TIMEOUT", "Session absolute timeout exceeded", 401),
|
|
46
|
+
forbidden: (message = "Forbidden") => new FortressError("FORBIDDEN", message, 403),
|
|
47
|
+
badRequest: (message = "Bad request") => new FortressError("BAD_REQUEST", message, 400),
|
|
48
|
+
notFound: (message = "Not found") => new FortressError("NOT_FOUND", message, 404),
|
|
49
|
+
conflict: (message = "Conflict", options) => new FortressError("CONFLICT", message, 409, options),
|
|
50
|
+
unprocessable: (message = "Unprocessable entity", options) => new FortressError("UNPROCESSABLE_ENTITY", message, 422, options),
|
|
51
|
+
rateLimited: (retryAfter) => new FortressError("RATE_LIMITED", "Too many requests", 429, { retryAfter }),
|
|
52
|
+
database: (message = "Database error", cause) => new FortressError("DATABASE_ERROR", message, 500, { cause }),
|
|
53
|
+
serviceUnavailable: (message = "Service unavailable", options) => new FortressError("SERVICE_UNAVAILABLE", message, 503, options),
|
|
54
|
+
validationError: (issues) => new FortressError("VALIDATION_ERROR", "Validation failed", 422, { details: issues }),
|
|
55
|
+
/**
|
|
56
|
+
* RFC 6749 §5.2 / §4.1.2.1 OAuth error.
|
|
57
|
+
*
|
|
58
|
+
* The HTTP error mapper detects `oauthError` and emits the OAuth-spec
|
|
59
|
+
* JSON body `{ error, error_description, error_uri? }` instead of the
|
|
60
|
+
* default fortress `{ code, message }` shape, so strict OAuth clients
|
|
61
|
+
* (Moodle, openid-client, Spring Security, etc.) can switch behaviour
|
|
62
|
+
* on the machine-readable `error` field.
|
|
63
|
+
*
|
|
64
|
+
* Status is inferred from the OAuth code per the spec table:
|
|
65
|
+
* - `invalid_client` → 401
|
|
66
|
+
* - everything else → 400
|
|
67
|
+
*/
|
|
68
|
+
oauth: (error, description, options) => {
|
|
69
|
+
const status = options?.status ?? (error === "invalid_client" ? 401 : 400);
|
|
70
|
+
const code = status === 401 ? "UNAUTHORIZED" : "BAD_REQUEST";
|
|
71
|
+
return new FortressError(code, description ?? error, status, {
|
|
72
|
+
oauthError: error,
|
|
73
|
+
oauthDescription: description,
|
|
74
|
+
oauthErrorUri: options?.errorUri,
|
|
75
|
+
cause: options?.cause
|
|
76
|
+
});
|
|
77
|
+
},
|
|
78
|
+
/**
|
|
79
|
+
* Reconstruct a {@link FortressError} from a JSON error body emitted by
|
|
80
|
+
* `errorToResponse`. Used by the in-process `fortress.call.*` client to
|
|
81
|
+
* convert non-2xx responses into typed throws that mirror what a direct
|
|
82
|
+
* service-method call would produce.
|
|
83
|
+
*
|
|
84
|
+
* Maps by HTTP status when the body doesn't carry a recognizable
|
|
85
|
+
* `{ code, message }` payload, so network errors and opaque upstream
|
|
86
|
+
* failures still become structured `FortressError`s.
|
|
87
|
+
*/
|
|
88
|
+
fromHttpResponse: (status, body) => {
|
|
89
|
+
const payload = body && typeof body === "object" ? body : {};
|
|
90
|
+
const message = typeof payload.message === "string" ? payload.message : `HTTP ${status}`;
|
|
91
|
+
const code = typeof payload.code === "string" && isFortressErrorCode(payload.code) ? payload.code : statusToErrorCode(status);
|
|
92
|
+
return new FortressError(code, message, status, { details: payload.details });
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
function isFortressErrorCode(code) {
|
|
96
|
+
return code === "UNAUTHORIZED" || code === "TOKEN_REUSE" || code === "SESSION_IDLE_TIMEOUT" || code === "SESSION_ABSOLUTE_TIMEOUT" || code === "FORBIDDEN" || code === "BAD_REQUEST" || code === "NOT_FOUND" || code === "CONFLICT" || code === "UNPROCESSABLE_ENTITY" || code === "RATE_LIMITED" || code === "DATABASE_ERROR" || code === "VALIDATION_ERROR" || code === "SERVICE_UNAVAILABLE";
|
|
97
|
+
}
|
|
98
|
+
function statusToErrorCode(status) {
|
|
99
|
+
if (status === 401)
|
|
100
|
+
return "UNAUTHORIZED";
|
|
101
|
+
if (status === 403)
|
|
102
|
+
return "FORBIDDEN";
|
|
103
|
+
if (status === 404)
|
|
104
|
+
return "NOT_FOUND";
|
|
105
|
+
if (status === 409)
|
|
106
|
+
return "CONFLICT";
|
|
107
|
+
if (status === 422)
|
|
108
|
+
return "UNPROCESSABLE_ENTITY";
|
|
109
|
+
if (status === 429)
|
|
110
|
+
return "RATE_LIMITED";
|
|
111
|
+
if (status === 503)
|
|
112
|
+
return "SERVICE_UNAVAILABLE";
|
|
113
|
+
if (status >= 500)
|
|
114
|
+
return "DATABASE_ERROR";
|
|
115
|
+
return "BAD_REQUEST";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// src/core/http/outbound.ts
|
|
119
|
+
import { createFetch } from "@bajustone/fetcher";
|
|
120
|
+
var OUTBOUND_TIMEOUT_MS = 1e4;
|
|
121
|
+
function requestWithSignal(request, signal) {
|
|
122
|
+
return new Request(request, request.body ? { signal, body: request.body, duplex: "half" } : { signal });
|
|
123
|
+
}
|
|
124
|
+
function fullExchangeTimeout(ms) {
|
|
125
|
+
return async (request, next) => {
|
|
126
|
+
const controller = new AbortController();
|
|
127
|
+
const userSignal = request.signal;
|
|
128
|
+
const abortFromUser = () => controller.abort(userSignal.reason);
|
|
129
|
+
if (userSignal.aborted)
|
|
130
|
+
abortFromUser();
|
|
131
|
+
else
|
|
132
|
+
userSignal.addEventListener("abort", abortFromUser, { once: true });
|
|
133
|
+
const timer = setTimeout(() => {
|
|
134
|
+
controller.abort(new DOMException(`Request timed out after ${ms}ms`, "TimeoutError"));
|
|
135
|
+
}, ms);
|
|
136
|
+
let cleaned = false;
|
|
137
|
+
const cleanup = () => {
|
|
138
|
+
if (cleaned)
|
|
139
|
+
return;
|
|
140
|
+
cleaned = true;
|
|
141
|
+
clearTimeout(timer);
|
|
142
|
+
userSignal.removeEventListener("abort", abortFromUser);
|
|
143
|
+
};
|
|
144
|
+
try {
|
|
145
|
+
const response = await next(requestWithSignal(request, controller.signal));
|
|
146
|
+
if (!response.body) {
|
|
147
|
+
cleanup();
|
|
148
|
+
return response;
|
|
149
|
+
}
|
|
150
|
+
const reader = response.body.getReader();
|
|
151
|
+
const body = new ReadableStream({
|
|
152
|
+
async pull(streamController) {
|
|
153
|
+
try {
|
|
154
|
+
const chunk = await reader.read();
|
|
155
|
+
if (chunk.done) {
|
|
156
|
+
cleanup();
|
|
157
|
+
streamController.close();
|
|
158
|
+
} else {
|
|
159
|
+
streamController.enqueue(chunk.value);
|
|
160
|
+
}
|
|
161
|
+
} catch (error) {
|
|
162
|
+
cleanup();
|
|
163
|
+
streamController.error(error);
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
async cancel(reason) {
|
|
167
|
+
cleanup();
|
|
168
|
+
await reader.cancel(reason);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
return new Response(body, {
|
|
172
|
+
headers: response.headers,
|
|
173
|
+
status: response.status,
|
|
174
|
+
statusText: response.statusText
|
|
175
|
+
});
|
|
176
|
+
} catch (error) {
|
|
177
|
+
cleanup();
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
function createOutboundClient(timeoutMs = OUTBOUND_TIMEOUT_MS) {
|
|
183
|
+
return createFetch({ baseUrl: "", middleware: [fullExchangeTimeout(timeoutMs)] });
|
|
184
|
+
}
|
|
185
|
+
var outboundClient = createOutboundClient();
|
|
186
|
+
|
|
187
|
+
// src/plugins/social-login/providers/apple.ts
|
|
188
|
+
var appleProvider = {
|
|
189
|
+
name: "apple",
|
|
190
|
+
discoveryUrl: "https://appleid.apple.com/.well-known/openid-configuration",
|
|
191
|
+
issuer: "https://appleid.apple.com",
|
|
192
|
+
jwksUri: "https://appleid.apple.com/auth/keys",
|
|
193
|
+
authorizationUrl: "https://appleid.apple.com/auth/authorize",
|
|
194
|
+
tokenUrl: "https://appleid.apple.com/auth/token",
|
|
195
|
+
userInfoUrl: void 0,
|
|
196
|
+
// No userinfo endpoint — profile from ID token
|
|
197
|
+
defaultScopes: ["name", "email"],
|
|
198
|
+
mapProfile(raw) {
|
|
199
|
+
const nameObj = raw.name;
|
|
200
|
+
const fullName = nameObj ? [nameObj.firstName, nameObj.lastName].filter(Boolean).join(" ") : void 0;
|
|
201
|
+
return {
|
|
202
|
+
id: String(raw.sub ?? ""),
|
|
203
|
+
email: String(raw.email ?? ""),
|
|
204
|
+
emailVerified: raw.email_verified === true || raw.email_verified === "true",
|
|
205
|
+
name: fullName || void 0,
|
|
206
|
+
displayName: fullName || void 0,
|
|
207
|
+
avatar: void 0,
|
|
208
|
+
// Apple does not provide avatars
|
|
209
|
+
raw
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// src/plugins/social-login/providers/discord.ts
|
|
215
|
+
var discordProvider = {
|
|
216
|
+
name: "discord",
|
|
217
|
+
authorizationUrl: "https://discord.com/api/oauth2/authorize",
|
|
218
|
+
tokenUrl: "https://discord.com/api/oauth2/token",
|
|
219
|
+
userInfoUrl: "https://discord.com/api/users/@me",
|
|
220
|
+
defaultScopes: ["identify", "email"],
|
|
221
|
+
mapProfile(raw) {
|
|
222
|
+
const id = String(raw.id ?? "");
|
|
223
|
+
const avatarHash = raw.avatar ? String(raw.avatar) : null;
|
|
224
|
+
const avatar = avatarHash ? `https://cdn.discordapp.com/avatars/${id}/${avatarHash}.png` : void 0;
|
|
225
|
+
return {
|
|
226
|
+
id,
|
|
227
|
+
email: String(raw.email ?? ""),
|
|
228
|
+
emailVerified: raw.verified === true,
|
|
229
|
+
name: String(raw.username ?? ""),
|
|
230
|
+
displayName: String(raw.global_name ?? raw.username ?? ""),
|
|
231
|
+
avatar,
|
|
232
|
+
raw
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
// src/plugins/social-login/providers/github.ts
|
|
238
|
+
import { array, object } from "@bajustone/fetcher/schema";
|
|
239
|
+
var userSchema = object({});
|
|
240
|
+
var emailsSchema = array(object({}));
|
|
241
|
+
var githubProvider = {
|
|
242
|
+
name: "github",
|
|
243
|
+
authorizationUrl: "https://github.com/login/oauth/authorize",
|
|
244
|
+
tokenUrl: "https://github.com/login/oauth/access_token",
|
|
245
|
+
userInfoUrl: "https://api.github.com/user",
|
|
246
|
+
defaultScopes: ["read:user", "user:email"],
|
|
247
|
+
async fetchProfile(accessToken) {
|
|
248
|
+
const userRes = await outboundClient.get("https://api.github.com/user", {
|
|
249
|
+
headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/vnd.github+json" },
|
|
250
|
+
responseSchema: userSchema
|
|
251
|
+
}).result();
|
|
252
|
+
if (!userRes.ok)
|
|
253
|
+
throw new Error("Failed to fetch GitHub profile");
|
|
254
|
+
const user = userRes.data;
|
|
255
|
+
if (!user.email) {
|
|
256
|
+
const emailsRes = await outboundClient.get("https://api.github.com/user/emails", {
|
|
257
|
+
headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/vnd.github+json" },
|
|
258
|
+
responseSchema: emailsSchema
|
|
259
|
+
}).result();
|
|
260
|
+
if (emailsRes.ok) {
|
|
261
|
+
const emails = emailsRes.data;
|
|
262
|
+
const primary = emails.find((e) => e.primary === true) ?? emails.find((e) => e.verified === true);
|
|
263
|
+
if (primary) {
|
|
264
|
+
user.email = primary.email;
|
|
265
|
+
user.email_verified = primary.verified === true;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return user;
|
|
270
|
+
},
|
|
271
|
+
mapProfile(raw) {
|
|
272
|
+
return {
|
|
273
|
+
id: String(raw.id ?? ""),
|
|
274
|
+
email: String(raw.email ?? ""),
|
|
275
|
+
emailVerified: raw.email_verified === true || raw.verified === true,
|
|
276
|
+
name: String(raw.name ?? raw.login ?? ""),
|
|
277
|
+
displayName: String(raw.name ?? raw.login ?? ""),
|
|
278
|
+
avatar: raw.avatar_url ? String(raw.avatar_url) : void 0,
|
|
279
|
+
raw
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
// src/plugins/social-login/providers/google.ts
|
|
285
|
+
var googleProvider = {
|
|
286
|
+
name: "google",
|
|
287
|
+
discoveryUrl: "https://accounts.google.com/.well-known/openid-configuration",
|
|
288
|
+
issuer: "https://accounts.google.com",
|
|
289
|
+
jwksUri: "https://www.googleapis.com/oauth2/v3/certs",
|
|
290
|
+
authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
291
|
+
tokenUrl: "https://oauth2.googleapis.com/token",
|
|
292
|
+
userInfoUrl: "https://openidconnect.googleapis.com/v1/userinfo",
|
|
293
|
+
defaultScopes: ["openid", "profile", "email"],
|
|
294
|
+
mapProfile(raw) {
|
|
295
|
+
return {
|
|
296
|
+
id: String(raw.sub ?? ""),
|
|
297
|
+
email: String(raw.email ?? ""),
|
|
298
|
+
emailVerified: raw.email_verified === true || raw.email_verified === "true",
|
|
299
|
+
name: String(raw.name ?? ""),
|
|
300
|
+
displayName: String(raw.name ?? ""),
|
|
301
|
+
avatar: raw.picture ? String(raw.picture) : void 0,
|
|
302
|
+
raw
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
// src/plugins/social-login/providers/microsoft.ts
|
|
308
|
+
function createMicrosoftProvider(options) {
|
|
309
|
+
const tenant = options?.tenant ?? "common";
|
|
310
|
+
const base = `https://login.microsoftonline.com/${tenant}/oauth2/v2.0`;
|
|
311
|
+
return {
|
|
312
|
+
name: "microsoft",
|
|
313
|
+
discoveryUrl: `https://login.microsoftonline.com/${tenant}/v2.0/.well-known/openid-configuration`,
|
|
314
|
+
issuer: `https://login.microsoftonline.com/${tenant}/v2.0`,
|
|
315
|
+
authorizationUrl: `${base}/authorize`,
|
|
316
|
+
tokenUrl: `${base}/token`,
|
|
317
|
+
userInfoUrl: "https://graph.microsoft.com/v1.0/me",
|
|
318
|
+
defaultScopes: ["openid", "profile", "email", "User.Read"],
|
|
319
|
+
mapProfile(raw) {
|
|
320
|
+
return {
|
|
321
|
+
id: String(raw.id ?? raw.sub ?? ""),
|
|
322
|
+
email: String(raw.mail ?? raw.userPrincipalName ?? raw.email ?? ""),
|
|
323
|
+
// Fail closed on an absent `email_verified` claim. Microsoft Graph `/me`
|
|
324
|
+
// and many Entra id_tokens omit it; treating "absent" as verified would
|
|
325
|
+
// let a Microsoft login auto-link by email to an existing account
|
|
326
|
+
// (takeover). Match the other providers — absent ⇒ unverified ⇒ no
|
|
327
|
+
// by-email auto-link. Tenants that trust their directory can opt back in
|
|
328
|
+
// with a custom `mapProfile`.
|
|
329
|
+
emailVerified: raw.email_verified === true || raw.email_verified === "true",
|
|
330
|
+
name: raw.givenName && raw.surname ? `${raw.givenName} ${raw.surname}` : String(raw.name ?? raw.displayName ?? ""),
|
|
331
|
+
displayName: String(raw.displayName ?? raw.name ?? ""),
|
|
332
|
+
avatar: void 0,
|
|
333
|
+
// Microsoft Graph requires separate photo endpoint
|
|
334
|
+
raw
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// src/plugins/social-login/providers/oidc.ts
|
|
341
|
+
var TRAILING_SLASHES = /\/+$/;
|
|
342
|
+
function createOidcProvider(name, issuerUrl) {
|
|
343
|
+
const issuer = issuerUrl.replace(TRAILING_SLASHES, "");
|
|
344
|
+
return {
|
|
345
|
+
name,
|
|
346
|
+
discoveryUrl: `${issuer}/.well-known/openid-configuration`,
|
|
347
|
+
issuer,
|
|
348
|
+
// These are placeholders — resolved from discovery at runtime when available.
|
|
349
|
+
authorizationUrl: `${issuer}/authorize`,
|
|
350
|
+
tokenUrl: `${issuer}/token`,
|
|
351
|
+
userInfoUrl: `${issuer}/userinfo`,
|
|
352
|
+
defaultScopes: ["openid", "profile", "email"],
|
|
353
|
+
mapProfile(raw) {
|
|
354
|
+
return {
|
|
355
|
+
id: String(raw.sub ?? ""),
|
|
356
|
+
email: String(raw.email ?? ""),
|
|
357
|
+
emailVerified: raw.email_verified === true || raw.email_verified === "true",
|
|
358
|
+
name: String(raw.name ?? ""),
|
|
359
|
+
displayName: String(raw.preferred_username ?? raw.name ?? ""),
|
|
360
|
+
avatar: raw.picture ? String(raw.picture) : void 0,
|
|
361
|
+
raw
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// src/plugins/social-login/providers/index.ts
|
|
368
|
+
var builtInProviders = {
|
|
369
|
+
microsoft: createMicrosoftProvider(),
|
|
370
|
+
google: googleProvider,
|
|
371
|
+
github: githubProvider,
|
|
372
|
+
apple: appleProvider,
|
|
373
|
+
discord: discordProvider
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
// src/plugins/social-login/index.ts
|
|
377
|
+
var jsonObjectSchema = object2({});
|
|
378
|
+
var tokenResponseSchema = union([object2({ access_token: string() }), string()]);
|
|
379
|
+
var encoder = new TextEncoder();
|
|
380
|
+
var HEX_32_BYTE_KEY = /^[0-9a-f]+$/i;
|
|
381
|
+
var discoveryCache = /* @__PURE__ */ new Map();
|
|
382
|
+
var jwksCache = /* @__PURE__ */ new Map();
|
|
383
|
+
var appleSecretCache = /* @__PURE__ */ new Map();
|
|
384
|
+
var 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;
|
|
385
|
+
var 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;
|
|
386
|
+
function randomBase64Url(bytes = 32) {
|
|
387
|
+
const data = new Uint8Array(bytes);
|
|
388
|
+
crypto.getRandomValues(data);
|
|
389
|
+
return btoa(String.fromCharCode(...data)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
390
|
+
}
|
|
391
|
+
function timingSafeEqual(a, b) {
|
|
392
|
+
const left = encoder.encode(a);
|
|
393
|
+
const right = encoder.encode(b);
|
|
394
|
+
const len = Math.max(left.length, right.length);
|
|
395
|
+
let diff = left.length ^ right.length;
|
|
396
|
+
for (let i = 0; i < len; i++)
|
|
397
|
+
diff |= (left[i] ?? 0) ^ (right[i] ?? 0);
|
|
398
|
+
return diff === 0;
|
|
399
|
+
}
|
|
400
|
+
function decodeKeyMaterial(key) {
|
|
401
|
+
const trimmed = key.trim();
|
|
402
|
+
if (HEX_32_BYTE_KEY.test(trimmed) && trimmed.length === 64) {
|
|
403
|
+
return new Uint8Array(trimmed.match(/.{2}/g).map((byte) => Number.parseInt(byte, 16)));
|
|
404
|
+
}
|
|
405
|
+
try {
|
|
406
|
+
const normalized = trimmed.replace(/-/g, "+").replace(/_/g, "/");
|
|
407
|
+
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
|
|
408
|
+
const raw = atob(padded);
|
|
409
|
+
const bytes = new Uint8Array(raw.length);
|
|
410
|
+
for (let i = 0; i < raw.length; i++)
|
|
411
|
+
bytes[i] = raw.charCodeAt(i);
|
|
412
|
+
if (bytes.length >= 32)
|
|
413
|
+
return bytes;
|
|
414
|
+
} catch {
|
|
415
|
+
}
|
|
416
|
+
return encoder.encode(key);
|
|
417
|
+
}
|
|
418
|
+
async function importTokenEncryptionKey(key) {
|
|
419
|
+
const bytes = decodeKeyMaterial(key);
|
|
420
|
+
if (bytes.length !== 32) {
|
|
421
|
+
throw Errors.badRequest("socialLogin tokenEncryptionKey must decode to exactly 32 bytes for AES-256-GCM");
|
|
422
|
+
}
|
|
423
|
+
const raw = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
424
|
+
return crypto.subtle.importKey("raw", raw, "AES-GCM", false, ["encrypt", "decrypt"]);
|
|
425
|
+
}
|
|
426
|
+
async function encryptToken(keyPromise, value) {
|
|
427
|
+
if (!value)
|
|
428
|
+
return null;
|
|
429
|
+
if (!keyPromise)
|
|
430
|
+
throw Errors.badRequest("socialLogin token persistence requires tokenEncryptionKey");
|
|
431
|
+
const key = await keyPromise;
|
|
432
|
+
const iv = new Uint8Array(12);
|
|
433
|
+
crypto.getRandomValues(iv);
|
|
434
|
+
const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoder.encode(value)));
|
|
435
|
+
return `v1.${base64UrlFromBytes(iv)}.${base64UrlFromBytes(ciphertext)}`;
|
|
436
|
+
}
|
|
437
|
+
async function decryptToken(keyPromise, value) {
|
|
438
|
+
if (!value)
|
|
439
|
+
return null;
|
|
440
|
+
if (!keyPromise)
|
|
441
|
+
throw Errors.badRequest("socialLogin token persistence requires tokenEncryptionKey");
|
|
442
|
+
const [version, ivEncoded, ciphertextEncoded] = value.split(".");
|
|
443
|
+
if (version !== "v1" || !ivEncoded || !ciphertextEncoded)
|
|
444
|
+
throw Errors.badRequest("Invalid encrypted social-login token format");
|
|
445
|
+
const key = await keyPromise;
|
|
446
|
+
const iv = base64UrlToBytes(ivEncoded);
|
|
447
|
+
const ciphertext = base64UrlToBytes(ciphertextEncoded);
|
|
448
|
+
const ivBuffer = iv.buffer.slice(iv.byteOffset, iv.byteOffset + iv.byteLength);
|
|
449
|
+
const ciphertextBuffer = ciphertext.buffer.slice(ciphertext.byteOffset, ciphertext.byteOffset + ciphertext.byteLength);
|
|
450
|
+
const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv: ivBuffer }, key, ciphertextBuffer);
|
|
451
|
+
return new TextDecoder().decode(plaintext);
|
|
452
|
+
}
|
|
453
|
+
function base64UrlFromBytes(data) {
|
|
454
|
+
return btoa(String.fromCharCode(...data)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
455
|
+
}
|
|
456
|
+
function base64UrlToBytes(value) {
|
|
457
|
+
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
458
|
+
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
|
|
459
|
+
const raw = atob(padded);
|
|
460
|
+
const bytes = new Uint8Array(raw.length);
|
|
461
|
+
for (let i = 0; i < raw.length; i++)
|
|
462
|
+
bytes[i] = raw.charCodeAt(i);
|
|
463
|
+
return bytes;
|
|
464
|
+
}
|
|
465
|
+
function resolveProviderDefinition(providerConfig) {
|
|
466
|
+
if (providerConfig.name === "microsoft" && providerConfig.tenant) {
|
|
467
|
+
return createMicrosoftProvider({ tenant: providerConfig.tenant });
|
|
468
|
+
}
|
|
469
|
+
if (providerConfig.issuer) {
|
|
470
|
+
const base = createOidcProvider(providerConfig.name, providerConfig.issuer);
|
|
471
|
+
return {
|
|
472
|
+
...base,
|
|
473
|
+
authorizationUrl: providerConfig.authorizationUrl ?? base.authorizationUrl,
|
|
474
|
+
tokenUrl: providerConfig.tokenUrl ?? base.tokenUrl,
|
|
475
|
+
userInfoUrl: providerConfig.userInfoUrl ?? base.userInfoUrl,
|
|
476
|
+
jwksUri: providerConfig.jwksUri
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
const builtIn = builtInProviders[providerConfig.name];
|
|
480
|
+
if (builtIn)
|
|
481
|
+
return builtIn;
|
|
482
|
+
throw Errors.badRequest(`Unknown social login provider: ${providerConfig.name}. Provide an 'issuer' URL for custom OIDC providers.`);
|
|
483
|
+
}
|
|
484
|
+
function isAbsoluteUrl(value) {
|
|
485
|
+
if (typeof value !== "string" || value.length === 0)
|
|
486
|
+
return false;
|
|
487
|
+
try {
|
|
488
|
+
return new URL(value).protocol === "https:";
|
|
489
|
+
} catch {
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
function isMicrosoftMultiTenant(providerConfig) {
|
|
494
|
+
return providerConfig.name === "microsoft" && ["common", "organizations", "consumers"].includes(providerConfig.tenant ?? "common");
|
|
495
|
+
}
|
|
496
|
+
async function resolveDiscoveredDefinition(definition, providerConfig) {
|
|
497
|
+
if (!definition.discoveryUrl || definition.issuer && definition.jwksUri && !providerConfig.issuer) {
|
|
498
|
+
return definition;
|
|
499
|
+
}
|
|
500
|
+
const cacheKey = `${providerConfig.name}:${definition.discoveryUrl}`;
|
|
501
|
+
const existing = discoveryCache.get(cacheKey);
|
|
502
|
+
if (existing)
|
|
503
|
+
return existing;
|
|
504
|
+
const pending = (async () => {
|
|
505
|
+
const res = await outboundClient.get(definition.discoveryUrl, { responseSchema: jsonObjectSchema }).result();
|
|
506
|
+
if (!res.ok)
|
|
507
|
+
return null;
|
|
508
|
+
const discovered = res.data;
|
|
509
|
+
const discoveredIssuer = discovered.issuer;
|
|
510
|
+
const expectedIssuer = definition.issuer ?? providerConfig.issuer;
|
|
511
|
+
const trustedIssuer = isMicrosoftMultiTenant(providerConfig) ? typeof discoveredIssuer === "string" && (discoveredIssuer === expectedIssuer || MICROSOFT_DISCOVERY_ISSUER_PATTERN.test(discoveredIssuer)) : typeof discoveredIssuer === "string" && discoveredIssuer === expectedIssuer;
|
|
512
|
+
if (!trustedIssuer || !isAbsoluteUrl(discovered.jwks_uri))
|
|
513
|
+
return null;
|
|
514
|
+
return {
|
|
515
|
+
...definition,
|
|
516
|
+
// Multi-tenant Microsoft authorities advertise a {tenantid} issuer in
|
|
517
|
+
// discovery; the concrete tenant is checked against the ID token below.
|
|
518
|
+
issuer: isMicrosoftMultiTenant(providerConfig) ? definition.issuer : discoveredIssuer,
|
|
519
|
+
authorizationUrl: providerConfig.authorizationUrl ?? (isAbsoluteUrl(discovered.authorization_endpoint) ? discovered.authorization_endpoint : definition.authorizationUrl),
|
|
520
|
+
tokenUrl: providerConfig.tokenUrl ?? (isAbsoluteUrl(discovered.token_endpoint) ? discovered.token_endpoint : definition.tokenUrl),
|
|
521
|
+
userInfoUrl: providerConfig.userInfoUrl ?? (isAbsoluteUrl(discovered.userinfo_endpoint) ? discovered.userinfo_endpoint : definition.userInfoUrl),
|
|
522
|
+
jwksUri: providerConfig.jwksUri ?? discovered.jwks_uri
|
|
523
|
+
};
|
|
524
|
+
})();
|
|
525
|
+
const cached = pending.then((result) => {
|
|
526
|
+
if (result)
|
|
527
|
+
return result;
|
|
528
|
+
if (discoveryCache.get(cacheKey) === cached)
|
|
529
|
+
discoveryCache.delete(cacheKey);
|
|
530
|
+
return definition;
|
|
531
|
+
}, (error) => {
|
|
532
|
+
if (discoveryCache.get(cacheKey) === cached)
|
|
533
|
+
discoveryCache.delete(cacheKey);
|
|
534
|
+
throw error;
|
|
535
|
+
});
|
|
536
|
+
discoveryCache.set(cacheKey, cached);
|
|
537
|
+
return cached;
|
|
538
|
+
}
|
|
539
|
+
async function verifyIdToken(definition, providerConfig, idToken, nonce) {
|
|
540
|
+
const shouldVerify = Boolean(definition.discoveryUrl || definition.jwksUri || idToken);
|
|
541
|
+
if (!shouldVerify)
|
|
542
|
+
return null;
|
|
543
|
+
if (!idToken)
|
|
544
|
+
throw Errors.unauthorized(`Provider ${providerConfig.name} did not return an ID token`);
|
|
545
|
+
if (!definition.jwksUri)
|
|
546
|
+
throw Errors.unauthorized(`Provider ${providerConfig.name} has no JWKS URI for ID token verification`);
|
|
547
|
+
if (!definition.issuer)
|
|
548
|
+
throw Errors.unauthorized(`Provider ${providerConfig.name} has no issuer for ID token verification`);
|
|
549
|
+
if (!nonce)
|
|
550
|
+
throw Errors.unauthorized(`Missing stored OIDC nonce for ${providerConfig.name}`);
|
|
551
|
+
let jwks = jwksCache.get(definition.jwksUri);
|
|
552
|
+
if (!jwks) {
|
|
553
|
+
jwks = createRemoteJWKSet(new URL(definition.jwksUri));
|
|
554
|
+
jwksCache.set(definition.jwksUri, jwks);
|
|
555
|
+
}
|
|
556
|
+
const multiTenantMicrosoft = isMicrosoftMultiTenant(providerConfig);
|
|
557
|
+
const { payload } = await jwtVerify(idToken, jwks, {
|
|
558
|
+
...multiTenantMicrosoft ? {} : { issuer: definition.issuer },
|
|
559
|
+
audience: providerConfig.clientId,
|
|
560
|
+
// Accept asymmetric OIDC signing algorithms while excluding `none` and
|
|
561
|
+
// every shared-secret HS* algorithm (which could enable key-confusion).
|
|
562
|
+
algorithms: ["RS256", "PS256", "ES256", "EdDSA"]
|
|
563
|
+
}).catch(() => {
|
|
564
|
+
throw Errors.unauthorized(`Invalid ID token from ${providerConfig.name}`);
|
|
565
|
+
});
|
|
566
|
+
if (multiTenantMicrosoft) {
|
|
567
|
+
const issuer = typeof payload.iss === "string" ? payload.iss : "";
|
|
568
|
+
const tenantId = typeof payload.tid === "string" ? payload.tid : "";
|
|
569
|
+
const match = MICROSOFT_TOKEN_ISSUER_PATTERN.exec(issuer);
|
|
570
|
+
if (!match || !tenantId || match[1].toLowerCase() !== tenantId.toLowerCase())
|
|
571
|
+
throw Errors.unauthorized(`Invalid ID token issuer from ${providerConfig.name}`);
|
|
572
|
+
}
|
|
573
|
+
if (payload.nonce !== nonce)
|
|
574
|
+
throw Errors.unauthorized(`Invalid ID token nonce from ${providerConfig.name}`);
|
|
575
|
+
return payload;
|
|
576
|
+
}
|
|
577
|
+
async function buildClientSecret(providerConfig, definition) {
|
|
578
|
+
if (providerConfig.name !== "apple" || !providerConfig.privateKey)
|
|
579
|
+
return providerConfig.clientSecret;
|
|
580
|
+
if (!providerConfig.teamId || !providerConfig.keyId)
|
|
581
|
+
throw Errors.badRequest("Apple social login requires teamId, keyId, and privateKey");
|
|
582
|
+
const cacheKey = `${providerConfig.teamId}:${providerConfig.clientId}:${providerConfig.keyId}`;
|
|
583
|
+
const cached = appleSecretCache.get(cacheKey);
|
|
584
|
+
if (cached && cached.expiresAt > Date.now() + 6e4)
|
|
585
|
+
return cached.value;
|
|
586
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
587
|
+
const expiresAt = now + 60 * 60;
|
|
588
|
+
const key = await importPKCS8(providerConfig.privateKey, "ES256");
|
|
589
|
+
const issuer = definition.issuer ?? "https://appleid.apple.com";
|
|
590
|
+
const value = await new SignJWT({}).setProtectedHeader({ alg: "ES256", kid: providerConfig.keyId }).setIssuer(providerConfig.teamId).setSubject(providerConfig.clientId).setAudience(issuer).setIssuedAt(now).setExpirationTime(expiresAt).sign(key);
|
|
591
|
+
appleSecretCache.set(cacheKey, { value, expiresAt: expiresAt * 1e3 });
|
|
592
|
+
return value;
|
|
593
|
+
}
|
|
594
|
+
async function generatePKCE() {
|
|
595
|
+
const bytes = new Uint8Array(32);
|
|
596
|
+
crypto.getRandomValues(bytes);
|
|
597
|
+
const codeVerifier = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
598
|
+
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(codeVerifier));
|
|
599
|
+
const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(hash))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
600
|
+
return { codeVerifier, codeChallenge };
|
|
601
|
+
}
|
|
602
|
+
function socialLogin(config) {
|
|
603
|
+
const autoRegister = config.autoRegister ?? true;
|
|
604
|
+
const linkAccounts = config.linkAccounts ?? true;
|
|
605
|
+
const persistTokens = config.persistTokens ?? false;
|
|
606
|
+
if (persistTokens && !config.tokenEncryptionKey)
|
|
607
|
+
throw Errors.badRequest("socialLogin requires tokenEncryptionKey when persistTokens is enabled");
|
|
608
|
+
if (persistTokens && config.tokenEncryptionKey && decodeKeyMaterial(config.tokenEncryptionKey).length !== 32)
|
|
609
|
+
throw Errors.badRequest("socialLogin tokenEncryptionKey must decode to exactly 32 bytes for AES-256-GCM");
|
|
610
|
+
const tokenEncryptionKey = persistTokens && config.tokenEncryptionKey ? importTokenEncryptionKey(config.tokenEncryptionKey) : null;
|
|
611
|
+
const providerMap = /* @__PURE__ */ new Map();
|
|
612
|
+
for (const pc of config.providers) {
|
|
613
|
+
providerMap.set(pc.name, { definition: resolveProviderDefinition(pc), config: pc });
|
|
614
|
+
}
|
|
615
|
+
return {
|
|
616
|
+
name: "social-login",
|
|
617
|
+
models: [{
|
|
618
|
+
name: "social_account",
|
|
619
|
+
fields: {
|
|
620
|
+
id: { type: "number", required: true },
|
|
621
|
+
userId: { type: "number", required: true, references: { model: "user", field: "id" } },
|
|
622
|
+
provider: { type: "string", required: true },
|
|
623
|
+
providerAccountId: { type: "string", required: true },
|
|
624
|
+
email: { type: "string" },
|
|
625
|
+
accessToken: { type: "string" },
|
|
626
|
+
refreshToken: { type: "string" },
|
|
627
|
+
tokenExpiresAt: { type: "date" },
|
|
628
|
+
profile: { type: "string" },
|
|
629
|
+
createdAt: { type: "date", required: true },
|
|
630
|
+
updatedAt: { type: "date", required: true }
|
|
631
|
+
},
|
|
632
|
+
constraints: [
|
|
633
|
+
{ type: "unique", fields: ["userId", "provider"] },
|
|
634
|
+
{ type: "unique", fields: ["provider", "providerAccountId"] }
|
|
635
|
+
]
|
|
636
|
+
}],
|
|
637
|
+
methods: (ctx) => ({
|
|
638
|
+
/**
|
|
639
|
+
* Get the authorization URL to redirect the user to for a given provider.
|
|
640
|
+
* Returns the URL and state that must be stored (e.g., in session) for callback verification.
|
|
641
|
+
*/
|
|
642
|
+
async getAuthorizationUrl(providerName, redirectUri) {
|
|
643
|
+
const entry = providerMap.get(providerName);
|
|
644
|
+
if (!entry)
|
|
645
|
+
throw Errors.badRequest(`Provider '${providerName}' is not configured`);
|
|
646
|
+
const { definition, config: pc } = entry;
|
|
647
|
+
const resolvedDefinition = await resolveDiscoveredDefinition(definition, pc);
|
|
648
|
+
const { codeVerifier, codeChallenge } = await generatePKCE();
|
|
649
|
+
const state = randomBase64Url();
|
|
650
|
+
const nonce = randomBase64Url();
|
|
651
|
+
const scopes = pc.scopes ?? resolvedDefinition.defaultScopes;
|
|
652
|
+
const params = new URLSearchParams({
|
|
653
|
+
client_id: pc.clientId,
|
|
654
|
+
redirect_uri: redirectUri,
|
|
655
|
+
response_type: "code",
|
|
656
|
+
scope: scopes.join(" "),
|
|
657
|
+
state,
|
|
658
|
+
code_challenge: codeChallenge,
|
|
659
|
+
code_challenge_method: "S256"
|
|
660
|
+
});
|
|
661
|
+
if (scopes.includes("openid") || resolvedDefinition.discoveryUrl)
|
|
662
|
+
params.set("nonce", nonce);
|
|
663
|
+
const url = `${resolvedDefinition.authorizationUrl}?${params.toString()}`;
|
|
664
|
+
return { url, state, codeVerifier, nonce };
|
|
665
|
+
},
|
|
666
|
+
/**
|
|
667
|
+
* Handle the OAuth callback. Exchanges the authorization code for tokens,
|
|
668
|
+
* fetches the user profile, and performs JIT provisioning / account linking.
|
|
669
|
+
*
|
|
670
|
+
* Returns the Fortress user (existing or newly created) and the provider profile.
|
|
671
|
+
*/
|
|
672
|
+
async handleCallback(providerName, code, redirectUri, codeVerifier, returnedState, storedState, storedNonce) {
|
|
673
|
+
if (!returnedState || !storedState || !timingSafeEqual(returnedState, storedState))
|
|
674
|
+
throw Errors.unauthorized("Invalid OAuth state");
|
|
675
|
+
const entry = providerMap.get(providerName);
|
|
676
|
+
if (!entry)
|
|
677
|
+
throw Errors.badRequest(`Provider '${providerName}' is not configured`);
|
|
678
|
+
const { definition, config: pc } = entry;
|
|
679
|
+
const resolvedDefinition = await resolveDiscoveredDefinition(definition, pc);
|
|
680
|
+
const clientSecret = await buildClientSecret(pc, resolvedDefinition);
|
|
681
|
+
const tokenRes = await outboundClient.post(resolvedDefinition.tokenUrl, {
|
|
682
|
+
headers: {
|
|
683
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
684
|
+
// GitHub defaults to form-urlencoded; explicitly requesting JSON
|
|
685
|
+
// makes its response interoperable while retaining the fallback.
|
|
686
|
+
"Accept": "application/json"
|
|
687
|
+
},
|
|
688
|
+
body: new URLSearchParams({
|
|
689
|
+
grant_type: "authorization_code",
|
|
690
|
+
code,
|
|
691
|
+
redirect_uri: redirectUri,
|
|
692
|
+
client_id: pc.clientId,
|
|
693
|
+
client_secret: clientSecret,
|
|
694
|
+
code_verifier: codeVerifier
|
|
695
|
+
}),
|
|
696
|
+
responseSchema: tokenResponseSchema
|
|
697
|
+
}).result();
|
|
698
|
+
if (!tokenRes.ok) {
|
|
699
|
+
throw Errors.unauthorized(`Failed to exchange authorization code with ${providerName}`);
|
|
700
|
+
}
|
|
701
|
+
const tokenData = tokenRes.data;
|
|
702
|
+
const tokens = typeof tokenData === "string" ? (() => {
|
|
703
|
+
const form = new URLSearchParams(tokenData);
|
|
704
|
+
return {
|
|
705
|
+
access_token: form.get("access_token") ?? "",
|
|
706
|
+
refresh_token: form.get("refresh_token") ?? void 0,
|
|
707
|
+
expires_in: form.has("expires_in") ? Number(form.get("expires_in")) : void 0,
|
|
708
|
+
id_token: form.get("id_token") ?? void 0
|
|
709
|
+
};
|
|
710
|
+
})() : tokenData;
|
|
711
|
+
if (!tokens.access_token)
|
|
712
|
+
throw Errors.unauthorized(`Failed to exchange authorization code with ${providerName}`);
|
|
713
|
+
const idTokenClaims = await verifyIdToken(resolvedDefinition, pc, tokens.id_token, storedNonce);
|
|
714
|
+
let rawProfile;
|
|
715
|
+
if (resolvedDefinition.fetchProfile) {
|
|
716
|
+
rawProfile = await resolvedDefinition.fetchProfile(tokens.access_token).catch(() => {
|
|
717
|
+
throw Errors.unauthorized(`Failed to fetch profile from ${providerName}`);
|
|
718
|
+
});
|
|
719
|
+
} else if (resolvedDefinition.userInfoUrl) {
|
|
720
|
+
const profileRes = await outboundClient.get(resolvedDefinition.userInfoUrl, {
|
|
721
|
+
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
|
722
|
+
responseSchema: jsonObjectSchema
|
|
723
|
+
}).result();
|
|
724
|
+
if (!profileRes.ok) {
|
|
725
|
+
throw Errors.unauthorized(`Failed to fetch profile from ${providerName}`);
|
|
726
|
+
}
|
|
727
|
+
rawProfile = profileRes.data;
|
|
728
|
+
} else if (idTokenClaims) {
|
|
729
|
+
rawProfile = { ...idTokenClaims };
|
|
730
|
+
} else {
|
|
731
|
+
throw Errors.unauthorized(`Provider ${providerName} returned no profile data`);
|
|
732
|
+
}
|
|
733
|
+
if (idTokenClaims)
|
|
734
|
+
rawProfile = { ...idTokenClaims, ...rawProfile };
|
|
735
|
+
const profile = resolvedDefinition.mapProfile(rawProfile);
|
|
736
|
+
if (!profile.id)
|
|
737
|
+
throw Errors.unauthorized(`Provider ${providerName} returned no subject`);
|
|
738
|
+
const canonicalProfileEmail = normalizeEmail(profile.email);
|
|
739
|
+
if (pc.allowedDomains && pc.allowedDomains.length > 0) {
|
|
740
|
+
const domain = canonicalProfileEmail.split("@")[1];
|
|
741
|
+
const allowedDomains = pc.allowedDomains.map((value) => value.normalize("NFC").toLowerCase());
|
|
742
|
+
if (!domain || !allowedDomains.includes(domain)) {
|
|
743
|
+
throw Errors.unauthorized(`Email domain '${domain ?? ""}' is not allowed for ${providerName}`);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
return ctx.db.transaction(async (tx) => {
|
|
747
|
+
const encryptedAccessToken = persistTokens ? await encryptToken(tokenEncryptionKey, tokens.access_token) : null;
|
|
748
|
+
const encryptedRefreshToken = persistTokens ? await encryptToken(tokenEncryptionKey, tokens.refresh_token) : null;
|
|
749
|
+
const socialAccount = await tx.findOne({
|
|
750
|
+
model: "social_account",
|
|
751
|
+
where: [
|
|
752
|
+
{ field: "provider", operator: "=", value: providerName },
|
|
753
|
+
{ field: "providerAccountId", operator: "=", value: profile.id }
|
|
754
|
+
]
|
|
755
|
+
});
|
|
756
|
+
let user = null;
|
|
757
|
+
let isNewUser = false;
|
|
758
|
+
if (socialAccount) {
|
|
759
|
+
user = await tx.findOne({
|
|
760
|
+
model: "user",
|
|
761
|
+
where: [{ field: "id", operator: "=", value: socialAccount.userId }]
|
|
762
|
+
});
|
|
763
|
+
if (!user || !user.isActive)
|
|
764
|
+
throw Errors.unauthorized("User account not found or disabled");
|
|
765
|
+
await tx.update({
|
|
766
|
+
model: "social_account",
|
|
767
|
+
where: [{ field: "id", operator: "=", value: socialAccount.id }],
|
|
768
|
+
data: {
|
|
769
|
+
accessToken: encryptedAccessToken,
|
|
770
|
+
refreshToken: encryptedRefreshToken ?? socialAccount.refreshToken,
|
|
771
|
+
tokenExpiresAt: tokens.expires_in ? new Date(Date.now() + tokens.expires_in * 1e3) : null,
|
|
772
|
+
profile: JSON.stringify(profile.raw),
|
|
773
|
+
email: canonicalProfileEmail,
|
|
774
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
775
|
+
}
|
|
776
|
+
});
|
|
777
|
+
} else {
|
|
778
|
+
if (linkAccounts && canonicalProfileEmail && profile.emailVerified) {
|
|
779
|
+
user = await tx.findOne({
|
|
780
|
+
model: "user",
|
|
781
|
+
where: [{ field: "email", operator: "=", value: canonicalProfileEmail }]
|
|
782
|
+
});
|
|
783
|
+
if (user && !user.isActive)
|
|
784
|
+
throw Errors.unauthorized("User account not found or disabled");
|
|
785
|
+
}
|
|
786
|
+
if (!user) {
|
|
787
|
+
if (!autoRegister) {
|
|
788
|
+
throw Errors.unauthorized("Auto-registration is disabled");
|
|
789
|
+
}
|
|
790
|
+
const mapped = config.mapProfile ? config.mapProfile(providerName, profile) : null;
|
|
791
|
+
const email = normalizeEmail(mapped?.email ?? canonicalProfileEmail);
|
|
792
|
+
const name = mapped?.name ?? profile.name ?? profile.email;
|
|
793
|
+
user = await tx.create({
|
|
794
|
+
model: "user",
|
|
795
|
+
data: {
|
|
796
|
+
email,
|
|
797
|
+
name,
|
|
798
|
+
passwordHash: null,
|
|
799
|
+
// Social-only user
|
|
800
|
+
isActive: true,
|
|
801
|
+
emailVerified: profile.emailVerified
|
|
802
|
+
}
|
|
803
|
+
});
|
|
804
|
+
isNewUser = true;
|
|
805
|
+
if (config.onFirstLogin) {
|
|
806
|
+
await config.onFirstLogin({ id: user.id }, providerName, profile);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
await tx.create({
|
|
810
|
+
model: "social_account",
|
|
811
|
+
data: {
|
|
812
|
+
userId: user.id,
|
|
813
|
+
provider: providerName,
|
|
814
|
+
providerAccountId: profile.id,
|
|
815
|
+
email: canonicalProfileEmail,
|
|
816
|
+
accessToken: encryptedAccessToken,
|
|
817
|
+
refreshToken: encryptedRefreshToken,
|
|
818
|
+
tokenExpiresAt: tokens.expires_in ? new Date(Date.now() + tokens.expires_in * 1e3) : null,
|
|
819
|
+
profile: JSON.stringify(profile.raw)
|
|
820
|
+
}
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
return { user, profile, isNewUser };
|
|
824
|
+
});
|
|
825
|
+
},
|
|
826
|
+
/**
|
|
827
|
+
* List social accounts linked to a user.
|
|
828
|
+
*/
|
|
829
|
+
async getLinkedAccounts(userId) {
|
|
830
|
+
const accounts = await ctx.db.findMany({
|
|
831
|
+
model: "social_account",
|
|
832
|
+
where: [{ field: "userId", operator: "=", value: userId }]
|
|
833
|
+
});
|
|
834
|
+
return accounts.map((a) => ({
|
|
835
|
+
provider: a.provider,
|
|
836
|
+
providerAccountId: a.providerAccountId,
|
|
837
|
+
email: a.email
|
|
838
|
+
}));
|
|
839
|
+
},
|
|
840
|
+
/**
|
|
841
|
+
* Return decrypted provider tokens for a linked account.
|
|
842
|
+
*/
|
|
843
|
+
async getProviderTokens(userId, provider) {
|
|
844
|
+
const account = await ctx.db.findOne({
|
|
845
|
+
model: "social_account",
|
|
846
|
+
where: [
|
|
847
|
+
{ field: "userId", operator: "=", value: userId },
|
|
848
|
+
{ field: "provider", operator: "=", value: provider }
|
|
849
|
+
]
|
|
850
|
+
});
|
|
851
|
+
if (!account)
|
|
852
|
+
throw Errors.notFound("Linked social account not found");
|
|
853
|
+
return {
|
|
854
|
+
accessToken: await decryptToken(tokenEncryptionKey, account.accessToken),
|
|
855
|
+
refreshToken: await decryptToken(tokenEncryptionKey, account.refreshToken),
|
|
856
|
+
tokenExpiresAt: account.tokenExpiresAt
|
|
857
|
+
};
|
|
858
|
+
},
|
|
859
|
+
/**
|
|
860
|
+
* Unlink a social account from a user.
|
|
861
|
+
*/
|
|
862
|
+
async unlinkAccount(userId, provider) {
|
|
863
|
+
await ctx.db.delete({
|
|
864
|
+
model: "social_account",
|
|
865
|
+
where: [
|
|
866
|
+
{ field: "userId", operator: "=", value: userId },
|
|
867
|
+
{ field: "provider", operator: "=", value: provider }
|
|
868
|
+
]
|
|
869
|
+
});
|
|
870
|
+
},
|
|
871
|
+
/** Get list of configured provider names */
|
|
872
|
+
getProviders() {
|
|
873
|
+
return Array.from(providerMap.keys());
|
|
874
|
+
}
|
|
875
|
+
})
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
export {
|
|
879
|
+
socialLogin
|
|
880
|
+
};
|