@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,1696 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/plugins/oauth/index.ts
|
|
21
|
+
var oauth_exports = {};
|
|
22
|
+
__export(oauth_exports, {
|
|
23
|
+
generateCodeChallenge: () => generateCodeChallenge,
|
|
24
|
+
generateCodeVerifier: () => generateCodeVerifier,
|
|
25
|
+
matchRedirectUri: () => matchRedirectUri,
|
|
26
|
+
oauth: () => oauth,
|
|
27
|
+
toOidcUserinfo: () => toOidcUserinfo,
|
|
28
|
+
verifyCodeChallenge: () => verifyCodeChallenge
|
|
29
|
+
});
|
|
30
|
+
module.exports = __toCommonJS(oauth_exports);
|
|
31
|
+
|
|
32
|
+
// src/core/auth/refresh-token.ts
|
|
33
|
+
async function generateRefreshToken() {
|
|
34
|
+
const bytes = new Uint8Array(32);
|
|
35
|
+
crypto.getRandomValues(bytes);
|
|
36
|
+
const raw = base64UrlEncode(bytes);
|
|
37
|
+
const hash = await hashToken(raw);
|
|
38
|
+
return { raw, hash };
|
|
39
|
+
}
|
|
40
|
+
function generateTokenFamily() {
|
|
41
|
+
const bytes = new Uint8Array(16);
|
|
42
|
+
crypto.getRandomValues(bytes);
|
|
43
|
+
return base64UrlEncode(bytes);
|
|
44
|
+
}
|
|
45
|
+
async function hashToken(raw) {
|
|
46
|
+
const data = new TextEncoder().encode(raw);
|
|
47
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
48
|
+
return hexEncode(new Uint8Array(hashBuffer));
|
|
49
|
+
}
|
|
50
|
+
function base64UrlEncode(bytes) {
|
|
51
|
+
const binString = Array.from(bytes, (b) => String.fromCodePoint(b)).join("");
|
|
52
|
+
return btoa(binString).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
53
|
+
}
|
|
54
|
+
function hexEncode(bytes) {
|
|
55
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// src/core/auth/timing-safe.ts
|
|
59
|
+
function timingSafeEqualHex(a, b) {
|
|
60
|
+
if (a.length !== b.length)
|
|
61
|
+
return false;
|
|
62
|
+
let diff = 0;
|
|
63
|
+
for (let i = 0; i < a.length; i++)
|
|
64
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
65
|
+
return diff === 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// src/core/errors.ts
|
|
69
|
+
var FortressError = class extends Error {
|
|
70
|
+
code;
|
|
71
|
+
statusCode;
|
|
72
|
+
retryAfter;
|
|
73
|
+
details;
|
|
74
|
+
/** RFC 6749 §5.2 / §4.1.2.1 machine code. Set by `Errors.oauth()`. */
|
|
75
|
+
oauthError;
|
|
76
|
+
/** Human-readable description for the OAuth `error_description` field. */
|
|
77
|
+
oauthDescription;
|
|
78
|
+
/** Optional URI for the OAuth `error_uri` field (§5.2). */
|
|
79
|
+
oauthErrorUri;
|
|
80
|
+
constructor(code, message, statusCode, options) {
|
|
81
|
+
super(message, { cause: options?.cause });
|
|
82
|
+
this.code = code;
|
|
83
|
+
this.statusCode = statusCode;
|
|
84
|
+
this.retryAfter = options?.retryAfter;
|
|
85
|
+
this.details = options?.details;
|
|
86
|
+
this.oauthError = options?.oauthError;
|
|
87
|
+
this.oauthDescription = options?.oauthDescription;
|
|
88
|
+
this.oauthErrorUri = options?.oauthErrorUri;
|
|
89
|
+
}
|
|
90
|
+
toJSON() {
|
|
91
|
+
return {
|
|
92
|
+
code: this.code,
|
|
93
|
+
message: this.message,
|
|
94
|
+
statusCode: this.statusCode,
|
|
95
|
+
...this.details !== void 0 && { details: this.details }
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
var Errors = {
|
|
100
|
+
unauthorized: (message = "Unauthorized") => new FortressError("UNAUTHORIZED", message, 401),
|
|
101
|
+
tokenReuse: () => new FortressError("TOKEN_REUSE", "Token reuse detected", 401),
|
|
102
|
+
sessionIdleTimeout: () => new FortressError("SESSION_IDLE_TIMEOUT", "Session idle timeout exceeded", 401),
|
|
103
|
+
sessionAbsoluteTimeout: () => new FortressError("SESSION_ABSOLUTE_TIMEOUT", "Session absolute timeout exceeded", 401),
|
|
104
|
+
forbidden: (message = "Forbidden") => new FortressError("FORBIDDEN", message, 403),
|
|
105
|
+
badRequest: (message = "Bad request") => new FortressError("BAD_REQUEST", message, 400),
|
|
106
|
+
notFound: (message = "Not found") => new FortressError("NOT_FOUND", message, 404),
|
|
107
|
+
conflict: (message = "Conflict", options) => new FortressError("CONFLICT", message, 409, options),
|
|
108
|
+
unprocessable: (message = "Unprocessable entity", options) => new FortressError("UNPROCESSABLE_ENTITY", message, 422, options),
|
|
109
|
+
rateLimited: (retryAfter) => new FortressError("RATE_LIMITED", "Too many requests", 429, { retryAfter }),
|
|
110
|
+
database: (message = "Database error", cause) => new FortressError("DATABASE_ERROR", message, 500, { cause }),
|
|
111
|
+
serviceUnavailable: (message = "Service unavailable", options) => new FortressError("SERVICE_UNAVAILABLE", message, 503, options),
|
|
112
|
+
validationError: (issues) => new FortressError("VALIDATION_ERROR", "Validation failed", 422, { details: issues }),
|
|
113
|
+
/**
|
|
114
|
+
* RFC 6749 §5.2 / §4.1.2.1 OAuth error.
|
|
115
|
+
*
|
|
116
|
+
* The HTTP error mapper detects `oauthError` and emits the OAuth-spec
|
|
117
|
+
* JSON body `{ error, error_description, error_uri? }` instead of the
|
|
118
|
+
* default fortress `{ code, message }` shape, so strict OAuth clients
|
|
119
|
+
* (Moodle, openid-client, Spring Security, etc.) can switch behaviour
|
|
120
|
+
* on the machine-readable `error` field.
|
|
121
|
+
*
|
|
122
|
+
* Status is inferred from the OAuth code per the spec table:
|
|
123
|
+
* - `invalid_client` → 401
|
|
124
|
+
* - everything else → 400
|
|
125
|
+
*/
|
|
126
|
+
oauth: (error, description, options) => {
|
|
127
|
+
const status = options?.status ?? (error === "invalid_client" ? 401 : 400);
|
|
128
|
+
const code = status === 401 ? "UNAUTHORIZED" : "BAD_REQUEST";
|
|
129
|
+
return new FortressError(code, description ?? error, status, {
|
|
130
|
+
oauthError: error,
|
|
131
|
+
oauthDescription: description,
|
|
132
|
+
oauthErrorUri: options?.errorUri,
|
|
133
|
+
cause: options?.cause
|
|
134
|
+
});
|
|
135
|
+
},
|
|
136
|
+
/**
|
|
137
|
+
* Reconstruct a {@link FortressError} from a JSON error body emitted by
|
|
138
|
+
* `errorToResponse`. Used by the in-process `fortress.call.*` client to
|
|
139
|
+
* convert non-2xx responses into typed throws that mirror what a direct
|
|
140
|
+
* service-method call would produce.
|
|
141
|
+
*
|
|
142
|
+
* Maps by HTTP status when the body doesn't carry a recognizable
|
|
143
|
+
* `{ code, message }` payload, so network errors and opaque upstream
|
|
144
|
+
* failures still become structured `FortressError`s.
|
|
145
|
+
*/
|
|
146
|
+
fromHttpResponse: (status, body) => {
|
|
147
|
+
const payload = body && typeof body === "object" ? body : {};
|
|
148
|
+
const message = typeof payload.message === "string" ? payload.message : `HTTP ${status}`;
|
|
149
|
+
const code = typeof payload.code === "string" && isFortressErrorCode(payload.code) ? payload.code : statusToErrorCode(status);
|
|
150
|
+
return new FortressError(code, message, status, { details: payload.details });
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
function isFortressErrorCode(code) {
|
|
154
|
+
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";
|
|
155
|
+
}
|
|
156
|
+
function statusToErrorCode(status) {
|
|
157
|
+
if (status === 401)
|
|
158
|
+
return "UNAUTHORIZED";
|
|
159
|
+
if (status === 403)
|
|
160
|
+
return "FORBIDDEN";
|
|
161
|
+
if (status === 404)
|
|
162
|
+
return "NOT_FOUND";
|
|
163
|
+
if (status === 409)
|
|
164
|
+
return "CONFLICT";
|
|
165
|
+
if (status === 422)
|
|
166
|
+
return "UNPROCESSABLE_ENTITY";
|
|
167
|
+
if (status === 429)
|
|
168
|
+
return "RATE_LIMITED";
|
|
169
|
+
if (status === 503)
|
|
170
|
+
return "SERVICE_UNAVAILABLE";
|
|
171
|
+
if (status >= 500)
|
|
172
|
+
return "DATABASE_ERROR";
|
|
173
|
+
return "BAD_REQUEST";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// src/plugins/oauth/id-token.ts
|
|
177
|
+
var import_jose = require("jose");
|
|
178
|
+
async function issueIdToken(params) {
|
|
179
|
+
const ttl = params.ttlSeconds ?? 3600;
|
|
180
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
181
|
+
const scopes = params.scope ? params.scope.split(" ").filter(Boolean) : [];
|
|
182
|
+
const exposeEmail = scopes.includes("email");
|
|
183
|
+
const exposeProfile = scopes.includes("profile");
|
|
184
|
+
const claims = {};
|
|
185
|
+
if (params.authTimeSeconds !== void 0)
|
|
186
|
+
claims.auth_time = params.authTimeSeconds;
|
|
187
|
+
if (params.nonce)
|
|
188
|
+
claims.nonce = params.nonce;
|
|
189
|
+
if (exposeEmail) {
|
|
190
|
+
claims.email = params.user.email;
|
|
191
|
+
if (typeof params.user.emailVerified === "boolean")
|
|
192
|
+
claims.email_verified = params.user.emailVerified;
|
|
193
|
+
}
|
|
194
|
+
if (exposeProfile) {
|
|
195
|
+
claims.name = params.user.name;
|
|
196
|
+
claims.preferred_username = params.user.email;
|
|
197
|
+
}
|
|
198
|
+
if (params.user.updatedAt) {
|
|
199
|
+
claims.updated_at = Math.floor(new Date(params.user.updatedAt).getTime() / 1e3);
|
|
200
|
+
}
|
|
201
|
+
return new import_jose.SignJWT(claims).setProtectedHeader({ alg: "RS256", kid: params.signingKey.kid, typ: "JWT" }).setIssuer(params.issuerUrl).setSubject(String(params.user.id)).setAudience(params.clientId).setIssuedAt(now).setExpirationTime(now + ttl).sign(params.signingKey.privateKey);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// src/plugins/oauth/jwks.ts
|
|
205
|
+
var import_jose2 = require("jose");
|
|
206
|
+
async function getActiveSigningKey(db) {
|
|
207
|
+
const existing = await db.findOne({
|
|
208
|
+
model: "oauth_signing_key",
|
|
209
|
+
where: [{ field: "rotatedAt", operator: "isNull", value: null }]
|
|
210
|
+
});
|
|
211
|
+
if (existing)
|
|
212
|
+
return hydrate(existing);
|
|
213
|
+
const { publicKey, privateKey } = await (0, import_jose2.generateKeyPair)("RS256", { extractable: true });
|
|
214
|
+
const publicJwk = await (0, import_jose2.exportJWK)(publicKey);
|
|
215
|
+
const privateJwk = await (0, import_jose2.exportJWK)(privateKey);
|
|
216
|
+
const kid = await (0, import_jose2.calculateJwkThumbprint)(publicJwk, "sha256");
|
|
217
|
+
publicJwk.kid = kid;
|
|
218
|
+
publicJwk.alg = "RS256";
|
|
219
|
+
publicJwk.use = "sig";
|
|
220
|
+
privateJwk.kid = kid;
|
|
221
|
+
privateJwk.alg = "RS256";
|
|
222
|
+
await db.create({
|
|
223
|
+
model: "oauth_signing_key",
|
|
224
|
+
data: {
|
|
225
|
+
kid,
|
|
226
|
+
alg: "RS256",
|
|
227
|
+
publicJwk: JSON.stringify(publicJwk),
|
|
228
|
+
privateJwk: JSON.stringify(privateJwk),
|
|
229
|
+
rotatedAt: null
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
return {
|
|
233
|
+
kid,
|
|
234
|
+
alg: "RS256",
|
|
235
|
+
privateKey,
|
|
236
|
+
publicJwk
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
async function listJwks(db, graceSeconds = 3600) {
|
|
240
|
+
const all = await db.findMany({
|
|
241
|
+
model: "oauth_signing_key",
|
|
242
|
+
where: []
|
|
243
|
+
});
|
|
244
|
+
const cutoff = Date.now() - Math.max(0, graceSeconds) * 1e3;
|
|
245
|
+
return {
|
|
246
|
+
keys: all.filter((row) => row.rotatedAt === null || new Date(row.rotatedAt).getTime() > cutoff).sort((a, b) => {
|
|
247
|
+
if (a.rotatedAt === null && b.rotatedAt !== null)
|
|
248
|
+
return -1;
|
|
249
|
+
if (a.rotatedAt !== null && b.rotatedAt === null)
|
|
250
|
+
return 1;
|
|
251
|
+
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
|
252
|
+
}).map((row) => JSON.parse(row.publicJwk))
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
async function rotateSigningKey(db, graceSeconds = 3600) {
|
|
256
|
+
return db.transaction(async (tx) => {
|
|
257
|
+
if (tx.dialect === "pg" && tx.rawQuery)
|
|
258
|
+
await tx.rawQuery("SELECT pg_advisory_xact_lock(hashtext(?))", ["fortress-oauth-signing-key"]);
|
|
259
|
+
const now = /* @__PURE__ */ new Date();
|
|
260
|
+
const active = await tx.findOne({
|
|
261
|
+
model: "oauth_signing_key",
|
|
262
|
+
where: [{ field: "rotatedAt", operator: "isNull", value: null }]
|
|
263
|
+
});
|
|
264
|
+
if (active) {
|
|
265
|
+
await tx.update({
|
|
266
|
+
model: "oauth_signing_key",
|
|
267
|
+
where: [{ field: "id", operator: "=", value: active.id }],
|
|
268
|
+
data: { rotatedAt: now }
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
const cutoff = new Date(now.getTime() - Math.max(0, graceSeconds) * 1e3);
|
|
272
|
+
const expired = await tx.findMany({
|
|
273
|
+
model: "oauth_signing_key",
|
|
274
|
+
where: [{ field: "rotatedAt", operator: "lt", value: cutoff }]
|
|
275
|
+
});
|
|
276
|
+
for (const key of expired) {
|
|
277
|
+
await tx.delete({
|
|
278
|
+
model: "oauth_signing_key",
|
|
279
|
+
where: [{ field: "id", operator: "=", value: key.id }]
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
return getActiveSigningKey(tx);
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
async function hydrate(record) {
|
|
286
|
+
const publicJwk = JSON.parse(record.publicJwk);
|
|
287
|
+
const privateJwk = JSON.parse(record.privateJwk);
|
|
288
|
+
const privateKey = await (0, import_jose2.importJWK)(privateJwk, "RS256");
|
|
289
|
+
return {
|
|
290
|
+
kid: record.kid,
|
|
291
|
+
alg: "RS256",
|
|
292
|
+
privateKey,
|
|
293
|
+
publicJwk
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// src/plugins/oauth/pkce.ts
|
|
298
|
+
var CODE_VERIFIER_PATTERN = /^[\w.~-]{43,128}$/;
|
|
299
|
+
function generateCodeVerifier() {
|
|
300
|
+
const bytes = new Uint8Array(32);
|
|
301
|
+
crypto.getRandomValues(bytes);
|
|
302
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
303
|
+
}
|
|
304
|
+
async function generateCodeChallenge(verifier) {
|
|
305
|
+
const data = new TextEncoder().encode(verifier);
|
|
306
|
+
const hash = await crypto.subtle.digest("SHA-256", data);
|
|
307
|
+
return btoa(String.fromCharCode(...new Uint8Array(hash))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
308
|
+
}
|
|
309
|
+
async function verifyCodeChallenge(verifier, challenge, method) {
|
|
310
|
+
if (method !== "S256")
|
|
311
|
+
return false;
|
|
312
|
+
if (!CODE_VERIFIER_PATTERN.test(verifier))
|
|
313
|
+
return false;
|
|
314
|
+
const computed = await generateCodeChallenge(verifier);
|
|
315
|
+
return timingSafeEqualHex(computed, challenge);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// src/plugins/oauth/index.ts
|
|
319
|
+
function oauth(config = {}) {
|
|
320
|
+
const authCodeExpiry = config.authCodeExpirySeconds ?? 600;
|
|
321
|
+
const pendingFlowExpiry = config.pendingFlowExpirySeconds ?? 600;
|
|
322
|
+
const accessTokenExpiry = config.accessTokenExpirySeconds ?? 3600;
|
|
323
|
+
const refreshTokenExpiry = config.refreshTokenExpirySeconds ?? 30 * 24 * 3600;
|
|
324
|
+
const refreshEnabled = refreshTokenExpiry > 0;
|
|
325
|
+
const idTokenExpiry = config.idTokenExpirySeconds ?? 3600;
|
|
326
|
+
const signingKeyGraceSeconds = config.signingKeyGraceSeconds ?? idTokenExpiry;
|
|
327
|
+
const scopePermissionMap = config.scopePermissionMap;
|
|
328
|
+
const issuerUrl = config.issuerUrl ?? "https://localhost";
|
|
329
|
+
if (typeof process !== "undefined" && process.env?.NODE_ENV === "production" && !issuerUrl.startsWith("https://")) {
|
|
330
|
+
throw new Error(
|
|
331
|
+
`[fortress/oauth] issuerUrl must use https:// in production (got: ${issuerUrl})`
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
return {
|
|
335
|
+
name: "oauth",
|
|
336
|
+
models: [
|
|
337
|
+
{
|
|
338
|
+
name: "oauth_client",
|
|
339
|
+
fields: {
|
|
340
|
+
id: { type: "number", required: true },
|
|
341
|
+
clientId: { type: "string", required: true, unique: true },
|
|
342
|
+
// For public clients (`tokenEndpointAuthMethod === 'none'`) the
|
|
343
|
+
// hash is set to the empty string — the column is required by the
|
|
344
|
+
// adapter for backwards compat, but never compared against an
|
|
345
|
+
// inbound secret.
|
|
346
|
+
clientSecretHash: { type: "string", required: true },
|
|
347
|
+
name: { type: "string", required: true },
|
|
348
|
+
redirectUris: { type: "string", required: true },
|
|
349
|
+
grantTypes: { type: "string", required: true },
|
|
350
|
+
// RFC 6749 §3.3 / RFC 9700 §2.2.1 per-client scope allow-list.
|
|
351
|
+
// Optional for backwards compatibility with legacy v0 clients
|
|
352
|
+
// created before this column existed.
|
|
353
|
+
allowedScopes: { type: "string" },
|
|
354
|
+
// RFC 6749 §2.1 client type. Optional — absent on legacy clients,
|
|
355
|
+
// which are treated as confidential (`client_secret_basic`).
|
|
356
|
+
tokenEndpointAuthMethod: { type: "string" },
|
|
357
|
+
createdAt: { type: "date", required: true }
|
|
358
|
+
}
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
name: "oauth_authorization_code",
|
|
362
|
+
fields: {
|
|
363
|
+
id: { type: "number", required: true },
|
|
364
|
+
code: { type: "string", required: true, unique: true },
|
|
365
|
+
clientId: { type: "string", required: true },
|
|
366
|
+
userId: { type: "number", required: true },
|
|
367
|
+
redirectUri: { type: "string", required: true },
|
|
368
|
+
scope: { type: "string" },
|
|
369
|
+
codeChallenge: { type: "string" },
|
|
370
|
+
codeChallengeMethod: { type: "string" },
|
|
371
|
+
// OIDC Core §3.1.2.1 nonce — echoed into the id_token verbatim.
|
|
372
|
+
nonce: { type: "string" },
|
|
373
|
+
// Unix seconds; OIDC `auth_time` claim source.
|
|
374
|
+
authTime: { type: "number" },
|
|
375
|
+
expiresAt: { type: "date", required: true },
|
|
376
|
+
usedAt: { type: "date" },
|
|
377
|
+
createdAt: { type: "date", required: true }
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
name: "oauth_access_token",
|
|
382
|
+
fields: {
|
|
383
|
+
id: { type: "number", required: true },
|
|
384
|
+
token: { type: "string", required: true, unique: true },
|
|
385
|
+
clientId: { type: "string", required: true },
|
|
386
|
+
userId: { type: "number" },
|
|
387
|
+
scope: { type: "string" },
|
|
388
|
+
expiresAt: { type: "date", required: true },
|
|
389
|
+
createdAt: { type: "date", required: true }
|
|
390
|
+
}
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
name: "oauth_refresh_token",
|
|
394
|
+
fields: {
|
|
395
|
+
id: { type: "number", required: true },
|
|
396
|
+
token: { type: "string", required: true, unique: true },
|
|
397
|
+
familyId: { type: "string", required: true },
|
|
398
|
+
clientId: { type: "string", required: true },
|
|
399
|
+
userId: { type: "number", required: true },
|
|
400
|
+
scope: { type: "string" },
|
|
401
|
+
issuedAt: { type: "date", required: true },
|
|
402
|
+
expiresAt: { type: "date", required: true },
|
|
403
|
+
usedAt: { type: "date" },
|
|
404
|
+
parentId: { type: "number" },
|
|
405
|
+
createdAt: { type: "date", required: true }
|
|
406
|
+
}
|
|
407
|
+
},
|
|
408
|
+
{
|
|
409
|
+
name: "oauth_pending_flow",
|
|
410
|
+
fields: {
|
|
411
|
+
id: { type: "number", required: true },
|
|
412
|
+
flowId: { type: "string", required: true, unique: true },
|
|
413
|
+
clientId: { type: "string", required: true },
|
|
414
|
+
redirectUri: { type: "string", required: true },
|
|
415
|
+
scope: { type: "string" },
|
|
416
|
+
state: { type: "string", required: true },
|
|
417
|
+
codeChallenge: { type: "string" },
|
|
418
|
+
codeChallengeMethod: { type: "string" },
|
|
419
|
+
nonce: { type: "string" },
|
|
420
|
+
// H6 fix: subject the flow is bound to. Nullable for back-compat
|
|
421
|
+
// and to model the trust-on-first-use claim.
|
|
422
|
+
userId: { type: "number" },
|
|
423
|
+
// Single-use approval/denial claim. Older rows may have null.
|
|
424
|
+
usedAt: { type: "date" },
|
|
425
|
+
expiresAt: { type: "date", required: true },
|
|
426
|
+
createdAt: { type: "date", required: true }
|
|
427
|
+
}
|
|
428
|
+
},
|
|
429
|
+
{
|
|
430
|
+
// OIDC Core §2 / RFC 7517 signing key persistence. RS256 only for
|
|
431
|
+
// now; the row stores both public and private JWKs as JSON. Active
|
|
432
|
+
// key has rotatedAt == null.
|
|
433
|
+
name: "oauth_signing_key",
|
|
434
|
+
fields: {
|
|
435
|
+
id: { type: "number", required: true },
|
|
436
|
+
kid: { type: "string", required: true, unique: true },
|
|
437
|
+
alg: { type: "string", required: true },
|
|
438
|
+
publicJwk: { type: "string", required: true },
|
|
439
|
+
privateJwk: { type: "string", required: true },
|
|
440
|
+
createdAt: { type: "date", required: true },
|
|
441
|
+
rotatedAt: { type: "date" }
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
],
|
|
445
|
+
methods: (ctx) => ({
|
|
446
|
+
/**
|
|
447
|
+
* Register a new OAuth client.
|
|
448
|
+
* Returns the clientId and raw clientSecret (shown once, stored as hash).
|
|
449
|
+
*/
|
|
450
|
+
async createClient(data) {
|
|
451
|
+
const isPublic = data.tokenEndpointAuthMethod === "none";
|
|
452
|
+
const { raw: clientSecret, hash: clientSecretHash } = isPublic ? { raw: "", hash: "" } : await generateRefreshToken();
|
|
453
|
+
const { raw: clientIdRaw } = await generateRefreshToken();
|
|
454
|
+
const clientId = clientIdRaw.slice(0, 24);
|
|
455
|
+
await ctx.db.create({
|
|
456
|
+
model: "oauth_client",
|
|
457
|
+
data: {
|
|
458
|
+
clientId,
|
|
459
|
+
clientSecretHash,
|
|
460
|
+
name: data.name,
|
|
461
|
+
redirectUris: JSON.stringify(data.redirectUris),
|
|
462
|
+
grantTypes: JSON.stringify(data.grantTypes),
|
|
463
|
+
allowedScopes: data.allowedScopes ? JSON.stringify(data.allowedScopes) : null,
|
|
464
|
+
tokenEndpointAuthMethod: data.tokenEndpointAuthMethod ?? "client_secret_basic"
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
return {
|
|
468
|
+
clientId,
|
|
469
|
+
// Public clients have no secret — returning `null` makes the
|
|
470
|
+
// contract explicit at compile time.
|
|
471
|
+
clientSecret: isPublic ? null : clientSecret
|
|
472
|
+
};
|
|
473
|
+
},
|
|
474
|
+
/**
|
|
475
|
+
* Generate an authorization code for a user+client.
|
|
476
|
+
* Used after the user authenticates and authorizes the client.
|
|
477
|
+
*/
|
|
478
|
+
async createAuthorizationCode(params) {
|
|
479
|
+
const client = await ctx.db.findOne({
|
|
480
|
+
model: "oauth_client",
|
|
481
|
+
where: [{ field: "clientId", operator: "=", value: params.clientId }]
|
|
482
|
+
});
|
|
483
|
+
if (!client)
|
|
484
|
+
throw Errors.oauth("invalid_request", "Invalid client_id");
|
|
485
|
+
const uris = JSON.parse(client.redirectUris);
|
|
486
|
+
if (!uris.some((r) => matchRedirectUri(r, params.redirectUri)))
|
|
487
|
+
throw Errors.oauth("invalid_request", "Invalid redirect_uri");
|
|
488
|
+
const isPublic = client.tokenEndpointAuthMethod === "none";
|
|
489
|
+
if (isPublic && (!params.codeChallenge || params.codeChallengeMethod !== "S256"))
|
|
490
|
+
throw Errors.oauth("invalid_request", "PKCE (S256) is required for public clients");
|
|
491
|
+
const { raw: code, hash: codeHash } = await generateRefreshToken();
|
|
492
|
+
const expiresAt = new Date(Date.now() + authCodeExpiry * 1e3);
|
|
493
|
+
await ctx.db.create({
|
|
494
|
+
model: "oauth_authorization_code",
|
|
495
|
+
data: {
|
|
496
|
+
code: codeHash,
|
|
497
|
+
clientId: params.clientId,
|
|
498
|
+
userId: params.userId,
|
|
499
|
+
redirectUri: params.redirectUri,
|
|
500
|
+
scope: params.scope ?? null,
|
|
501
|
+
codeChallenge: params.codeChallenge ?? null,
|
|
502
|
+
codeChallengeMethod: params.codeChallengeMethod ?? null,
|
|
503
|
+
nonce: params.nonce ?? null,
|
|
504
|
+
authTime: params.authTime ?? null,
|
|
505
|
+
expiresAt,
|
|
506
|
+
usedAt: null
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
return { code };
|
|
510
|
+
},
|
|
511
|
+
/**
|
|
512
|
+
* Exchange an authorization code for an access token.
|
|
513
|
+
*/
|
|
514
|
+
async exchangeCode(params) {
|
|
515
|
+
const client = await ctx.db.findOne({
|
|
516
|
+
model: "oauth_client",
|
|
517
|
+
where: [{ field: "clientId", operator: "=", value: params.clientId }]
|
|
518
|
+
});
|
|
519
|
+
if (!client)
|
|
520
|
+
throw Errors.oauth("invalid_client", "Invalid client credentials");
|
|
521
|
+
const isPublic = client.tokenEndpointAuthMethod === "none";
|
|
522
|
+
if (!isPublic) {
|
|
523
|
+
if (!params.clientSecret)
|
|
524
|
+
throw Errors.oauth("invalid_client", "Client secret required");
|
|
525
|
+
const secretValid = timingSafeEqualHex(
|
|
526
|
+
await hashToken(params.clientSecret),
|
|
527
|
+
client.clientSecretHash
|
|
528
|
+
);
|
|
529
|
+
if (!secretValid)
|
|
530
|
+
throw Errors.oauth("invalid_client", "Invalid client credentials");
|
|
531
|
+
} else if (params.clientSecret) {
|
|
532
|
+
throw Errors.oauth("invalid_client", "Public clients must not present a client_secret");
|
|
533
|
+
}
|
|
534
|
+
const grantTypes = JSON.parse(client.grantTypes);
|
|
535
|
+
if (!grantTypes.includes("authorization_code"))
|
|
536
|
+
throw Errors.oauth("unauthorized_client", "Client does not support authorization_code grant");
|
|
537
|
+
const codeHash = await hashToken(params.code);
|
|
538
|
+
return ctx.db.transaction(async (tx) => {
|
|
539
|
+
const authCode = await tx.findOne({
|
|
540
|
+
model: "oauth_authorization_code",
|
|
541
|
+
where: [{ field: "code", operator: "=", value: codeHash }]
|
|
542
|
+
});
|
|
543
|
+
if (!authCode)
|
|
544
|
+
throw Errors.oauth("invalid_grant", "Invalid authorization code");
|
|
545
|
+
if (authCode.expiresAt < /* @__PURE__ */ new Date())
|
|
546
|
+
throw Errors.oauth("invalid_grant", "Authorization code expired");
|
|
547
|
+
if (authCode.clientId !== params.clientId)
|
|
548
|
+
throw Errors.oauth("invalid_grant", "Client mismatch");
|
|
549
|
+
if (authCode.redirectUri !== params.redirectUri)
|
|
550
|
+
throw Errors.oauth("invalid_grant", "Redirect URI mismatch");
|
|
551
|
+
if (isPublic && !authCode.codeChallenge)
|
|
552
|
+
throw Errors.oauth("invalid_grant", "PKCE required for public clients");
|
|
553
|
+
if (authCode.codeChallenge && authCode.codeChallengeMethod) {
|
|
554
|
+
if (!params.codeVerifier)
|
|
555
|
+
throw Errors.oauth("invalid_grant", "code_verifier required");
|
|
556
|
+
const valid = await verifyCodeChallenge(
|
|
557
|
+
params.codeVerifier,
|
|
558
|
+
authCode.codeChallenge,
|
|
559
|
+
authCode.codeChallengeMethod
|
|
560
|
+
);
|
|
561
|
+
if (!valid)
|
|
562
|
+
throw Errors.oauth("invalid_grant", "Invalid code_verifier");
|
|
563
|
+
}
|
|
564
|
+
const claimed = await tx.update({
|
|
565
|
+
model: "oauth_authorization_code",
|
|
566
|
+
where: [
|
|
567
|
+
{ field: "id", operator: "=", value: authCode.id },
|
|
568
|
+
{ field: "usedAt", operator: "isNull", value: null }
|
|
569
|
+
],
|
|
570
|
+
data: { usedAt: /* @__PURE__ */ new Date() }
|
|
571
|
+
});
|
|
572
|
+
if (!claimed)
|
|
573
|
+
throw Errors.oauth("invalid_grant", "Authorization code already used");
|
|
574
|
+
const { raw: tokenRaw, hash: tokenHash } = await generateRefreshToken();
|
|
575
|
+
const expiresAt = new Date(Date.now() + accessTokenExpiry * 1e3);
|
|
576
|
+
await tx.create({
|
|
577
|
+
model: "oauth_access_token",
|
|
578
|
+
data: {
|
|
579
|
+
token: tokenHash,
|
|
580
|
+
clientId: params.clientId,
|
|
581
|
+
userId: authCode.userId,
|
|
582
|
+
scope: authCode.scope,
|
|
583
|
+
expiresAt
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
let idToken;
|
|
587
|
+
const scopes = (authCode.scope ?? "").split(" ").filter(Boolean);
|
|
588
|
+
if (scopes.includes("openid")) {
|
|
589
|
+
const user = await tx.findOne({
|
|
590
|
+
model: "user",
|
|
591
|
+
where: [{ field: "id", operator: "=", value: authCode.userId }]
|
|
592
|
+
});
|
|
593
|
+
if (user) {
|
|
594
|
+
const signingKey = await getActiveSigningKey(tx);
|
|
595
|
+
idToken = await issueIdToken({
|
|
596
|
+
user,
|
|
597
|
+
clientId: params.clientId,
|
|
598
|
+
issuerUrl,
|
|
599
|
+
ttlSeconds: idTokenExpiry,
|
|
600
|
+
nonce: authCode.nonce ?? void 0,
|
|
601
|
+
authTimeSeconds: authCode.authTime ?? Math.floor(Date.now() / 1e3),
|
|
602
|
+
scope: authCode.scope,
|
|
603
|
+
signingKey
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
let refreshTokenRaw;
|
|
608
|
+
if (refreshEnabled) {
|
|
609
|
+
const { raw, hash } = await generateRefreshToken();
|
|
610
|
+
refreshTokenRaw = raw;
|
|
611
|
+
await tx.create({
|
|
612
|
+
model: "oauth_refresh_token",
|
|
613
|
+
data: {
|
|
614
|
+
token: hash,
|
|
615
|
+
familyId: generateTokenFamily(),
|
|
616
|
+
clientId: params.clientId,
|
|
617
|
+
userId: authCode.userId,
|
|
618
|
+
scope: authCode.scope ?? null,
|
|
619
|
+
issuedAt: /* @__PURE__ */ new Date(),
|
|
620
|
+
expiresAt: new Date(Date.now() + refreshTokenExpiry * 1e3),
|
|
621
|
+
usedAt: null,
|
|
622
|
+
parentId: null
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
return {
|
|
627
|
+
accessToken: tokenRaw,
|
|
628
|
+
tokenType: "Bearer",
|
|
629
|
+
expiresIn: accessTokenExpiry,
|
|
630
|
+
scope: authCode.scope ?? void 0,
|
|
631
|
+
...refreshTokenRaw ? { refreshToken: refreshTokenRaw } : {},
|
|
632
|
+
...idToken ? { idToken } : {}
|
|
633
|
+
};
|
|
634
|
+
});
|
|
635
|
+
},
|
|
636
|
+
/**
|
|
637
|
+
* RFC 6749 §6 refresh-token grant with RFC 9700 §2.2.2 rotation.
|
|
638
|
+
*
|
|
639
|
+
* Flow:
|
|
640
|
+
* 1. Look up the inbound refresh token by hash.
|
|
641
|
+
* 2. If it's already been used (`usedAt != null`), this is a replay
|
|
642
|
+
* attack — delete every refresh token in the same family and
|
|
643
|
+
* throw `invalid_grant`. The legitimate client's most recent
|
|
644
|
+
* refresh dies with the family, so the user is forced to
|
|
645
|
+
* re-authenticate, but the attacker's stolen token is now
|
|
646
|
+
* useless.
|
|
647
|
+
* 3. Otherwise mark the old token used, mint a new access token +
|
|
648
|
+
* a new refresh token in the same family, link the new refresh
|
|
649
|
+
* via `parentId`, and return the pair.
|
|
650
|
+
*/
|
|
651
|
+
async refreshTokenGrant(params) {
|
|
652
|
+
const client = await ctx.db.findOne({
|
|
653
|
+
model: "oauth_client",
|
|
654
|
+
where: [{ field: "clientId", operator: "=", value: params.clientId }]
|
|
655
|
+
});
|
|
656
|
+
if (!client)
|
|
657
|
+
throw Errors.oauth("invalid_client", "Invalid client credentials");
|
|
658
|
+
const isPublic = client.tokenEndpointAuthMethod === "none";
|
|
659
|
+
if (!isPublic) {
|
|
660
|
+
if (!params.clientSecret)
|
|
661
|
+
throw Errors.oauth("invalid_client", "Client secret required");
|
|
662
|
+
const secretValid = timingSafeEqualHex(
|
|
663
|
+
await hashToken(params.clientSecret),
|
|
664
|
+
client.clientSecretHash
|
|
665
|
+
);
|
|
666
|
+
if (!secretValid)
|
|
667
|
+
throw Errors.oauth("invalid_client", "Invalid client credentials");
|
|
668
|
+
} else if (params.clientSecret) {
|
|
669
|
+
throw Errors.oauth("invalid_client", "Public clients must not present a client_secret");
|
|
670
|
+
}
|
|
671
|
+
const refreshGrantTypes = JSON.parse(client.grantTypes);
|
|
672
|
+
if (!refreshGrantTypes.includes("refresh_token"))
|
|
673
|
+
throw Errors.oauth("unauthorized_client", "Client does not support refresh_token grant");
|
|
674
|
+
const tokenHash = await hashToken(params.refreshToken);
|
|
675
|
+
const grant = await ctx.db.transaction(async (tx) => {
|
|
676
|
+
const record = await tx.findOne({
|
|
677
|
+
model: "oauth_refresh_token",
|
|
678
|
+
where: [{ field: "token", operator: "=", value: tokenHash }]
|
|
679
|
+
});
|
|
680
|
+
if (!record)
|
|
681
|
+
throw Errors.oauth("invalid_grant", "Invalid refresh token");
|
|
682
|
+
if (record.clientId !== params.clientId)
|
|
683
|
+
throw Errors.oauth("invalid_grant", "Refresh token client mismatch");
|
|
684
|
+
if (record.expiresAt < /* @__PURE__ */ new Date())
|
|
685
|
+
throw Errors.oauth("invalid_grant", "Refresh token expired");
|
|
686
|
+
if (record.usedAt) {
|
|
687
|
+
await tx.delete({
|
|
688
|
+
model: "oauth_refresh_token",
|
|
689
|
+
where: [{ field: "familyId", operator: "=", value: record.familyId }]
|
|
690
|
+
});
|
|
691
|
+
return { replayDetected: true };
|
|
692
|
+
}
|
|
693
|
+
let scope = record.scope;
|
|
694
|
+
if (params.scope) {
|
|
695
|
+
const requested = params.scope.split(" ").filter(Boolean);
|
|
696
|
+
const original = (record.scope ?? "").split(" ").filter(Boolean);
|
|
697
|
+
const originalSet = new Set(original);
|
|
698
|
+
const widened = requested.find((s) => !originalSet.has(s));
|
|
699
|
+
if (widened) {
|
|
700
|
+
throw Errors.oauth("invalid_scope", `Refreshed scope cannot widen: ${widened}`);
|
|
701
|
+
}
|
|
702
|
+
scope = requested.join(" ");
|
|
703
|
+
}
|
|
704
|
+
const claimed = await tx.update({
|
|
705
|
+
model: "oauth_refresh_token",
|
|
706
|
+
where: [
|
|
707
|
+
{ field: "id", operator: "=", value: record.id },
|
|
708
|
+
{ field: "usedAt", operator: "isNull", value: null }
|
|
709
|
+
],
|
|
710
|
+
data: { usedAt: /* @__PURE__ */ new Date() }
|
|
711
|
+
});
|
|
712
|
+
if (!claimed) {
|
|
713
|
+
await tx.delete({
|
|
714
|
+
model: "oauth_refresh_token",
|
|
715
|
+
where: [{ field: "familyId", operator: "=", value: record.familyId }]
|
|
716
|
+
});
|
|
717
|
+
return { replayDetected: true };
|
|
718
|
+
}
|
|
719
|
+
const { raw: accessRaw, hash: accessHash } = await generateRefreshToken();
|
|
720
|
+
await tx.create({
|
|
721
|
+
model: "oauth_access_token",
|
|
722
|
+
data: {
|
|
723
|
+
token: accessHash,
|
|
724
|
+
clientId: params.clientId,
|
|
725
|
+
userId: record.userId,
|
|
726
|
+
scope,
|
|
727
|
+
expiresAt: new Date(Date.now() + accessTokenExpiry * 1e3)
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
let idToken;
|
|
731
|
+
if ((scope ?? "").split(" ").filter(Boolean).includes("openid")) {
|
|
732
|
+
const user = await tx.findOne({
|
|
733
|
+
model: "user",
|
|
734
|
+
where: [{ field: "id", operator: "=", value: record.userId }]
|
|
735
|
+
});
|
|
736
|
+
if (user) {
|
|
737
|
+
const signingKey = await getActiveSigningKey(tx);
|
|
738
|
+
idToken = await issueIdToken({
|
|
739
|
+
user,
|
|
740
|
+
clientId: params.clientId,
|
|
741
|
+
issuerUrl,
|
|
742
|
+
ttlSeconds: idTokenExpiry,
|
|
743
|
+
// The original authentication time is not stored on the
|
|
744
|
+
// refresh family. Omit auth_time rather than fabricating a
|
|
745
|
+
// fresh authentication event at token rotation.
|
|
746
|
+
scope,
|
|
747
|
+
signingKey
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
const { raw: refreshRaw, hash: refreshHash } = await generateRefreshToken();
|
|
752
|
+
await tx.create({
|
|
753
|
+
model: "oauth_refresh_token",
|
|
754
|
+
data: {
|
|
755
|
+
token: refreshHash,
|
|
756
|
+
familyId: record.familyId,
|
|
757
|
+
clientId: params.clientId,
|
|
758
|
+
userId: record.userId,
|
|
759
|
+
scope,
|
|
760
|
+
issuedAt: /* @__PURE__ */ new Date(),
|
|
761
|
+
expiresAt: new Date(Date.now() + refreshTokenExpiry * 1e3),
|
|
762
|
+
usedAt: null,
|
|
763
|
+
parentId: record.id
|
|
764
|
+
}
|
|
765
|
+
});
|
|
766
|
+
return {
|
|
767
|
+
accessToken: accessRaw,
|
|
768
|
+
tokenType: "Bearer",
|
|
769
|
+
expiresIn: accessTokenExpiry,
|
|
770
|
+
refreshToken: refreshRaw,
|
|
771
|
+
...scope ? { scope } : {},
|
|
772
|
+
...idToken ? { idToken } : {}
|
|
773
|
+
};
|
|
774
|
+
});
|
|
775
|
+
if ("replayDetected" in grant)
|
|
776
|
+
throw Errors.oauth("invalid_grant", "Refresh token reuse detected; family revoked");
|
|
777
|
+
return grant;
|
|
778
|
+
},
|
|
779
|
+
/**
|
|
780
|
+
* Client credentials grant — issue token for a service client (no user).
|
|
781
|
+
*/
|
|
782
|
+
async clientCredentialsGrant(params) {
|
|
783
|
+
const client = await ctx.db.findOne({
|
|
784
|
+
model: "oauth_client",
|
|
785
|
+
where: [{ field: "clientId", operator: "=", value: params.clientId }]
|
|
786
|
+
});
|
|
787
|
+
if (!client)
|
|
788
|
+
throw Errors.oauth("invalid_client", "Invalid client credentials");
|
|
789
|
+
if (client.tokenEndpointAuthMethod === "none")
|
|
790
|
+
throw Errors.oauth("unauthorized_client", "Public clients cannot use client_credentials");
|
|
791
|
+
const grantTypes = JSON.parse(client.grantTypes);
|
|
792
|
+
if (!grantTypes.includes("client_credentials"))
|
|
793
|
+
throw Errors.oauth("unauthorized_client", "Client does not support client_credentials grant");
|
|
794
|
+
const secretValid = timingSafeEqualHex(
|
|
795
|
+
await hashToken(params.clientSecret),
|
|
796
|
+
client.clientSecretHash
|
|
797
|
+
);
|
|
798
|
+
if (!secretValid)
|
|
799
|
+
throw Errors.oauth("invalid_client", "Invalid client credentials");
|
|
800
|
+
const effectiveScope = intersectScope(params.scope ?? null, client.allowedScopes);
|
|
801
|
+
if (params.scope && effectiveScope === "") {
|
|
802
|
+
throw Errors.oauth("invalid_scope", "No requested scope is allowed for this client");
|
|
803
|
+
}
|
|
804
|
+
const { raw: tokenRaw, hash: tokenHash } = await generateRefreshToken();
|
|
805
|
+
const expiresAt = new Date(Date.now() + accessTokenExpiry * 1e3);
|
|
806
|
+
await ctx.db.create({
|
|
807
|
+
model: "oauth_access_token",
|
|
808
|
+
data: {
|
|
809
|
+
token: tokenHash,
|
|
810
|
+
clientId: params.clientId,
|
|
811
|
+
userId: null,
|
|
812
|
+
scope: effectiveScope || null,
|
|
813
|
+
expiresAt
|
|
814
|
+
}
|
|
815
|
+
});
|
|
816
|
+
return {
|
|
817
|
+
accessToken: tokenRaw,
|
|
818
|
+
tokenType: "Bearer",
|
|
819
|
+
expiresIn: accessTokenExpiry
|
|
820
|
+
};
|
|
821
|
+
},
|
|
822
|
+
/**
|
|
823
|
+
* Revoke an access token OR refresh token (RFC 7009).
|
|
824
|
+
*
|
|
825
|
+
* Per §2.1: revoking a refresh token also revokes the related access
|
|
826
|
+
* tokens. When a refresh token is revoked here, the entire token
|
|
827
|
+
* family is dropped — every rotated descendant becomes unusable.
|
|
828
|
+
*/
|
|
829
|
+
async revokeToken(token) {
|
|
830
|
+
const tokenHash = await hashToken(token);
|
|
831
|
+
await ctx.db.delete({
|
|
832
|
+
model: "oauth_access_token",
|
|
833
|
+
where: [{ field: "token", operator: "=", value: tokenHash }]
|
|
834
|
+
});
|
|
835
|
+
if (refreshEnabled) {
|
|
836
|
+
const refreshRecord = await ctx.db.findOne({
|
|
837
|
+
model: "oauth_refresh_token",
|
|
838
|
+
where: [{ field: "token", operator: "=", value: tokenHash }]
|
|
839
|
+
});
|
|
840
|
+
if (refreshRecord) {
|
|
841
|
+
await ctx.db.delete({
|
|
842
|
+
model: "oauth_refresh_token",
|
|
843
|
+
where: [{ field: "familyId", operator: "=", value: refreshRecord.familyId }]
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
},
|
|
848
|
+
/**
|
|
849
|
+
* Validate an access token and return associated user/client info.
|
|
850
|
+
*/
|
|
851
|
+
async introspectToken(token) {
|
|
852
|
+
const tokenHash = await hashToken(token);
|
|
853
|
+
const record = await ctx.db.findOne({
|
|
854
|
+
model: "oauth_access_token",
|
|
855
|
+
where: [{ field: "token", operator: "=", value: tokenHash }]
|
|
856
|
+
});
|
|
857
|
+
if (!record || record.expiresAt < /* @__PURE__ */ new Date()) {
|
|
858
|
+
return { active: false };
|
|
859
|
+
}
|
|
860
|
+
return {
|
|
861
|
+
active: true,
|
|
862
|
+
clientId: record.clientId,
|
|
863
|
+
userId: record.userId ?? void 0,
|
|
864
|
+
scope: record.scope ?? void 0
|
|
865
|
+
};
|
|
866
|
+
},
|
|
867
|
+
/**
|
|
868
|
+
* Create a pending OAuth flow for unauthenticated users (identity broker pattern).
|
|
869
|
+
*/
|
|
870
|
+
async createPendingFlow(params) {
|
|
871
|
+
const expiresAt = new Date(Date.now() + pendingFlowExpiry * 1e3);
|
|
872
|
+
const { raw: flowId } = await generateRefreshToken();
|
|
873
|
+
const flow = await ctx.db.create({
|
|
874
|
+
model: "oauth_pending_flow",
|
|
875
|
+
data: {
|
|
876
|
+
flowId,
|
|
877
|
+
clientId: params.clientId,
|
|
878
|
+
redirectUri: params.redirectUri,
|
|
879
|
+
scope: params.scope ?? null,
|
|
880
|
+
state: params.state,
|
|
881
|
+
codeChallenge: params.codeChallenge ?? null,
|
|
882
|
+
codeChallengeMethod: params.codeChallengeMethod ?? null,
|
|
883
|
+
userId: params.userId ?? null,
|
|
884
|
+
usedAt: null,
|
|
885
|
+
expiresAt
|
|
886
|
+
}
|
|
887
|
+
});
|
|
888
|
+
return { flowId: flow.flowId };
|
|
889
|
+
},
|
|
890
|
+
/**
|
|
891
|
+
* Read a pending OAuth flow without consuming it.
|
|
892
|
+
*
|
|
893
|
+
* Used by the consent UI to fetch flow metadata (client, scopes, redirect URI)
|
|
894
|
+
* before the user approves or denies. Safe to call multiple times.
|
|
895
|
+
*
|
|
896
|
+
* @throws {FortressError} `not_found` if the flow doesn't exist.
|
|
897
|
+
* @throws {FortressError} `bad_request` if the flow has expired.
|
|
898
|
+
*/
|
|
899
|
+
async getPendingFlow(flowId, context) {
|
|
900
|
+
const flow = await ctx.db.findOne({
|
|
901
|
+
model: "oauth_pending_flow",
|
|
902
|
+
where: pendingFlowLookupWhere(flowId)
|
|
903
|
+
});
|
|
904
|
+
if (!flow)
|
|
905
|
+
throw Errors.notFound("Pending flow not found");
|
|
906
|
+
if (flow.usedAt)
|
|
907
|
+
throw Errors.notFound("Pending flow not found");
|
|
908
|
+
if (flow.expiresAt < /* @__PURE__ */ new Date())
|
|
909
|
+
throw Errors.badRequest("Pending flow expired");
|
|
910
|
+
if (context?.userId !== void 0 && flow.userId !== null && flow.userId !== context.userId) {
|
|
911
|
+
throw Errors.notFound("Pending flow not found");
|
|
912
|
+
}
|
|
913
|
+
return flow;
|
|
914
|
+
},
|
|
915
|
+
/**
|
|
916
|
+
* Resume a pending OAuth flow after user authenticates (single-use consume).
|
|
917
|
+
*
|
|
918
|
+
* Returns the stored flow params and deletes the row so the same flow can't
|
|
919
|
+
* be replayed. Use {@link OAuthMethods.getPendingFlow} for non-destructive reads.
|
|
920
|
+
*/
|
|
921
|
+
async resumePendingFlow(flowId, context) {
|
|
922
|
+
const flow = await ctx.db.findOne({
|
|
923
|
+
model: "oauth_pending_flow",
|
|
924
|
+
where: pendingFlowLookupWhere(flowId)
|
|
925
|
+
});
|
|
926
|
+
if (!flow)
|
|
927
|
+
throw Errors.notFound("Pending flow not found");
|
|
928
|
+
if (flow.usedAt)
|
|
929
|
+
throw Errors.notFound("Pending flow not found");
|
|
930
|
+
if (flow.expiresAt < /* @__PURE__ */ new Date())
|
|
931
|
+
throw Errors.badRequest("Pending flow expired");
|
|
932
|
+
if (context?.userId !== void 0 && flow.userId !== null && flow.userId !== context.userId) {
|
|
933
|
+
throw Errors.notFound("Pending flow not found");
|
|
934
|
+
}
|
|
935
|
+
await ctx.db.delete({
|
|
936
|
+
model: "oauth_pending_flow",
|
|
937
|
+
where: [{ field: "id", operator: "=", value: flow.id }]
|
|
938
|
+
});
|
|
939
|
+
return flow;
|
|
940
|
+
},
|
|
941
|
+
/**
|
|
942
|
+
* Get userinfo for an access token (OpenID Connect userinfo endpoint).
|
|
943
|
+
*/
|
|
944
|
+
/**
|
|
945
|
+
* Handle the authorize endpoint. Validates the OAuth query, creates a
|
|
946
|
+
* pending flow row, and decides whether the browser should be sent to
|
|
947
|
+
* the host app's login page or consent page next.
|
|
948
|
+
*
|
|
949
|
+
* The dispatcher turns `redirectUrl` into a 302 response. Errors are
|
|
950
|
+
* thrown as {@link FortressError}s so they surface as a 4xx JSON body
|
|
951
|
+
* (the OAuth client is expected to handle that).
|
|
952
|
+
*/
|
|
953
|
+
async handleAuthorizeRequest(query, context) {
|
|
954
|
+
const nonce = query.nonce;
|
|
955
|
+
if (!config.loginUrl || !config.consentUrl) {
|
|
956
|
+
throw Errors.badRequest(
|
|
957
|
+
"OAuth plugin is missing loginUrl/consentUrl configuration"
|
|
958
|
+
);
|
|
959
|
+
}
|
|
960
|
+
const clientId = query.client_id;
|
|
961
|
+
const redirectUri = query.redirect_uri;
|
|
962
|
+
const responseType = query.response_type;
|
|
963
|
+
const state = query.state;
|
|
964
|
+
const scope = query.scope;
|
|
965
|
+
const codeChallenge = query.code_challenge;
|
|
966
|
+
const codeChallengeMethod = query.code_challenge_method;
|
|
967
|
+
if (!clientId)
|
|
968
|
+
throw Errors.oauth("invalid_request", "client_id is required");
|
|
969
|
+
if (!redirectUri)
|
|
970
|
+
throw Errors.oauth("invalid_request", "redirect_uri is required");
|
|
971
|
+
if (responseType !== "code")
|
|
972
|
+
throw Errors.oauth("unsupported_response_type", 'response_type must be "code"');
|
|
973
|
+
if (!state)
|
|
974
|
+
throw Errors.oauth("invalid_request", "state is required");
|
|
975
|
+
const client = await ctx.db.findOne({
|
|
976
|
+
model: "oauth_client",
|
|
977
|
+
where: [{ field: "clientId", operator: "=", value: clientId }]
|
|
978
|
+
});
|
|
979
|
+
if (!client)
|
|
980
|
+
throw Errors.oauth("invalid_request", "Unknown client_id");
|
|
981
|
+
const allowedRedirects = JSON.parse(client.redirectUris);
|
|
982
|
+
if (!allowedRedirects.some((r) => matchRedirectUri(r, redirectUri)))
|
|
983
|
+
throw Errors.oauth("invalid_request", "Invalid redirect_uri");
|
|
984
|
+
if (!codeChallenge && !config.allowNonPkceConfidentialClients) {
|
|
985
|
+
throw Errors.oauth("invalid_request", "code_challenge is required (PKCE)");
|
|
986
|
+
}
|
|
987
|
+
if (codeChallenge && codeChallengeMethod && codeChallengeMethod !== "S256") {
|
|
988
|
+
throw Errors.oauth("invalid_request", "Only S256 code_challenge_method is supported");
|
|
989
|
+
}
|
|
990
|
+
if (codeChallenge && !codeChallengeMethod) {
|
|
991
|
+
throw Errors.oauth(
|
|
992
|
+
"invalid_request",
|
|
993
|
+
"code_challenge_method is required when code_challenge is set; only S256 is supported"
|
|
994
|
+
);
|
|
995
|
+
}
|
|
996
|
+
const effectiveScope = intersectScope(scope ?? null, client.allowedScopes);
|
|
997
|
+
if (scope && effectiveScope === "") {
|
|
998
|
+
throw Errors.oauth("invalid_scope", "No requested scope is allowed for this client");
|
|
999
|
+
}
|
|
1000
|
+
const expiresAt = new Date(Date.now() + pendingFlowExpiry * 1e3);
|
|
1001
|
+
const { raw: flowId } = await generateRefreshToken();
|
|
1002
|
+
const flow = await ctx.db.create({
|
|
1003
|
+
model: "oauth_pending_flow",
|
|
1004
|
+
data: {
|
|
1005
|
+
flowId,
|
|
1006
|
+
clientId,
|
|
1007
|
+
redirectUri,
|
|
1008
|
+
scope: effectiveScope || null,
|
|
1009
|
+
state,
|
|
1010
|
+
codeChallenge: codeChallenge ?? null,
|
|
1011
|
+
codeChallengeMethod: codeChallengeMethod ?? null,
|
|
1012
|
+
nonce: nonce ?? null,
|
|
1013
|
+
// H6 fix — bind the flow to the authenticated user up front when
|
|
1014
|
+
// the authorize endpoint already knows them. The login redirect
|
|
1015
|
+
// path leaves userId null and lets the first authenticated
|
|
1016
|
+
// GetFlow / Approve claim it.
|
|
1017
|
+
userId: context.userId ?? null,
|
|
1018
|
+
usedAt: null,
|
|
1019
|
+
expiresAt
|
|
1020
|
+
}
|
|
1021
|
+
});
|
|
1022
|
+
const target = context.userId ? config.consentUrl : config.loginUrl;
|
|
1023
|
+
const url = new URL(target);
|
|
1024
|
+
url.searchParams.set("flow", flow.flowId);
|
|
1025
|
+
return { redirectUrl: url.toString(), flowId: flow.flowId };
|
|
1026
|
+
},
|
|
1027
|
+
/**
|
|
1028
|
+
* Read a pending flow as the consent UI's data source. Strips fields
|
|
1029
|
+
* that should never leave the server (PKCE challenge / method).
|
|
1030
|
+
*/
|
|
1031
|
+
async handleGetFlow(flowId, context) {
|
|
1032
|
+
const flow = await this.getPendingFlow(flowId, context);
|
|
1033
|
+
if (flow.userId === null) {
|
|
1034
|
+
await ctx.db.update({
|
|
1035
|
+
model: "oauth_pending_flow",
|
|
1036
|
+
where: [
|
|
1037
|
+
{ field: "id", operator: "=", value: flow.id },
|
|
1038
|
+
{ field: "userId", operator: "isNull", value: null }
|
|
1039
|
+
],
|
|
1040
|
+
data: { userId: context.userId }
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
const client = await ctx.db.findOne({
|
|
1044
|
+
model: "oauth_client",
|
|
1045
|
+
where: [{ field: "clientId", operator: "=", value: flow.clientId }]
|
|
1046
|
+
});
|
|
1047
|
+
if (!client)
|
|
1048
|
+
throw Errors.notFound("OAuth client not found");
|
|
1049
|
+
return {
|
|
1050
|
+
flowId: flow.flowId,
|
|
1051
|
+
client: { clientId: client.clientId, name: client.name },
|
|
1052
|
+
redirectUri: flow.redirectUri,
|
|
1053
|
+
scopes: flow.scope ? flow.scope.split(" ").filter(Boolean) : [],
|
|
1054
|
+
state: flow.state
|
|
1055
|
+
};
|
|
1056
|
+
},
|
|
1057
|
+
/**
|
|
1058
|
+
* Approve a pending flow on behalf of the authenticated user. Creates
|
|
1059
|
+
* the authorization code, consumes the pending flow, and returns the
|
|
1060
|
+
* client's redirect URI with `?code=...&state=...` appended.
|
|
1061
|
+
*/
|
|
1062
|
+
async handleApproveFlow(flowId, context) {
|
|
1063
|
+
const { flow, code } = await ctx.db.transaction(async (tx) => {
|
|
1064
|
+
const flow2 = await tx.findOne({
|
|
1065
|
+
model: "oauth_pending_flow",
|
|
1066
|
+
where: pendingFlowLookupWhere(flowId)
|
|
1067
|
+
});
|
|
1068
|
+
if (!flow2)
|
|
1069
|
+
throw Errors.notFound("Pending flow not found");
|
|
1070
|
+
if (flow2.usedAt)
|
|
1071
|
+
throw Errors.notFound("Pending flow not found");
|
|
1072
|
+
if (flow2.expiresAt < /* @__PURE__ */ new Date())
|
|
1073
|
+
throw Errors.badRequest("Pending flow expired");
|
|
1074
|
+
if (flow2.userId !== null && flow2.userId !== context.userId)
|
|
1075
|
+
throw Errors.notFound("Pending flow not found");
|
|
1076
|
+
const claimed = await tx.update({
|
|
1077
|
+
model: "oauth_pending_flow",
|
|
1078
|
+
where: [
|
|
1079
|
+
{ field: "id", operator: "=", value: flow2.id },
|
|
1080
|
+
{ field: "usedAt", operator: "isNull", value: null }
|
|
1081
|
+
],
|
|
1082
|
+
data: {
|
|
1083
|
+
usedAt: /* @__PURE__ */ new Date(),
|
|
1084
|
+
userId: flow2.userId ?? context.userId
|
|
1085
|
+
}
|
|
1086
|
+
});
|
|
1087
|
+
if (!claimed)
|
|
1088
|
+
throw Errors.notFound("Pending flow not found");
|
|
1089
|
+
const client = await tx.findOne({
|
|
1090
|
+
model: "oauth_client",
|
|
1091
|
+
where: [{ field: "clientId", operator: "=", value: flow2.clientId }]
|
|
1092
|
+
});
|
|
1093
|
+
if (!client)
|
|
1094
|
+
throw Errors.oauth("invalid_request", "Invalid client_id");
|
|
1095
|
+
const uris = JSON.parse(client.redirectUris);
|
|
1096
|
+
if (!uris.some((r) => matchRedirectUri(r, flow2.redirectUri)))
|
|
1097
|
+
throw Errors.oauth("invalid_request", "Invalid redirect_uri");
|
|
1098
|
+
const isPublic = client.tokenEndpointAuthMethod === "none";
|
|
1099
|
+
if (isPublic && (!flow2.codeChallenge || flow2.codeChallengeMethod !== "S256"))
|
|
1100
|
+
throw Errors.oauth("invalid_request", "PKCE (S256) is required for public clients");
|
|
1101
|
+
const { raw: code2, hash: codeHash } = await generateRefreshToken();
|
|
1102
|
+
await tx.create({
|
|
1103
|
+
model: "oauth_authorization_code",
|
|
1104
|
+
data: {
|
|
1105
|
+
code: codeHash,
|
|
1106
|
+
clientId: flow2.clientId,
|
|
1107
|
+
userId: context.userId,
|
|
1108
|
+
redirectUri: flow2.redirectUri,
|
|
1109
|
+
scope: flow2.scope ?? null,
|
|
1110
|
+
codeChallenge: flow2.codeChallenge ?? null,
|
|
1111
|
+
codeChallengeMethod: flow2.codeChallengeMethod ?? null,
|
|
1112
|
+
nonce: flow2.nonce ?? null,
|
|
1113
|
+
authTime: context.authTimeSeconds ?? Math.floor(Date.now() / 1e3),
|
|
1114
|
+
expiresAt: new Date(Date.now() + authCodeExpiry * 1e3),
|
|
1115
|
+
usedAt: null
|
|
1116
|
+
}
|
|
1117
|
+
});
|
|
1118
|
+
await tx.delete({
|
|
1119
|
+
model: "oauth_pending_flow",
|
|
1120
|
+
where: [{ field: "id", operator: "=", value: flow2.id }]
|
|
1121
|
+
});
|
|
1122
|
+
return { flow: flow2, code: code2 };
|
|
1123
|
+
});
|
|
1124
|
+
const url = new URL(flow.redirectUri);
|
|
1125
|
+
url.searchParams.set("code", code);
|
|
1126
|
+
url.searchParams.set("state", flow.state);
|
|
1127
|
+
url.searchParams.set("iss", issuerUrl);
|
|
1128
|
+
return { redirectUrl: url.toString() };
|
|
1129
|
+
},
|
|
1130
|
+
/**
|
|
1131
|
+
* Deny a pending flow. Consumes the flow row and returns the OAuth
|
|
1132
|
+
* client's redirect URI with `?error=access_denied&state=...&iss=...`.
|
|
1133
|
+
*
|
|
1134
|
+
* Per RFC 9207 §2, the issuer parameter is included on error responses
|
|
1135
|
+
* too — the RP needs to validate the AS identity before trusting any
|
|
1136
|
+
* field, including `error`.
|
|
1137
|
+
*/
|
|
1138
|
+
async handleDenyFlow(flowId, context) {
|
|
1139
|
+
const flow = await this.resumePendingFlow(flowId, context);
|
|
1140
|
+
const url = new URL(flow.redirectUri);
|
|
1141
|
+
url.searchParams.set("error", "access_denied");
|
|
1142
|
+
url.searchParams.set("state", flow.state);
|
|
1143
|
+
url.searchParams.set("iss", issuerUrl);
|
|
1144
|
+
return { redirectUrl: url.toString() };
|
|
1145
|
+
},
|
|
1146
|
+
async getUserInfo(token) {
|
|
1147
|
+
const result = await this._lookupBearer(token);
|
|
1148
|
+
return result?.user ?? null;
|
|
1149
|
+
},
|
|
1150
|
+
/**
|
|
1151
|
+
* Internal: resolve a bearer token to its access-token row + user.
|
|
1152
|
+
* Shared by `getUserInfo` (returns the raw user) and
|
|
1153
|
+
* `handleUserInfoRequest` (maps to OIDC claims).
|
|
1154
|
+
*/
|
|
1155
|
+
async _lookupBearer(token) {
|
|
1156
|
+
const tokenHash = await hashToken(token);
|
|
1157
|
+
const record = await ctx.db.findOne({
|
|
1158
|
+
model: "oauth_access_token",
|
|
1159
|
+
where: [{ field: "token", operator: "=", value: tokenHash }]
|
|
1160
|
+
});
|
|
1161
|
+
if (!record || !record.userId || record.expiresAt < /* @__PURE__ */ new Date())
|
|
1162
|
+
return null;
|
|
1163
|
+
const user = await ctx.db.findOne({
|
|
1164
|
+
model: "user",
|
|
1165
|
+
where: [{ field: "id", operator: "=", value: record.userId }]
|
|
1166
|
+
});
|
|
1167
|
+
if (!user)
|
|
1168
|
+
return null;
|
|
1169
|
+
return { user, scope: record.scope };
|
|
1170
|
+
},
|
|
1171
|
+
// --- HTTP handler methods (RFC 6749 / 7662 / 7009) ---
|
|
1172
|
+
/**
|
|
1173
|
+
* Handle POST /oauth/token — dispatches to exchangeCode or clientCredentialsGrant.
|
|
1174
|
+
*/
|
|
1175
|
+
async handleTokenRequest(body, clientAuth) {
|
|
1176
|
+
const clientId = clientAuth?.clientId ?? body.client_id;
|
|
1177
|
+
const clientSecret = clientAuth?.clientSecret ?? body.client_secret;
|
|
1178
|
+
if (!clientId)
|
|
1179
|
+
throw Errors.oauth("invalid_client", "Client authentication required");
|
|
1180
|
+
const clientRecord = await ctx.db.findOne({
|
|
1181
|
+
model: "oauth_client",
|
|
1182
|
+
where: [{ field: "clientId", operator: "=", value: clientId }]
|
|
1183
|
+
});
|
|
1184
|
+
if (!clientRecord)
|
|
1185
|
+
throw Errors.oauth("invalid_client", "Invalid client credentials");
|
|
1186
|
+
const isPublic = clientRecord.tokenEndpointAuthMethod === "none";
|
|
1187
|
+
if (!isPublic && !clientSecret)
|
|
1188
|
+
throw Errors.oauth("invalid_client", "Client authentication required");
|
|
1189
|
+
if (body.grant_type === "authorization_code") {
|
|
1190
|
+
if (!body.code || !body.redirect_uri)
|
|
1191
|
+
throw Errors.oauth("invalid_request", "Missing required parameters: code, redirect_uri");
|
|
1192
|
+
if (isPublic && !body.code_verifier)
|
|
1193
|
+
throw Errors.oauth("invalid_grant", "code_verifier required for public clients");
|
|
1194
|
+
const result = await this.exchangeCode({
|
|
1195
|
+
code: body.code,
|
|
1196
|
+
clientId,
|
|
1197
|
+
clientSecret: isPublic ? void 0 : clientSecret,
|
|
1198
|
+
redirectUri: body.redirect_uri,
|
|
1199
|
+
codeVerifier: body.code_verifier
|
|
1200
|
+
});
|
|
1201
|
+
return {
|
|
1202
|
+
access_token: result.accessToken,
|
|
1203
|
+
token_type: result.tokenType,
|
|
1204
|
+
expires_in: result.expiresIn,
|
|
1205
|
+
...result.refreshToken ? { refresh_token: result.refreshToken } : {},
|
|
1206
|
+
...result.idToken ? { id_token: result.idToken } : {},
|
|
1207
|
+
...result.scope ? { scope: result.scope } : {}
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
if (body.grant_type === "refresh_token") {
|
|
1211
|
+
if (!body.refresh_token)
|
|
1212
|
+
throw Errors.oauth("invalid_request", "refresh_token is required");
|
|
1213
|
+
const result = await this.refreshTokenGrant({
|
|
1214
|
+
clientId,
|
|
1215
|
+
clientSecret: isPublic ? void 0 : clientSecret,
|
|
1216
|
+
refreshToken: body.refresh_token,
|
|
1217
|
+
scope: body.scope
|
|
1218
|
+
});
|
|
1219
|
+
return {
|
|
1220
|
+
access_token: result.accessToken,
|
|
1221
|
+
token_type: result.tokenType,
|
|
1222
|
+
expires_in: result.expiresIn,
|
|
1223
|
+
refresh_token: result.refreshToken,
|
|
1224
|
+
...result.idToken ? { id_token: result.idToken } : {},
|
|
1225
|
+
...result.scope ? { scope: result.scope } : {}
|
|
1226
|
+
};
|
|
1227
|
+
}
|
|
1228
|
+
if (body.grant_type === "client_credentials") {
|
|
1229
|
+
if (!clientSecret)
|
|
1230
|
+
throw Errors.oauth("invalid_client", "client_credentials requires a client secret");
|
|
1231
|
+
const result = await this.clientCredentialsGrant({
|
|
1232
|
+
clientId,
|
|
1233
|
+
clientSecret,
|
|
1234
|
+
scope: body.scope
|
|
1235
|
+
});
|
|
1236
|
+
return {
|
|
1237
|
+
access_token: result.accessToken,
|
|
1238
|
+
token_type: result.tokenType,
|
|
1239
|
+
expires_in: result.expiresIn
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
throw Errors.oauth("unsupported_grant_type", `Unsupported grant_type: ${body.grant_type}`);
|
|
1243
|
+
},
|
|
1244
|
+
/**
|
|
1245
|
+
* Handle POST /oauth/introspect (RFC 7662).
|
|
1246
|
+
*/
|
|
1247
|
+
async handleIntrospectRequest(body, clientAuth) {
|
|
1248
|
+
const client = await ctx.db.findOne({
|
|
1249
|
+
model: "oauth_client",
|
|
1250
|
+
where: [{ field: "clientId", operator: "=", value: clientAuth.clientId }]
|
|
1251
|
+
});
|
|
1252
|
+
if (!client)
|
|
1253
|
+
throw Errors.unauthorized("Invalid client credentials");
|
|
1254
|
+
const secretValid = timingSafeEqualHex(
|
|
1255
|
+
await hashToken(clientAuth.clientSecret),
|
|
1256
|
+
client.clientSecretHash
|
|
1257
|
+
);
|
|
1258
|
+
if (!secretValid)
|
|
1259
|
+
throw Errors.oauth("invalid_client", "Invalid client credentials");
|
|
1260
|
+
const result = await this.introspectToken(body.token);
|
|
1261
|
+
if (!result.active || result.clientId !== clientAuth.clientId)
|
|
1262
|
+
return { active: false };
|
|
1263
|
+
return {
|
|
1264
|
+
active: true,
|
|
1265
|
+
client_id: result.clientId,
|
|
1266
|
+
...result.userId != null ? { sub: String(result.userId) } : {},
|
|
1267
|
+
...result.scope ? { scope: result.scope } : {},
|
|
1268
|
+
token_type: "Bearer"
|
|
1269
|
+
};
|
|
1270
|
+
},
|
|
1271
|
+
/**
|
|
1272
|
+
* Handle POST /oauth/revoke (RFC 7009). Always returns success.
|
|
1273
|
+
*/
|
|
1274
|
+
async handleRevokeRequest(body, clientAuth) {
|
|
1275
|
+
const client = await ctx.db.findOne({
|
|
1276
|
+
model: "oauth_client",
|
|
1277
|
+
where: [{ field: "clientId", operator: "=", value: clientAuth.clientId }]
|
|
1278
|
+
});
|
|
1279
|
+
if (!client || !timingSafeEqualHex(await hashToken(clientAuth.clientSecret), client.clientSecretHash))
|
|
1280
|
+
throw Errors.oauth("invalid_client", "Invalid client credentials");
|
|
1281
|
+
const tokenHash = await hashToken(body.token);
|
|
1282
|
+
await ctx.db.delete({
|
|
1283
|
+
model: "oauth_access_token",
|
|
1284
|
+
where: [
|
|
1285
|
+
{ field: "token", operator: "=", value: tokenHash },
|
|
1286
|
+
{ field: "clientId", operator: "=", value: clientAuth.clientId }
|
|
1287
|
+
]
|
|
1288
|
+
});
|
|
1289
|
+
if (refreshEnabled) {
|
|
1290
|
+
const refreshRecord = await ctx.db.findOne({
|
|
1291
|
+
model: "oauth_refresh_token",
|
|
1292
|
+
where: [
|
|
1293
|
+
{ field: "token", operator: "=", value: tokenHash },
|
|
1294
|
+
{ field: "clientId", operator: "=", value: clientAuth.clientId }
|
|
1295
|
+
]
|
|
1296
|
+
});
|
|
1297
|
+
if (refreshRecord) {
|
|
1298
|
+
await ctx.db.delete({
|
|
1299
|
+
model: "oauth_refresh_token",
|
|
1300
|
+
where: [
|
|
1301
|
+
{ field: "familyId", operator: "=", value: refreshRecord.familyId },
|
|
1302
|
+
{ field: "clientId", operator: "=", value: clientAuth.clientId }
|
|
1303
|
+
]
|
|
1304
|
+
});
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
},
|
|
1308
|
+
/**
|
|
1309
|
+
* Handle GET /oauth/userinfo (OpenID Connect Core §5.3).
|
|
1310
|
+
*
|
|
1311
|
+
* Returns the OIDC-shaped claims object expected by strict RPs (Moodle
|
|
1312
|
+
* `core\oauth2\client::get_userinfo()`, openid-client, Keycloak
|
|
1313
|
+
* federation, Spring Security). The pre-1.0 fortress shape that
|
|
1314
|
+
* leaked DB-internal fields (`id`, `isActive`, `createdAt`) is gone
|
|
1315
|
+
* — callers must rely on the spec-conformant fields below.
|
|
1316
|
+
*
|
|
1317
|
+
* Standard claim coverage (OIDC Core §5.1) gated by access-token
|
|
1318
|
+
* scope (§5.4):
|
|
1319
|
+
* - `sub` always (stringified `user.id`)
|
|
1320
|
+
* - `email`, `email_verified` only when scope contains `email`
|
|
1321
|
+
* - `name`, `preferred_username` only when scope contains `profile`
|
|
1322
|
+
* - `updated_at` always (Unix seconds, per §5.1)
|
|
1323
|
+
*
|
|
1324
|
+
* Extra claims can be added per-deployment via
|
|
1325
|
+
* {@link OAuthConfig.userinfoClaims}.
|
|
1326
|
+
*/
|
|
1327
|
+
async handleUserInfoRequest(bearerToken) {
|
|
1328
|
+
const result = await this._lookupBearer(bearerToken);
|
|
1329
|
+
if (!result)
|
|
1330
|
+
throw Errors.unauthorized("Invalid or expired access token");
|
|
1331
|
+
const claims = toOidcUserinfo(result.user, result.scope);
|
|
1332
|
+
if (config.userinfoClaims) {
|
|
1333
|
+
const extra = await config.userinfoClaims(result.user, result.scope);
|
|
1334
|
+
Object.assign(claims, extra);
|
|
1335
|
+
}
|
|
1336
|
+
return claims;
|
|
1337
|
+
},
|
|
1338
|
+
/**
|
|
1339
|
+
* Handle GET /.well-known/openid-configuration (RFC 8414 + OIDC
|
|
1340
|
+
* Discovery 1.0). Includes the RFC 9207 metadata flag and the OIDC
|
|
1341
|
+
* fields strict RPs (Moodle, openid-client, Spring Security) require
|
|
1342
|
+
* for autoconfig.
|
|
1343
|
+
*/
|
|
1344
|
+
handleDiscovery() {
|
|
1345
|
+
const issuer = issuerUrl;
|
|
1346
|
+
const mappedScopes = scopePermissionMap ? Object.keys(scopePermissionMap) : [];
|
|
1347
|
+
const scopesSupported = Array.from(/* @__PURE__ */ new Set([
|
|
1348
|
+
"openid",
|
|
1349
|
+
"email",
|
|
1350
|
+
"profile",
|
|
1351
|
+
...config.scopesSupported ?? [],
|
|
1352
|
+
...mappedScopes
|
|
1353
|
+
]));
|
|
1354
|
+
return {
|
|
1355
|
+
issuer,
|
|
1356
|
+
authorization_endpoint: `${issuer}/oauth/authorize`,
|
|
1357
|
+
token_endpoint: `${issuer}/oauth/token`,
|
|
1358
|
+
introspection_endpoint: `${issuer}/oauth/introspect`,
|
|
1359
|
+
revocation_endpoint: `${issuer}/oauth/revoke`,
|
|
1360
|
+
userinfo_endpoint: `${issuer}/oauth/userinfo`,
|
|
1361
|
+
jwks_uri: `${issuer}/oauth/.well-known/jwks.json`,
|
|
1362
|
+
response_types_supported: ["code"],
|
|
1363
|
+
response_modes_supported: ["query"],
|
|
1364
|
+
grant_types_supported: refreshEnabled ? ["authorization_code", "client_credentials", "refresh_token"] : ["authorization_code", "client_credentials"],
|
|
1365
|
+
token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"],
|
|
1366
|
+
code_challenge_methods_supported: ["S256"],
|
|
1367
|
+
subject_types_supported: ["public"],
|
|
1368
|
+
// OIDC Discovery §3 REQUIRED when id_tokens are issued.
|
|
1369
|
+
id_token_signing_alg_values_supported: ["RS256"],
|
|
1370
|
+
scopes_supported: scopesSupported,
|
|
1371
|
+
claims_supported: [
|
|
1372
|
+
"sub",
|
|
1373
|
+
"email",
|
|
1374
|
+
"email_verified",
|
|
1375
|
+
"name",
|
|
1376
|
+
"preferred_username",
|
|
1377
|
+
"updated_at",
|
|
1378
|
+
"iss",
|
|
1379
|
+
"aud",
|
|
1380
|
+
"exp",
|
|
1381
|
+
"iat",
|
|
1382
|
+
"auth_time",
|
|
1383
|
+
"nonce"
|
|
1384
|
+
],
|
|
1385
|
+
// RFC 9207 §3.
|
|
1386
|
+
authorization_response_iss_parameter_supported: true
|
|
1387
|
+
};
|
|
1388
|
+
},
|
|
1389
|
+
/**
|
|
1390
|
+
* Handle GET /oauth/.well-known/jwks.json (RFC 7517).
|
|
1391
|
+
*
|
|
1392
|
+
* Returns the AS's JSON Web Key Set: the active RS256 public key
|
|
1393
|
+
* plus any rotated keys still in the verification grace window. RPs
|
|
1394
|
+
* fetch this URL (advertised via discovery's `jwks_uri`) and use it
|
|
1395
|
+
* to verify id_token signatures by `kid`.
|
|
1396
|
+
*/
|
|
1397
|
+
async rotateSigningKey() {
|
|
1398
|
+
const key = await rotateSigningKey(ctx.db, signingKeyGraceSeconds);
|
|
1399
|
+
return { kid: key.kid };
|
|
1400
|
+
},
|
|
1401
|
+
async handleJwksRequest() {
|
|
1402
|
+
const jwks = await listJwks(ctx.db, signingKeyGraceSeconds);
|
|
1403
|
+
if (jwks.keys.length === 0) {
|
|
1404
|
+
await getActiveSigningKey(ctx.db);
|
|
1405
|
+
return await listJwks(ctx.db, signingKeyGraceSeconds);
|
|
1406
|
+
}
|
|
1407
|
+
return jwks;
|
|
1408
|
+
},
|
|
1409
|
+
/**
|
|
1410
|
+
* Resolve an access token's scopes to IAM permissions using the configured scopePermissionMap.
|
|
1411
|
+
*/
|
|
1412
|
+
async resolveTokenPermissions(token) {
|
|
1413
|
+
const info = await this.introspectToken(token);
|
|
1414
|
+
if (!info.active || !info.scope || !scopePermissionMap)
|
|
1415
|
+
return [];
|
|
1416
|
+
const scopes = info.scope.split(" ");
|
|
1417
|
+
const permissions = [];
|
|
1418
|
+
for (const scope of scopes) {
|
|
1419
|
+
const mapping = scopePermissionMap[scope];
|
|
1420
|
+
if (mapping) {
|
|
1421
|
+
permissions.push(mapping);
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
return permissions;
|
|
1425
|
+
}
|
|
1426
|
+
}),
|
|
1427
|
+
routes: {
|
|
1428
|
+
// Front door for the auth-code flow. Opt-in: only mounted when the
|
|
1429
|
+
// host app has wired up `loginUrl` and `consentUrl`.
|
|
1430
|
+
...config.enableAuthorizeEndpoint ? {
|
|
1431
|
+
handleAuthorizeRequest: {
|
|
1432
|
+
method: "GET",
|
|
1433
|
+
path: "/oauth/authorize",
|
|
1434
|
+
handler: "handleAuthorizeRequest",
|
|
1435
|
+
meta: {
|
|
1436
|
+
summary: "Start an OAuth authorization-code flow",
|
|
1437
|
+
tags: ["OAuth"],
|
|
1438
|
+
security: ["none"],
|
|
1439
|
+
bearerKind: "oauth"
|
|
1440
|
+
},
|
|
1441
|
+
input: {
|
|
1442
|
+
query: {
|
|
1443
|
+
type: "object",
|
|
1444
|
+
properties: {
|
|
1445
|
+
client_id: { type: "string", description: "Registered OAuth client ID" },
|
|
1446
|
+
redirect_uri: { type: "string", format: "uri", description: "Must match a redirect URI registered with the client" },
|
|
1447
|
+
response_type: { type: "string", enum: ["code"], description: 'Only "code" is supported' },
|
|
1448
|
+
scope: { type: "string", description: "Space-separated requested scopes" },
|
|
1449
|
+
state: { type: "string", description: "Opaque value returned to the client; required to prevent CSRF" },
|
|
1450
|
+
code_challenge: { type: "string", description: "PKCE code challenge (recommended for public clients)" },
|
|
1451
|
+
code_challenge_method: { type: "string", enum: ["S256"], description: "PKCE method \u2014 only S256 is supported" }
|
|
1452
|
+
},
|
|
1453
|
+
required: ["client_id", "redirect_uri", "response_type", "state"]
|
|
1454
|
+
}
|
|
1455
|
+
},
|
|
1456
|
+
responses: {
|
|
1457
|
+
302: { description: "Redirect to login (unauthenticated) or consent (authenticated) page with ?flow=<id>" },
|
|
1458
|
+
400: { description: "Invalid request" }
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
} : {},
|
|
1462
|
+
// Consent API — SPA-friendly flow inspection + approve/deny.
|
|
1463
|
+
...config.enableConsentApi ? {
|
|
1464
|
+
handleGetFlow: {
|
|
1465
|
+
method: "GET",
|
|
1466
|
+
path: "/oauth/flows/:flowId",
|
|
1467
|
+
handler: "handleGetFlow",
|
|
1468
|
+
meta: {
|
|
1469
|
+
summary: "Fetch pending OAuth flow metadata",
|
|
1470
|
+
tags: ["OAuth"],
|
|
1471
|
+
security: ["bearer"]
|
|
1472
|
+
},
|
|
1473
|
+
responses: {
|
|
1474
|
+
200: {
|
|
1475
|
+
description: "Flow metadata",
|
|
1476
|
+
schema: {
|
|
1477
|
+
type: "object",
|
|
1478
|
+
properties: {
|
|
1479
|
+
flowId: { type: "string" },
|
|
1480
|
+
client: {
|
|
1481
|
+
type: "object",
|
|
1482
|
+
properties: {
|
|
1483
|
+
clientId: { type: "string" },
|
|
1484
|
+
name: { type: "string" }
|
|
1485
|
+
}
|
|
1486
|
+
},
|
|
1487
|
+
redirectUri: { type: "string" },
|
|
1488
|
+
scopes: { type: "array", items: { type: "string" } },
|
|
1489
|
+
state: { type: "string" }
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
},
|
|
1493
|
+
401: { description: "Authentication required" },
|
|
1494
|
+
404: { description: "Flow not found" }
|
|
1495
|
+
}
|
|
1496
|
+
},
|
|
1497
|
+
handleApproveFlow: {
|
|
1498
|
+
method: "POST",
|
|
1499
|
+
path: "/oauth/flows/:flowId/approve",
|
|
1500
|
+
handler: "handleApproveFlow",
|
|
1501
|
+
meta: {
|
|
1502
|
+
summary: "Approve a pending OAuth flow",
|
|
1503
|
+
tags: ["OAuth"],
|
|
1504
|
+
security: ["bearer"]
|
|
1505
|
+
},
|
|
1506
|
+
responses: {
|
|
1507
|
+
200: {
|
|
1508
|
+
description: "Authorization code issued",
|
|
1509
|
+
schema: {
|
|
1510
|
+
type: "object",
|
|
1511
|
+
properties: { redirectUrl: { type: "string" } }
|
|
1512
|
+
}
|
|
1513
|
+
},
|
|
1514
|
+
401: { description: "Authentication required" },
|
|
1515
|
+
404: { description: "Flow not found" }
|
|
1516
|
+
}
|
|
1517
|
+
},
|
|
1518
|
+
handleDenyFlow: {
|
|
1519
|
+
method: "POST",
|
|
1520
|
+
path: "/oauth/flows/:flowId/deny",
|
|
1521
|
+
handler: "handleDenyFlow",
|
|
1522
|
+
meta: {
|
|
1523
|
+
summary: "Deny a pending OAuth flow",
|
|
1524
|
+
tags: ["OAuth"],
|
|
1525
|
+
security: ["bearer"]
|
|
1526
|
+
},
|
|
1527
|
+
responses: {
|
|
1528
|
+
200: {
|
|
1529
|
+
description: "Flow denied",
|
|
1530
|
+
schema: {
|
|
1531
|
+
type: "object",
|
|
1532
|
+
properties: { redirectUrl: { type: "string" } }
|
|
1533
|
+
}
|
|
1534
|
+
},
|
|
1535
|
+
401: { description: "Authentication required" },
|
|
1536
|
+
404: { description: "Flow not found" }
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
} : {},
|
|
1540
|
+
handleTokenRequest: {
|
|
1541
|
+
method: "POST",
|
|
1542
|
+
path: "/oauth/token",
|
|
1543
|
+
handler: "handleTokenRequest",
|
|
1544
|
+
meta: { summary: "Exchange credentials for tokens", tags: ["OAuth"], security: ["basic"], bearerKind: "oauth" },
|
|
1545
|
+
input: {
|
|
1546
|
+
body: {
|
|
1547
|
+
type: "object",
|
|
1548
|
+
properties: {
|
|
1549
|
+
grant_type: { type: "string", enum: ["authorization_code", "client_credentials", "refresh_token"], description: "OAuth grant type" },
|
|
1550
|
+
code: { type: "string", description: "Authorization code" },
|
|
1551
|
+
redirect_uri: { type: "string", format: "uri", description: "Redirect URI used in authorize" },
|
|
1552
|
+
client_id: { type: "string", description: "Client ID (if not using Basic auth)" },
|
|
1553
|
+
client_secret: { type: "string", description: "Client secret (if not using Basic auth)" },
|
|
1554
|
+
code_verifier: { type: "string", description: "PKCE code verifier" },
|
|
1555
|
+
refresh_token: { type: "string", description: "Refresh token for grant_type=refresh_token" },
|
|
1556
|
+
scope: { type: "string", description: "Space-separated scopes" }
|
|
1557
|
+
},
|
|
1558
|
+
required: ["grant_type"]
|
|
1559
|
+
}
|
|
1560
|
+
},
|
|
1561
|
+
responses: {
|
|
1562
|
+
200: { description: "Token issued", schema: { type: "object", properties: { access_token: { type: "string" }, token_type: { type: "string" }, expires_in: { type: "number" }, refresh_token: { type: "string" }, id_token: { type: "string" }, scope: { type: "string" } } } },
|
|
1563
|
+
400: { description: "Invalid request" },
|
|
1564
|
+
401: { description: "Invalid client credentials" }
|
|
1565
|
+
}
|
|
1566
|
+
},
|
|
1567
|
+
handleIntrospectRequest: {
|
|
1568
|
+
method: "POST",
|
|
1569
|
+
path: "/oauth/introspect",
|
|
1570
|
+
handler: "handleIntrospectRequest",
|
|
1571
|
+
meta: { summary: "Introspect a token (RFC 7662)", tags: ["OAuth"], security: ["basic"], bearerKind: "oauth" },
|
|
1572
|
+
input: { body: { type: "object", properties: { token: { type: "string" } }, required: ["token"] } },
|
|
1573
|
+
responses: {
|
|
1574
|
+
200: { description: "Token info", schema: { type: "object", properties: { active: { type: "boolean" }, sub: { type: "string" }, scope: { type: "string" }, exp: { type: "number" } } } },
|
|
1575
|
+
401: { description: "Client authentication required" }
|
|
1576
|
+
}
|
|
1577
|
+
},
|
|
1578
|
+
handleRevokeRequest: {
|
|
1579
|
+
method: "POST",
|
|
1580
|
+
path: "/oauth/revoke",
|
|
1581
|
+
handler: "handleRevokeRequest",
|
|
1582
|
+
meta: { summary: "Revoke a token (RFC 7009)", tags: ["OAuth"], security: ["basic"], bearerKind: "oauth" },
|
|
1583
|
+
input: { body: { type: "object", properties: { token: { type: "string" } }, required: ["token"] } },
|
|
1584
|
+
responses: { 200: { description: "Token revoked" } }
|
|
1585
|
+
},
|
|
1586
|
+
handleUserInfoRequest: {
|
|
1587
|
+
method: "GET",
|
|
1588
|
+
path: "/oauth/userinfo",
|
|
1589
|
+
handler: "handleUserInfoRequest",
|
|
1590
|
+
meta: { summary: "Get user info (OIDC Core \xA75.3)", tags: ["OAuth"], security: ["bearer"], bearerKind: "oauth" },
|
|
1591
|
+
responses: {
|
|
1592
|
+
200: {
|
|
1593
|
+
description: "OIDC userinfo response",
|
|
1594
|
+
schema: {
|
|
1595
|
+
type: "object",
|
|
1596
|
+
properties: {
|
|
1597
|
+
sub: { type: "string", description: "Stringified user identifier" },
|
|
1598
|
+
email: { type: "string", format: "email" },
|
|
1599
|
+
email_verified: { type: "boolean" },
|
|
1600
|
+
name: { type: "string" },
|
|
1601
|
+
preferred_username: { type: "string" },
|
|
1602
|
+
updated_at: { type: "number", description: "Unix seconds since the user record was last updated" }
|
|
1603
|
+
},
|
|
1604
|
+
required: ["sub"],
|
|
1605
|
+
additionalProperties: true
|
|
1606
|
+
}
|
|
1607
|
+
},
|
|
1608
|
+
401: { description: "Invalid or expired bearer token" }
|
|
1609
|
+
}
|
|
1610
|
+
},
|
|
1611
|
+
handleDiscovery: {
|
|
1612
|
+
method: "GET",
|
|
1613
|
+
path: "/oauth/.well-known/openid-configuration",
|
|
1614
|
+
handler: "handleDiscovery",
|
|
1615
|
+
meta: { summary: "OIDC discovery document", tags: ["OAuth"], security: ["none"], bearerKind: "oauth" },
|
|
1616
|
+
responses: { 200: { description: "OIDC configuration", schema: { type: "object", additionalProperties: true } } }
|
|
1617
|
+
},
|
|
1618
|
+
handleJwksRequest: {
|
|
1619
|
+
method: "GET",
|
|
1620
|
+
path: "/oauth/.well-known/jwks.json",
|
|
1621
|
+
handler: "handleJwksRequest",
|
|
1622
|
+
meta: { summary: "JSON Web Key Set (RFC 7517)", tags: ["OAuth"], security: ["none"], bearerKind: "oauth" },
|
|
1623
|
+
responses: {
|
|
1624
|
+
200: {
|
|
1625
|
+
description: "JWKS for verifying id_token signatures",
|
|
1626
|
+
schema: {
|
|
1627
|
+
type: "object",
|
|
1628
|
+
properties: {
|
|
1629
|
+
keys: {
|
|
1630
|
+
type: "array",
|
|
1631
|
+
items: { type: "object", additionalProperties: true }
|
|
1632
|
+
}
|
|
1633
|
+
},
|
|
1634
|
+
required: ["keys"]
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
};
|
|
1641
|
+
}
|
|
1642
|
+
function matchRedirectUri(registered, inbound) {
|
|
1643
|
+
if (registered === inbound)
|
|
1644
|
+
return true;
|
|
1645
|
+
let r, i;
|
|
1646
|
+
try {
|
|
1647
|
+
r = new URL(registered);
|
|
1648
|
+
i = new URL(inbound);
|
|
1649
|
+
} catch {
|
|
1650
|
+
return false;
|
|
1651
|
+
}
|
|
1652
|
+
const isLoopback = r.protocol === "http:" && (r.hostname === "127.0.0.1" || r.hostname === "[::1]") && r.hostname === i.hostname;
|
|
1653
|
+
if (!isLoopback)
|
|
1654
|
+
return false;
|
|
1655
|
+
return r.protocol === i.protocol && r.pathname === i.pathname && r.search === i.search;
|
|
1656
|
+
}
|
|
1657
|
+
function pendingFlowLookupWhere(flowId) {
|
|
1658
|
+
return typeof flowId === "number" ? [{ field: "id", operator: "=", value: flowId }] : [{ field: "flowId", operator: "=", value: flowId }];
|
|
1659
|
+
}
|
|
1660
|
+
function intersectScope(requested, clientAllowedJson) {
|
|
1661
|
+
if (!requested)
|
|
1662
|
+
return "";
|
|
1663
|
+
if (clientAllowedJson == null)
|
|
1664
|
+
return requested;
|
|
1665
|
+
const allowed = JSON.parse(clientAllowedJson);
|
|
1666
|
+
const allowedSet = new Set(allowed);
|
|
1667
|
+
return requested.split(" ").filter(Boolean).filter((s) => allowedSet.has(s)).join(" ");
|
|
1668
|
+
}
|
|
1669
|
+
function toOidcUserinfo(user, scope) {
|
|
1670
|
+
const scopes = scope ? scope.split(" ").filter(Boolean) : [];
|
|
1671
|
+
const exposeEmail = scopes.includes("email");
|
|
1672
|
+
const exposeProfile = scopes.includes("profile");
|
|
1673
|
+
const claims = { sub: String(user.id) };
|
|
1674
|
+
if (exposeEmail) {
|
|
1675
|
+
claims.email = user.email;
|
|
1676
|
+
if (typeof user.emailVerified === "boolean")
|
|
1677
|
+
claims.email_verified = user.emailVerified;
|
|
1678
|
+
}
|
|
1679
|
+
if (exposeProfile) {
|
|
1680
|
+
claims.name = user.name;
|
|
1681
|
+
claims.preferred_username = user.email;
|
|
1682
|
+
}
|
|
1683
|
+
if (user.updatedAt) {
|
|
1684
|
+
claims.updated_at = Math.floor(new Date(user.updatedAt).getTime() / 1e3);
|
|
1685
|
+
}
|
|
1686
|
+
return claims;
|
|
1687
|
+
}
|
|
1688
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1689
|
+
0 && (module.exports = {
|
|
1690
|
+
generateCodeChallenge,
|
|
1691
|
+
generateCodeVerifier,
|
|
1692
|
+
matchRedirectUri,
|
|
1693
|
+
oauth,
|
|
1694
|
+
toOidcUserinfo,
|
|
1695
|
+
verifyCodeChallenge
|
|
1696
|
+
});
|