@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,2145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth 2.0 authorization server plugin for fortress.
|
|
3
|
+
*
|
|
4
|
+
* Implements the authorization-code grant (with PKCE) and client-credentials
|
|
5
|
+
* grant for confidential and public clients. Persists clients, authorization
|
|
6
|
+
* codes, access tokens, and pending flows via the fortress database adapter,
|
|
7
|
+
* and exposes the standard `/authorize` and `/token` endpoints when mounted.
|
|
8
|
+
*
|
|
9
|
+
* @module
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { WhereClause } from '../../adapters/database/types';
|
|
13
|
+
import type { FortressPlugin } from '../../core/plugin';
|
|
14
|
+
import type { FortressUser } from '../../core/types';
|
|
15
|
+
import { generateRefreshToken, generateTokenFamily, hashToken } from '../../core/auth/refresh-token';
|
|
16
|
+
import { timingSafeEqualHex } from '../../core/auth/timing-safe';
|
|
17
|
+
import { Errors } from '../../core/errors';
|
|
18
|
+
import { issueIdToken } from './id-token';
|
|
19
|
+
import { getActiveSigningKey, listJwks, rotateSigningKey } from './jwks';
|
|
20
|
+
import { verifyCodeChallenge } from './pkce';
|
|
21
|
+
|
|
22
|
+
export interface OAuthConfig {
|
|
23
|
+
/** Authorization code expiry in seconds (default: 600 = 10 min) */
|
|
24
|
+
authCodeExpirySeconds?: number;
|
|
25
|
+
/** Pending flow expiry in seconds (default: 600 = 10 min) */
|
|
26
|
+
pendingFlowExpirySeconds?: number;
|
|
27
|
+
/** Access token expiry in seconds (default: 3600 = 1 hour) */
|
|
28
|
+
accessTokenExpirySeconds?: number;
|
|
29
|
+
/**
|
|
30
|
+
* Refresh token expiry in seconds (default: 30 days). Refresh tokens are
|
|
31
|
+
* sliding — each rotation issues a fresh token with a new expiry. Set to
|
|
32
|
+
* 0 to disable refresh-token issuance entirely.
|
|
33
|
+
*
|
|
34
|
+
* @see RFC 6749 §6 · RFC 9700 §2.2.2
|
|
35
|
+
*/
|
|
36
|
+
refreshTokenExpirySeconds?: number;
|
|
37
|
+
/**
|
|
38
|
+
* id_token expiry in seconds (default: 3600 = 1 hour). Only relevant
|
|
39
|
+
* when the request includes `scope=openid`.
|
|
40
|
+
*
|
|
41
|
+
* @see OIDC Core 1.0 §2
|
|
42
|
+
*/
|
|
43
|
+
idTokenExpirySeconds?: number;
|
|
44
|
+
/** Seconds rotated signing keys remain published in JWKS. Default: idTokenExpirySeconds. */
|
|
45
|
+
signingKeyGraceSeconds?: number;
|
|
46
|
+
/** Map OAuth scopes to IAM permissions. Example: `{ 'read:posts': { resource: 'post', action: 'read' } }` */
|
|
47
|
+
scopePermissionMap?: Record<string, { resource: string; action: string }>;
|
|
48
|
+
/** Base URL for the OAuth server (used in OIDC discovery document) */
|
|
49
|
+
issuerUrl?: string;
|
|
50
|
+
/**
|
|
51
|
+
* RFC 9700 §2.1.1 escape hatch: when `true`, `/oauth/authorize` will accept
|
|
52
|
+
* requests without a `code_challenge` from confidential clients. Defaults
|
|
53
|
+
* to `false`. Strongly discouraged — PKCE is mandatory in current OAuth
|
|
54
|
+
* BCP. Provided only so legacy server-side RPs can be migrated
|
|
55
|
+
* incrementally.
|
|
56
|
+
*/
|
|
57
|
+
allowNonPkceConfidentialClients?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Static list of OIDC/OAuth scope names the AS knows about, surfaced in
|
|
60
|
+
* discovery's `scopes_supported`. Merged with the keys of
|
|
61
|
+
* {@link OAuthConfig.scopePermissionMap} and the default OIDC scopes
|
|
62
|
+
* (`openid`, `email`, `profile`). Optional — informational metadata only;
|
|
63
|
+
* does not enforce per-client allow-listing on its own.
|
|
64
|
+
*/
|
|
65
|
+
scopesSupported?: string[];
|
|
66
|
+
/**
|
|
67
|
+
* Per-deployment extension hook for `/oauth/userinfo`. Receives the
|
|
68
|
+
* {@link FortressUser} resolved from the access token plus the token's
|
|
69
|
+
* scope (or `null` if no scope was issued), and returns a claims record
|
|
70
|
+
* that is merged into the OIDC-shaped response on top of the standard
|
|
71
|
+
* claims fortress emits.
|
|
72
|
+
*
|
|
73
|
+
* Use this to attach app-specific claims (tenant, roles, custom
|
|
74
|
+
* profile fields). Returning `{}` is the no-op; returning a key already
|
|
75
|
+
* emitted by fortress overwrites it (you'd typically only do that for
|
|
76
|
+
* `preferred_username` or `name` when the host app stores its own).
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* ```ts
|
|
80
|
+
* userinfoClaims: (user, scope) => ({
|
|
81
|
+
* tenant_id: user.tenantId,
|
|
82
|
+
* ...(scope?.includes('profile') ? { picture: user.avatarUrl } : {}),
|
|
83
|
+
* })
|
|
84
|
+
* ```
|
|
85
|
+
*/
|
|
86
|
+
userinfoClaims?: (
|
|
87
|
+
user: FortressUser,
|
|
88
|
+
scope: string | null,
|
|
89
|
+
) => Record<string, unknown> | Promise<Record<string, unknown>>;
|
|
90
|
+
/**
|
|
91
|
+
* Mount the front-door `GET /oauth/authorize` endpoint. Off by default —
|
|
92
|
+
* opt in once your host app has wired up `loginUrl` and `consentUrl`.
|
|
93
|
+
* Plugin methods (`createPendingFlow`, `handleAuthorizeRequest`) stay
|
|
94
|
+
* available regardless.
|
|
95
|
+
*/
|
|
96
|
+
enableAuthorizeEndpoint?: boolean;
|
|
97
|
+
/**
|
|
98
|
+
* Mount the consent-API endpoints (`GET /oauth/flows/:flowId`,
|
|
99
|
+
* `POST /oauth/flows/:flowId/approve`, `POST /oauth/flows/:flowId/deny`).
|
|
100
|
+
* Off by default — opt in for the SPA-friendly consent flow (Pattern B).
|
|
101
|
+
* The corresponding methods stay available regardless.
|
|
102
|
+
*/
|
|
103
|
+
enableConsentApi?: boolean;
|
|
104
|
+
/**
|
|
105
|
+
* Where the host app's sign-in page lives, e.g.
|
|
106
|
+
* `'https://app.example.com/signin'`. The authorize endpoint redirects
|
|
107
|
+
* unauthenticated users to `${loginUrl}?flow=<id>`.
|
|
108
|
+
*/
|
|
109
|
+
loginUrl?: string;
|
|
110
|
+
/**
|
|
111
|
+
* Where the host app's consent page lives, e.g.
|
|
112
|
+
* `'https://app.example.com/oauth/consent'`. The authorize endpoint
|
|
113
|
+
* redirects authenticated users to `${consentUrl}?flow=<id>`.
|
|
114
|
+
*/
|
|
115
|
+
consentUrl?: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
interface OAuthClientRecord {
|
|
119
|
+
id: string;
|
|
120
|
+
clientId: string;
|
|
121
|
+
clientSecretHash: string;
|
|
122
|
+
name: string;
|
|
123
|
+
redirectUris: string; // JSON
|
|
124
|
+
grantTypes: string; // JSON
|
|
125
|
+
/**
|
|
126
|
+
* RFC 6749 §3.3 + RFC 9700 §2.2.1: per-client scope allow-list (JSON
|
|
127
|
+
* array). When set, requested scopes are intersected against this list at
|
|
128
|
+
* authorize / token time — unauthorised scopes are dropped silently per
|
|
129
|
+
* §3.3, and an empty intersection returns `error=invalid_scope`. When
|
|
130
|
+
* `null`, fortress passes through whatever scope the client requested
|
|
131
|
+
* (legacy v0 behaviour, deprecated — set `allowedScopes: []` to deny all
|
|
132
|
+
* or pass an explicit list).
|
|
133
|
+
*/
|
|
134
|
+
allowedScopes: string | null;
|
|
135
|
+
/**
|
|
136
|
+
* RFC 6749 §2.1 client type. Persisted as the OIDC discovery alias:
|
|
137
|
+
* `'client_secret_basic'` (default), `'client_secret_post'`, or `'none'`
|
|
138
|
+
* for public clients (SPAs, native apps — RFC 8252). Public clients use
|
|
139
|
+
* PKCE in lieu of a secret; the token endpoint accepts them on `client_id`
|
|
140
|
+
* + verifier alone.
|
|
141
|
+
*/
|
|
142
|
+
tokenEndpointAuthMethod: string | null;
|
|
143
|
+
createdAt: Date;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** RFC 6749 §2.1 / OIDC Discovery `token_endpoint_auth_methods_supported` aliases. */
|
|
147
|
+
export type TokenEndpointAuthMethod = 'client_secret_basic' | 'client_secret_post' | 'none';
|
|
148
|
+
|
|
149
|
+
interface AuthCodeRecord {
|
|
150
|
+
id: string;
|
|
151
|
+
code: string;
|
|
152
|
+
clientId: string;
|
|
153
|
+
userId: string;
|
|
154
|
+
redirectUri: string;
|
|
155
|
+
scope: string | null;
|
|
156
|
+
codeChallenge: string | null;
|
|
157
|
+
codeChallengeMethod: string | null;
|
|
158
|
+
/** OIDC Core §3.1.2.1 — echoed into the id_token if the authorize request supplied one. */
|
|
159
|
+
nonce: string | null;
|
|
160
|
+
/** Unix seconds (OIDC `auth_time` claim) recorded when the user approved the flow. */
|
|
161
|
+
authTime: number | null;
|
|
162
|
+
expiresAt: Date;
|
|
163
|
+
usedAt: Date | null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
interface AccessTokenRecord {
|
|
167
|
+
id: string;
|
|
168
|
+
token: string;
|
|
169
|
+
clientId: string;
|
|
170
|
+
userId: string | null;
|
|
171
|
+
scope: string | null;
|
|
172
|
+
expiresAt: Date;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Persisted refresh token row. Stores only the SHA-256 hash; the raw token
|
|
177
|
+
* is returned to the client exactly once and never retained.
|
|
178
|
+
*
|
|
179
|
+
* Rotation tracking: every refresh-token request mints a fresh token with a
|
|
180
|
+
* new id, marks the old one's `usedAt`, and links via `parentId`. The
|
|
181
|
+
* `familyId` is shared across the entire chain — detecting reuse of an
|
|
182
|
+
* already-rotated token revokes every member of the family (RFC 9700
|
|
183
|
+
* §2.2.2).
|
|
184
|
+
*/
|
|
185
|
+
interface RefreshTokenRecord {
|
|
186
|
+
id: string;
|
|
187
|
+
token: string;
|
|
188
|
+
familyId: string;
|
|
189
|
+
clientId: string;
|
|
190
|
+
userId: string;
|
|
191
|
+
scope: string | null;
|
|
192
|
+
issuedAt: Date;
|
|
193
|
+
expiresAt: Date;
|
|
194
|
+
usedAt: Date | null;
|
|
195
|
+
parentId: string | null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Persisted state for an in-flight OAuth authorization-code flow. */
|
|
199
|
+
export interface PendingFlowRecord {
|
|
200
|
+
/** Internal DB primary key — never exposed to clients. */
|
|
201
|
+
id: string;
|
|
202
|
+
/** Public opaque handle used in URLs; replaces enumerable serial ids (H6). */
|
|
203
|
+
flowId: string;
|
|
204
|
+
clientId: string;
|
|
205
|
+
redirectUri: string;
|
|
206
|
+
scope: string | null;
|
|
207
|
+
state: string;
|
|
208
|
+
codeChallenge: string | null;
|
|
209
|
+
codeChallengeMethod: string | null;
|
|
210
|
+
/** OIDC Core §3.1.2.1 nonce — mirrored from the authorize query if present. */
|
|
211
|
+
nonce: string | null;
|
|
212
|
+
/**
|
|
213
|
+
* Subject the flow is bound to. Null until the first authenticated
|
|
214
|
+
* read/approve, then trust-on-first-use claims it for that user. Every
|
|
215
|
+
* subsequent read/approve/deny MUST match.
|
|
216
|
+
*
|
|
217
|
+
* Closes H6 (IDOR on `/oauth/flows/:flowId`): without this column, any
|
|
218
|
+
* authenticated user could fetch another user's RP state token or
|
|
219
|
+
* approve their flow.
|
|
220
|
+
*/
|
|
221
|
+
userId: string | null;
|
|
222
|
+
/** Set during atomic approve/deny claim; prevents concurrent double-submit. */
|
|
223
|
+
usedAt: Date | null;
|
|
224
|
+
expiresAt: Date;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Client authentication extracted from HTTP request (Basic auth or body params) */
|
|
228
|
+
/** Resolved client credentials parsed from a token-endpoint request. */
|
|
229
|
+
export interface ClientAuth {
|
|
230
|
+
clientId: string;
|
|
231
|
+
clientSecret: string;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Token endpoint request body (application/x-www-form-urlencoded) */
|
|
235
|
+
/** Body shape accepted by the OAuth `/token` endpoint. */
|
|
236
|
+
export interface TokenRequestBody {
|
|
237
|
+
grant_type: string;
|
|
238
|
+
code?: string;
|
|
239
|
+
redirect_uri?: string;
|
|
240
|
+
client_id?: string;
|
|
241
|
+
client_secret?: string;
|
|
242
|
+
code_verifier?: string;
|
|
243
|
+
scope?: string;
|
|
244
|
+
/** RFC 6749 §6 refresh-token grant payload. */
|
|
245
|
+
refresh_token?: string;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Authorization endpoint query params */
|
|
249
|
+
/** Query parameters accepted by the OAuth `/authorize` endpoint. */
|
|
250
|
+
export interface AuthorizeRequestParams {
|
|
251
|
+
client_id: string;
|
|
252
|
+
redirect_uri: string;
|
|
253
|
+
response_type: string;
|
|
254
|
+
scope?: string;
|
|
255
|
+
state?: string;
|
|
256
|
+
code_challenge?: string;
|
|
257
|
+
code_challenge_method?: string;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export interface OAuthMethods {
|
|
261
|
+
// Programmatic API
|
|
262
|
+
createClient: (data: { name: string; redirectUris: string[]; grantTypes: string[]; allowedScopes?: string[]; tokenEndpointAuthMethod?: TokenEndpointAuthMethod }) => Promise<{ clientId: string; clientSecret: string | null }>;
|
|
263
|
+
createAuthorizationCode: (params: { clientId: string; userId: string; redirectUri: string; scope?: string; codeChallenge?: string; codeChallengeMethod?: string; nonce?: string; authTime?: number }) => Promise<{ code: string }>;
|
|
264
|
+
exchangeCode: (params: { code: string; clientId: string; clientSecret?: string; redirectUri: string; codeVerifier?: string }) => Promise<{ accessToken: string; tokenType: string; expiresIn: number; scope?: string; refreshToken?: string; idToken?: string }>;
|
|
265
|
+
refreshTokenGrant: (params: { clientId: string; clientSecret?: string; refreshToken: string; scope?: string }) => Promise<{ accessToken: string; tokenType: string; expiresIn: number; refreshToken: string; scope?: string; idToken?: string }>;
|
|
266
|
+
clientCredentialsGrant: (params: { clientId: string; clientSecret: string; scope?: string }) => Promise<{ accessToken: string; tokenType: string; expiresIn: number }>;
|
|
267
|
+
revokeToken: (token: string) => Promise<void>;
|
|
268
|
+
introspectToken: (token: string) => Promise<{ active: boolean; clientId?: string; userId?: string; scope?: string }>;
|
|
269
|
+
createPendingFlow: (params: { clientId: string; redirectUri: string; scope?: string; state: string; codeChallenge?: string; codeChallengeMethod?: string; userId?: string }) => Promise<{ flowId: string }>;
|
|
270
|
+
/**
|
|
271
|
+
* Read a pending flow without consuming it. Throws if not found, expired,
|
|
272
|
+
* or (when `userId` is supplied) owned by a different user — in the
|
|
273
|
+
* "different user" case the error is `not_found` rather than `forbidden`
|
|
274
|
+
* to avoid confirming the flow's existence.
|
|
275
|
+
*/
|
|
276
|
+
getPendingFlow: (flowId: string | number, context?: { userId?: string }) => Promise<PendingFlowRecord>;
|
|
277
|
+
/** Read and delete a pending flow (single-use). Throws if not found or expired. */
|
|
278
|
+
resumePendingFlow: (flowId: string | number, context?: { userId?: string }) => Promise<PendingFlowRecord>;
|
|
279
|
+
getUserInfo: (token: string) => Promise<FortressUser | null>;
|
|
280
|
+
/** Rotate the RS256 id-token signing key and prune expired retired keys. */
|
|
281
|
+
rotateSigningKey: () => Promise<{ kid: string }>;
|
|
282
|
+
// HTTP handler methods (transport-agnostic, accept/return plain objects)
|
|
283
|
+
handleTokenRequest: (body: TokenRequestBody, clientAuth?: ClientAuth) => Promise<Record<string, unknown>>;
|
|
284
|
+
handleIntrospectRequest: (body: { token: string }, clientAuth: ClientAuth) => Promise<Record<string, unknown>>;
|
|
285
|
+
handleRevokeRequest: (body: { token: string }, clientAuth: ClientAuth) => Promise<void>;
|
|
286
|
+
/**
|
|
287
|
+
* OIDC Core 1.0 §5.3 userinfo endpoint. Returns the standard claims set
|
|
288
|
+
* (`sub`, `email`, `email_verified`, `name`, `preferred_username`,
|
|
289
|
+
* `updated_at`) gated by the access token's scope (§5.4), plus anything
|
|
290
|
+
* the {@link OAuthConfig.userinfoClaims} hook contributes. Throws 401
|
|
291
|
+
* for invalid / expired tokens (RFC 6750).
|
|
292
|
+
*/
|
|
293
|
+
handleUserInfoRequest: (bearerToken: string) => Promise<Record<string, unknown>>;
|
|
294
|
+
/**
|
|
295
|
+
* RFC 7517 / OIDC Discovery JWKS endpoint. Returns the AS's public
|
|
296
|
+
* verification keys (`{ keys: [...] }`). The first call materialises the
|
|
297
|
+
* active signing key if none has been generated yet; subsequent calls
|
|
298
|
+
* are cheap reads.
|
|
299
|
+
*/
|
|
300
|
+
handleJwksRequest: () => Promise<Record<string, unknown>>;
|
|
301
|
+
handleDiscovery: () => Record<string, unknown>;
|
|
302
|
+
/**
|
|
303
|
+
* Handle GET /oauth/authorize. Validates the query, creates a pending
|
|
304
|
+
* flow, and returns the URL to redirect the browser to (login if no
|
|
305
|
+
* user, consent if authenticated).
|
|
306
|
+
*/
|
|
307
|
+
handleAuthorizeRequest: (
|
|
308
|
+
query: Record<string, string | undefined>,
|
|
309
|
+
context: { userId?: string },
|
|
310
|
+
) => Promise<{ redirectUrl: string; flowId: string }>;
|
|
311
|
+
/**
|
|
312
|
+
* Handle GET /oauth/flows/:flowId. Returns the consent metadata the host
|
|
313
|
+
* app's consent page renders. Throws if the flow is unknown or expired.
|
|
314
|
+
*
|
|
315
|
+
* Requires the request principal to own the flow — the first authenticated
|
|
316
|
+
* read claims the flow for `context.userId` (trust-on-first-use); every
|
|
317
|
+
* subsequent read MUST come from the same user or the response is 404.
|
|
318
|
+
*/
|
|
319
|
+
handleGetFlow: (
|
|
320
|
+
flowId: string | number,
|
|
321
|
+
context: { userId: string },
|
|
322
|
+
) => Promise<{
|
|
323
|
+
flowId: string;
|
|
324
|
+
client: { clientId: string; name: string };
|
|
325
|
+
redirectUri: string;
|
|
326
|
+
scopes: string[];
|
|
327
|
+
state: string;
|
|
328
|
+
}>;
|
|
329
|
+
/**
|
|
330
|
+
* Handle POST /oauth/flows/:flowId/approve. Issues an authorization code
|
|
331
|
+
* for the authenticated user, deletes the pending flow, and returns the
|
|
332
|
+
* URL the browser should be sent back to (the OAuth client's redirect_uri
|
|
333
|
+
* with `?code=...&state=...`).
|
|
334
|
+
*/
|
|
335
|
+
handleApproveFlow: (
|
|
336
|
+
flowId: string | number,
|
|
337
|
+
context: { userId: string; authTimeSeconds?: number },
|
|
338
|
+
) => Promise<{ redirectUrl: string }>;
|
|
339
|
+
/**
|
|
340
|
+
* Handle POST /oauth/flows/:flowId/deny. Deletes the pending flow and
|
|
341
|
+
* returns the URL the browser should be sent back to (redirect_uri with
|
|
342
|
+
* `?error=access_denied&state=...`).
|
|
343
|
+
*
|
|
344
|
+
* Requires the request principal to own the flow.
|
|
345
|
+
*/
|
|
346
|
+
handleDenyFlow: (
|
|
347
|
+
flowId: string | number,
|
|
348
|
+
context: { userId: string },
|
|
349
|
+
) => Promise<{ redirectUrl: string }>;
|
|
350
|
+
resolveTokenPermissions: (token: string) => Promise<{ resource: string; action: string }[]>;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* OAuth 2.0 server plugin factory. Returns a {@link FortressPlugin} that
|
|
354
|
+
* implements the authorization-code grant (with PKCE) and client-credentials
|
|
355
|
+
* grant, persists clients and tokens, and exposes `/authorize` and `/token`
|
|
356
|
+
* endpoints when mounted on a framework adapter.
|
|
357
|
+
*/
|
|
358
|
+
export function oauth(config: OAuthConfig = {}): FortressPlugin & { readonly name: 'oauth' } {
|
|
359
|
+
const authCodeExpiry = config.authCodeExpirySeconds ?? 600;
|
|
360
|
+
const pendingFlowExpiry = config.pendingFlowExpirySeconds ?? 600;
|
|
361
|
+
const accessTokenExpiry = config.accessTokenExpirySeconds ?? 3600;
|
|
362
|
+
// 30 days. RFC 9700 §2.2.2 doesn't mandate a duration; this matches the
|
|
363
|
+
// default fortress core uses for user-session refresh tokens.
|
|
364
|
+
const refreshTokenExpiry = config.refreshTokenExpirySeconds ?? 30 * 24 * 3600;
|
|
365
|
+
const refreshEnabled = refreshTokenExpiry > 0;
|
|
366
|
+
const idTokenExpiry = config.idTokenExpirySeconds ?? 3600;
|
|
367
|
+
const signingKeyGraceSeconds = config.signingKeyGraceSeconds ?? idTokenExpiry;
|
|
368
|
+
const scopePermissionMap = config.scopePermissionMap;
|
|
369
|
+
const issuerUrl = config.issuerUrl ?? 'https://localhost';
|
|
370
|
+
|
|
371
|
+
// RFC 8414 §2 + RFC 9700 §4.16: production issuer URL MUST be HTTPS.
|
|
372
|
+
// Dev / test (`NODE_ENV !== 'production'`) keeps localhost-HTTP working
|
|
373
|
+
// for the example app and integration suites.
|
|
374
|
+
if (
|
|
375
|
+
typeof process !== 'undefined'
|
|
376
|
+
&& process.env?.NODE_ENV === 'production'
|
|
377
|
+
&& !issuerUrl.startsWith('https://')
|
|
378
|
+
) {
|
|
379
|
+
throw new Error(
|
|
380
|
+
`[fortress/oauth] issuerUrl must use https:// in production (got: ${issuerUrl})`,
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
name: 'oauth',
|
|
386
|
+
|
|
387
|
+
models: [
|
|
388
|
+
{
|
|
389
|
+
name: 'oauth_client',
|
|
390
|
+
fields: {
|
|
391
|
+
id: { type: 'number', required: true },
|
|
392
|
+
clientId: { type: 'string', required: true, unique: true },
|
|
393
|
+
// For public clients (`tokenEndpointAuthMethod === 'none'`) the
|
|
394
|
+
// hash is set to the empty string — the column is required by the
|
|
395
|
+
// adapter for backwards compat, but never compared against an
|
|
396
|
+
// inbound secret.
|
|
397
|
+
clientSecretHash: { type: 'string', required: true },
|
|
398
|
+
name: { type: 'string', required: true },
|
|
399
|
+
redirectUris: { type: 'string', required: true },
|
|
400
|
+
grantTypes: { type: 'string', required: true },
|
|
401
|
+
// RFC 6749 §3.3 / RFC 9700 §2.2.1 per-client scope allow-list.
|
|
402
|
+
// Optional for backwards compatibility with legacy v0 clients
|
|
403
|
+
// created before this column existed.
|
|
404
|
+
allowedScopes: { type: 'string' },
|
|
405
|
+
// RFC 6749 §2.1 client type. Optional — absent on legacy clients,
|
|
406
|
+
// which are treated as confidential (`client_secret_basic`).
|
|
407
|
+
tokenEndpointAuthMethod: { type: 'string' },
|
|
408
|
+
createdAt: { type: 'date', required: true },
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
{
|
|
412
|
+
name: 'oauth_authorization_code',
|
|
413
|
+
fields: {
|
|
414
|
+
id: { type: 'number', required: true },
|
|
415
|
+
code: { type: 'string', required: true, unique: true },
|
|
416
|
+
clientId: { type: 'string', required: true },
|
|
417
|
+
userId: { type: 'number', required: true },
|
|
418
|
+
redirectUri: { type: 'string', required: true },
|
|
419
|
+
scope: { type: 'string' },
|
|
420
|
+
codeChallenge: { type: 'string' },
|
|
421
|
+
codeChallengeMethod: { type: 'string' },
|
|
422
|
+
// OIDC Core §3.1.2.1 nonce — echoed into the id_token verbatim.
|
|
423
|
+
nonce: { type: 'string' },
|
|
424
|
+
// Unix seconds; OIDC `auth_time` claim source.
|
|
425
|
+
authTime: { type: 'number' },
|
|
426
|
+
expiresAt: { type: 'date', required: true },
|
|
427
|
+
usedAt: { type: 'date' },
|
|
428
|
+
createdAt: { type: 'date', required: true },
|
|
429
|
+
},
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
name: 'oauth_access_token',
|
|
433
|
+
fields: {
|
|
434
|
+
id: { type: 'number', required: true },
|
|
435
|
+
token: { type: 'string', required: true, unique: true },
|
|
436
|
+
clientId: { type: 'string', required: true },
|
|
437
|
+
userId: { type: 'number' },
|
|
438
|
+
scope: { type: 'string' },
|
|
439
|
+
expiresAt: { type: 'date', required: true },
|
|
440
|
+
createdAt: { type: 'date', required: true },
|
|
441
|
+
},
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
name: 'oauth_refresh_token',
|
|
445
|
+
fields: {
|
|
446
|
+
id: { type: 'number', required: true },
|
|
447
|
+
token: { type: 'string', required: true, unique: true },
|
|
448
|
+
familyId: { type: 'string', required: true },
|
|
449
|
+
clientId: { type: 'string', required: true },
|
|
450
|
+
userId: { type: 'number', required: true },
|
|
451
|
+
scope: { type: 'string' },
|
|
452
|
+
issuedAt: { type: 'date', required: true },
|
|
453
|
+
expiresAt: { type: 'date', required: true },
|
|
454
|
+
usedAt: { type: 'date' },
|
|
455
|
+
parentId: { type: 'number' },
|
|
456
|
+
createdAt: { type: 'date', required: true },
|
|
457
|
+
},
|
|
458
|
+
},
|
|
459
|
+
{
|
|
460
|
+
name: 'oauth_pending_flow',
|
|
461
|
+
fields: {
|
|
462
|
+
id: { type: 'number', required: true },
|
|
463
|
+
flowId: { type: 'string', required: true, unique: true },
|
|
464
|
+
clientId: { type: 'string', required: true },
|
|
465
|
+
redirectUri: { type: 'string', required: true },
|
|
466
|
+
scope: { type: 'string' },
|
|
467
|
+
state: { type: 'string', required: true },
|
|
468
|
+
codeChallenge: { type: 'string' },
|
|
469
|
+
codeChallengeMethod: { type: 'string' },
|
|
470
|
+
nonce: { type: 'string' },
|
|
471
|
+
// H6 fix: subject the flow is bound to. Nullable for back-compat
|
|
472
|
+
// and to model the trust-on-first-use claim.
|
|
473
|
+
userId: { type: 'number' },
|
|
474
|
+
// Single-use approval/denial claim. Older rows may have null.
|
|
475
|
+
usedAt: { type: 'date' },
|
|
476
|
+
expiresAt: { type: 'date', required: true },
|
|
477
|
+
createdAt: { type: 'date', required: true },
|
|
478
|
+
},
|
|
479
|
+
},
|
|
480
|
+
{
|
|
481
|
+
// OIDC Core §2 / RFC 7517 signing key persistence. RS256 only for
|
|
482
|
+
// now; the row stores both public and private JWKs as JSON. Active
|
|
483
|
+
// key has rotatedAt == null.
|
|
484
|
+
name: 'oauth_signing_key',
|
|
485
|
+
fields: {
|
|
486
|
+
id: { type: 'number', required: true },
|
|
487
|
+
kid: { type: 'string', required: true, unique: true },
|
|
488
|
+
alg: { type: 'string', required: true },
|
|
489
|
+
publicJwk: { type: 'string', required: true },
|
|
490
|
+
privateJwk: { type: 'string', required: true },
|
|
491
|
+
createdAt: { type: 'date', required: true },
|
|
492
|
+
rotatedAt: { type: 'date' },
|
|
493
|
+
},
|
|
494
|
+
},
|
|
495
|
+
],
|
|
496
|
+
|
|
497
|
+
methods: ctx => ({
|
|
498
|
+
/**
|
|
499
|
+
* Register a new OAuth client.
|
|
500
|
+
* Returns the clientId and raw clientSecret (shown once, stored as hash).
|
|
501
|
+
*/
|
|
502
|
+
async createClient(data: {
|
|
503
|
+
name: string;
|
|
504
|
+
redirectUris: string[];
|
|
505
|
+
grantTypes: string[];
|
|
506
|
+
/**
|
|
507
|
+
* RFC 6749 §3.3 / RFC 9700 §2.2.1 scope allow-list. Optional — omit
|
|
508
|
+
* to allow whatever scope the client requests (legacy v0
|
|
509
|
+
* behaviour); pass `[]` to deny all; pass `['openid', 'email']` to
|
|
510
|
+
* gate to a known set.
|
|
511
|
+
*/
|
|
512
|
+
allowedScopes?: string[];
|
|
513
|
+
/**
|
|
514
|
+
* RFC 6749 §2.1 / OIDC Discovery client authentication method.
|
|
515
|
+
* Defaults to `'client_secret_basic'` (confidential client). Pass
|
|
516
|
+
* `'none'` for public clients (SPAs, native apps — RFC 8252) which
|
|
517
|
+
* authenticate via PKCE alone; no secret is generated.
|
|
518
|
+
*/
|
|
519
|
+
tokenEndpointAuthMethod?: TokenEndpointAuthMethod;
|
|
520
|
+
}): Promise<{ clientId: string; clientSecret: string | null }> {
|
|
521
|
+
const isPublic = data.tokenEndpointAuthMethod === 'none';
|
|
522
|
+
const { raw: clientSecret, hash: clientSecretHash } = isPublic
|
|
523
|
+
? { raw: '', hash: '' }
|
|
524
|
+
: await generateRefreshToken();
|
|
525
|
+
const { raw: clientIdRaw } = await generateRefreshToken();
|
|
526
|
+
const clientId = clientIdRaw.slice(0, 24); // Shorter, readable client ID
|
|
527
|
+
|
|
528
|
+
await ctx.db.create({
|
|
529
|
+
model: 'oauth_client',
|
|
530
|
+
data: {
|
|
531
|
+
clientId,
|
|
532
|
+
clientSecretHash,
|
|
533
|
+
name: data.name,
|
|
534
|
+
redirectUris: JSON.stringify(data.redirectUris),
|
|
535
|
+
grantTypes: JSON.stringify(data.grantTypes),
|
|
536
|
+
allowedScopes: data.allowedScopes ? JSON.stringify(data.allowedScopes) : null,
|
|
537
|
+
tokenEndpointAuthMethod: data.tokenEndpointAuthMethod ?? 'client_secret_basic',
|
|
538
|
+
},
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
return {
|
|
542
|
+
clientId,
|
|
543
|
+
// Public clients have no secret — returning `null` makes the
|
|
544
|
+
// contract explicit at compile time.
|
|
545
|
+
clientSecret: isPublic ? null : clientSecret,
|
|
546
|
+
};
|
|
547
|
+
},
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Generate an authorization code for a user+client.
|
|
551
|
+
* Used after the user authenticates and authorizes the client.
|
|
552
|
+
*/
|
|
553
|
+
async createAuthorizationCode(params: {
|
|
554
|
+
clientId: string;
|
|
555
|
+
userId: string;
|
|
556
|
+
redirectUri: string;
|
|
557
|
+
scope?: string;
|
|
558
|
+
codeChallenge?: string;
|
|
559
|
+
codeChallengeMethod?: string;
|
|
560
|
+
/** OIDC Core §3.1.2.1 nonce; echoed verbatim into the id_token. */
|
|
561
|
+
nonce?: string;
|
|
562
|
+
/** Unix seconds when the user authenticated; OIDC `auth_time`. */
|
|
563
|
+
authTime?: number;
|
|
564
|
+
}): Promise<{ code: string }> {
|
|
565
|
+
// Validate client
|
|
566
|
+
const client = await ctx.db.findOne<OAuthClientRecord>({
|
|
567
|
+
model: 'oauth_client',
|
|
568
|
+
where: [{ field: 'clientId', operator: '=', value: params.clientId }],
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
if (!client)
|
|
572
|
+
throw Errors.oauth('invalid_request', 'Invalid client_id');
|
|
573
|
+
|
|
574
|
+
const uris = JSON.parse(client.redirectUris) as string[];
|
|
575
|
+
if (!uris.some(r => matchRedirectUri(r, params.redirectUri)))
|
|
576
|
+
throw Errors.oauth('invalid_request', 'Invalid redirect_uri');
|
|
577
|
+
|
|
578
|
+
// P1.3: enforce PKCE at code-issuance time for public clients. The
|
|
579
|
+
// exchange path also rejects a public-client code with no challenge,
|
|
580
|
+
// but failing here keeps a binding-less code from ever being stored
|
|
581
|
+
// and surfaces the error at the point of mint.
|
|
582
|
+
const isPublic = client.tokenEndpointAuthMethod === 'none';
|
|
583
|
+
if (isPublic && (!params.codeChallenge || params.codeChallengeMethod !== 'S256'))
|
|
584
|
+
throw Errors.oauth('invalid_request', 'PKCE (S256) is required for public clients');
|
|
585
|
+
|
|
586
|
+
const { raw: code, hash: codeHash } = await generateRefreshToken();
|
|
587
|
+
const expiresAt = new Date(Date.now() + authCodeExpiry * 1000);
|
|
588
|
+
|
|
589
|
+
await ctx.db.create({
|
|
590
|
+
model: 'oauth_authorization_code',
|
|
591
|
+
data: {
|
|
592
|
+
code: codeHash,
|
|
593
|
+
clientId: params.clientId,
|
|
594
|
+
userId: params.userId,
|
|
595
|
+
redirectUri: params.redirectUri,
|
|
596
|
+
scope: params.scope ?? null,
|
|
597
|
+
codeChallenge: params.codeChallenge ?? null,
|
|
598
|
+
codeChallengeMethod: params.codeChallengeMethod ?? null,
|
|
599
|
+
nonce: params.nonce ?? null,
|
|
600
|
+
authTime: params.authTime ?? null,
|
|
601
|
+
expiresAt,
|
|
602
|
+
usedAt: null,
|
|
603
|
+
},
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
return { code };
|
|
607
|
+
},
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Exchange an authorization code for an access token.
|
|
611
|
+
*/
|
|
612
|
+
async exchangeCode(params: {
|
|
613
|
+
code: string;
|
|
614
|
+
clientId: string;
|
|
615
|
+
/**
|
|
616
|
+
* Required for confidential clients; ignored for public clients
|
|
617
|
+
* (`tokenEndpointAuthMethod === 'none'`), which authenticate via
|
|
618
|
+
* PKCE alone (RFC 8252 §8.6).
|
|
619
|
+
*/
|
|
620
|
+
clientSecret?: string;
|
|
621
|
+
redirectUri: string;
|
|
622
|
+
codeVerifier?: string;
|
|
623
|
+
}): Promise<{ accessToken: string; tokenType: string; expiresIn: number; scope?: string; refreshToken?: string; idToken?: string }> {
|
|
624
|
+
// Validate client credentials
|
|
625
|
+
const client = await ctx.db.findOne<OAuthClientRecord>({
|
|
626
|
+
model: 'oauth_client',
|
|
627
|
+
where: [{ field: 'clientId', operator: '=', value: params.clientId }],
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
if (!client)
|
|
631
|
+
throw Errors.oauth('invalid_client', 'Invalid client credentials');
|
|
632
|
+
|
|
633
|
+
const isPublic = client.tokenEndpointAuthMethod === 'none';
|
|
634
|
+
if (!isPublic) {
|
|
635
|
+
if (!params.clientSecret)
|
|
636
|
+
throw Errors.oauth('invalid_client', 'Client secret required');
|
|
637
|
+
const secretValid = timingSafeEqualHex(
|
|
638
|
+
await hashToken(params.clientSecret),
|
|
639
|
+
client.clientSecretHash,
|
|
640
|
+
);
|
|
641
|
+
if (!secretValid)
|
|
642
|
+
throw Errors.oauth('invalid_client', 'Invalid client credentials');
|
|
643
|
+
}
|
|
644
|
+
else if (params.clientSecret) {
|
|
645
|
+
// RFC 6749 §2.3.1: a public client MUST NOT present credentials.
|
|
646
|
+
throw Errors.oauth('invalid_client', 'Public clients must not present a client_secret');
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// RFC 6749 §2.1 / §5.2: enforce per-client `grant_types` registration.
|
|
650
|
+
// Refresh + client-credentials grants check the same thing; we now
|
|
651
|
+
// also gate authorization_code here so a confidential client that
|
|
652
|
+
// was registered with `client_credentials` only can't silently
|
|
653
|
+
// exchange a smuggled code (M9).
|
|
654
|
+
const grantTypes = JSON.parse(client.grantTypes) as string[];
|
|
655
|
+
if (!grantTypes.includes('authorization_code'))
|
|
656
|
+
throw Errors.oauth('unauthorized_client', 'Client does not support authorization_code grant');
|
|
657
|
+
|
|
658
|
+
// P1.1: do lookup, CAS-claim, and token issuance inside a single
|
|
659
|
+
// transaction so two concurrent /token requests for the same code
|
|
660
|
+
// cannot both mint tokens. The conditional `update` (`usedAt is
|
|
661
|
+
// null`) is the compare-and-set: only one caller flips the row, the
|
|
662
|
+
// other gets `null` and is rejected with `invalid_grant`.
|
|
663
|
+
const codeHash = await hashToken(params.code);
|
|
664
|
+
return ctx.db.transaction(async (tx) => {
|
|
665
|
+
const authCode = await tx.findOne<AuthCodeRecord>({
|
|
666
|
+
model: 'oauth_authorization_code',
|
|
667
|
+
where: [{ field: 'code', operator: '=', value: codeHash }],
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
if (!authCode)
|
|
671
|
+
throw Errors.oauth('invalid_grant', 'Invalid authorization code');
|
|
672
|
+
|
|
673
|
+
if (authCode.expiresAt < new Date())
|
|
674
|
+
throw Errors.oauth('invalid_grant', 'Authorization code expired');
|
|
675
|
+
|
|
676
|
+
if (authCode.clientId !== params.clientId)
|
|
677
|
+
throw Errors.oauth('invalid_grant', 'Client mismatch');
|
|
678
|
+
|
|
679
|
+
if (authCode.redirectUri !== params.redirectUri)
|
|
680
|
+
throw Errors.oauth('invalid_grant', 'Redirect URI mismatch');
|
|
681
|
+
|
|
682
|
+
// RFC 7636 §4.4.1 + RFC 9700 §2.1.1 (P1.3): PKCE is mandatory for
|
|
683
|
+
// public clients. A public-client code MUST carry a challenge —
|
|
684
|
+
// missing state here is a server-side bug, not a leniency.
|
|
685
|
+
if (isPublic && !authCode.codeChallenge)
|
|
686
|
+
throw Errors.oauth('invalid_grant', 'PKCE required for public clients');
|
|
687
|
+
|
|
688
|
+
// Verify PKCE — RFC 7636 §4.6.
|
|
689
|
+
if (authCode.codeChallenge && authCode.codeChallengeMethod) {
|
|
690
|
+
if (!params.codeVerifier)
|
|
691
|
+
throw Errors.oauth('invalid_grant', 'code_verifier required');
|
|
692
|
+
|
|
693
|
+
const valid = await verifyCodeChallenge(
|
|
694
|
+
params.codeVerifier,
|
|
695
|
+
authCode.codeChallenge,
|
|
696
|
+
authCode.codeChallengeMethod,
|
|
697
|
+
);
|
|
698
|
+
|
|
699
|
+
if (!valid)
|
|
700
|
+
throw Errors.oauth('invalid_grant', 'Invalid code_verifier');
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// CAS claim: mark the code used iff it hasn't been used yet. If
|
|
704
|
+
// a concurrent exchange already won the race the conditional
|
|
705
|
+
// update returns null and we reject this caller — the standard
|
|
706
|
+
// RFC 6749 §4.1.2 single-use enforcement, now actually atomic.
|
|
707
|
+
const claimed = await tx.update<AuthCodeRecord>({
|
|
708
|
+
model: 'oauth_authorization_code',
|
|
709
|
+
where: [
|
|
710
|
+
{ field: 'id', operator: '=', value: authCode.id },
|
|
711
|
+
{ field: 'usedAt', operator: 'isNull', value: null },
|
|
712
|
+
],
|
|
713
|
+
data: { usedAt: new Date() },
|
|
714
|
+
});
|
|
715
|
+
if (!claimed)
|
|
716
|
+
throw Errors.oauth('invalid_grant', 'Authorization code already used');
|
|
717
|
+
|
|
718
|
+
// Issue access token
|
|
719
|
+
const { raw: tokenRaw, hash: tokenHash } = await generateRefreshToken();
|
|
720
|
+
const expiresAt = new Date(Date.now() + accessTokenExpiry * 1000);
|
|
721
|
+
|
|
722
|
+
await tx.create({
|
|
723
|
+
model: 'oauth_access_token',
|
|
724
|
+
data: {
|
|
725
|
+
token: tokenHash,
|
|
726
|
+
clientId: params.clientId,
|
|
727
|
+
userId: authCode.userId,
|
|
728
|
+
scope: authCode.scope,
|
|
729
|
+
expiresAt,
|
|
730
|
+
},
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
// OIDC Core §3.1.3.7: id_token alongside the access token when the
|
|
734
|
+
// request used scope=openid. Resolve user + active signing key.
|
|
735
|
+
let idToken: string | undefined;
|
|
736
|
+
const scopes = (authCode.scope ?? '').split(' ').filter(Boolean);
|
|
737
|
+
if (scopes.includes('openid')) {
|
|
738
|
+
const user = await tx.findOne<FortressUser>({
|
|
739
|
+
model: 'user',
|
|
740
|
+
where: [{ field: 'id', operator: '=', value: authCode.userId }],
|
|
741
|
+
});
|
|
742
|
+
if (user) {
|
|
743
|
+
const signingKey = await getActiveSigningKey(tx);
|
|
744
|
+
idToken = await issueIdToken({
|
|
745
|
+
user,
|
|
746
|
+
clientId: params.clientId,
|
|
747
|
+
issuerUrl,
|
|
748
|
+
ttlSeconds: idTokenExpiry,
|
|
749
|
+
nonce: authCode.nonce ?? undefined,
|
|
750
|
+
authTimeSeconds: authCode.authTime ?? Math.floor(Date.now() / 1000),
|
|
751
|
+
scope: authCode.scope,
|
|
752
|
+
signingKey,
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// RFC 6749 §6 + RFC 9700 §2.2.2: issue a refresh token alongside
|
|
758
|
+
// the access token at the start of a new rotation family. Public
|
|
759
|
+
// clients (RFC 8252) get them too; rotation + replay detection is
|
|
760
|
+
// the mitigation, not client confidentiality.
|
|
761
|
+
let refreshTokenRaw: string | undefined;
|
|
762
|
+
if (refreshEnabled) {
|
|
763
|
+
const { raw, hash } = await generateRefreshToken();
|
|
764
|
+
refreshTokenRaw = raw;
|
|
765
|
+
await tx.create({
|
|
766
|
+
model: 'oauth_refresh_token',
|
|
767
|
+
data: {
|
|
768
|
+
token: hash,
|
|
769
|
+
familyId: generateTokenFamily(),
|
|
770
|
+
clientId: params.clientId,
|
|
771
|
+
userId: authCode.userId,
|
|
772
|
+
scope: authCode.scope ?? null,
|
|
773
|
+
issuedAt: new Date(),
|
|
774
|
+
expiresAt: new Date(Date.now() + refreshTokenExpiry * 1000),
|
|
775
|
+
usedAt: null,
|
|
776
|
+
parentId: null,
|
|
777
|
+
},
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
return {
|
|
782
|
+
accessToken: tokenRaw,
|
|
783
|
+
tokenType: 'Bearer',
|
|
784
|
+
expiresIn: accessTokenExpiry,
|
|
785
|
+
scope: authCode.scope ?? undefined,
|
|
786
|
+
...(refreshTokenRaw ? { refreshToken: refreshTokenRaw } : {}),
|
|
787
|
+
...(idToken ? { idToken } : {}),
|
|
788
|
+
};
|
|
789
|
+
});
|
|
790
|
+
},
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* RFC 6749 §6 refresh-token grant with RFC 9700 §2.2.2 rotation.
|
|
794
|
+
*
|
|
795
|
+
* Flow:
|
|
796
|
+
* 1. Look up the inbound refresh token by hash.
|
|
797
|
+
* 2. If it's already been used (`usedAt != null`), this is a replay
|
|
798
|
+
* attack — delete every refresh token in the same family and
|
|
799
|
+
* throw `invalid_grant`. The legitimate client's most recent
|
|
800
|
+
* refresh dies with the family, so the user is forced to
|
|
801
|
+
* re-authenticate, but the attacker's stolen token is now
|
|
802
|
+
* useless.
|
|
803
|
+
* 3. Otherwise mark the old token used, mint a new access token +
|
|
804
|
+
* a new refresh token in the same family, link the new refresh
|
|
805
|
+
* via `parentId`, and return the pair.
|
|
806
|
+
*/
|
|
807
|
+
async refreshTokenGrant(params: {
|
|
808
|
+
clientId: string;
|
|
809
|
+
clientSecret?: string;
|
|
810
|
+
refreshToken: string;
|
|
811
|
+
scope?: string;
|
|
812
|
+
}): Promise<{ accessToken: string; tokenType: string; expiresIn: number; refreshToken: string; scope?: string; idToken?: string }> {
|
|
813
|
+
const client = await ctx.db.findOne<OAuthClientRecord>({
|
|
814
|
+
model: 'oauth_client',
|
|
815
|
+
where: [{ field: 'clientId', operator: '=', value: params.clientId }],
|
|
816
|
+
});
|
|
817
|
+
if (!client)
|
|
818
|
+
throw Errors.oauth('invalid_client', 'Invalid client credentials');
|
|
819
|
+
|
|
820
|
+
const isPublic = client.tokenEndpointAuthMethod === 'none';
|
|
821
|
+
if (!isPublic) {
|
|
822
|
+
if (!params.clientSecret)
|
|
823
|
+
throw Errors.oauth('invalid_client', 'Client secret required');
|
|
824
|
+
const secretValid = timingSafeEqualHex(
|
|
825
|
+
await hashToken(params.clientSecret),
|
|
826
|
+
client.clientSecretHash,
|
|
827
|
+
);
|
|
828
|
+
if (!secretValid)
|
|
829
|
+
throw Errors.oauth('invalid_client', 'Invalid client credentials');
|
|
830
|
+
}
|
|
831
|
+
else if (params.clientSecret) {
|
|
832
|
+
// Mirror exchangeCode: a public client MUST NOT present credentials
|
|
833
|
+
// even on /refresh (RFC 6749 §2.3.1 + M9 follow-up).
|
|
834
|
+
throw Errors.oauth('invalid_client', 'Public clients must not present a client_secret');
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// Per-client grant_types enforcement (M9).
|
|
838
|
+
const refreshGrantTypes = JSON.parse(client.grantTypes) as string[];
|
|
839
|
+
if (!refreshGrantTypes.includes('refresh_token'))
|
|
840
|
+
throw Errors.oauth('unauthorized_client', 'Client does not support refresh_token grant');
|
|
841
|
+
|
|
842
|
+
const tokenHash = await hashToken(params.refreshToken);
|
|
843
|
+
|
|
844
|
+
const grant = await ctx.db.transaction(async (tx) => {
|
|
845
|
+
const record = await tx.findOne<RefreshTokenRecord>({
|
|
846
|
+
model: 'oauth_refresh_token',
|
|
847
|
+
where: [{ field: 'token', operator: '=', value: tokenHash }],
|
|
848
|
+
});
|
|
849
|
+
if (!record)
|
|
850
|
+
throw Errors.oauth('invalid_grant', 'Invalid refresh token');
|
|
851
|
+
|
|
852
|
+
if (record.clientId !== params.clientId)
|
|
853
|
+
throw Errors.oauth('invalid_grant', 'Refresh token client mismatch');
|
|
854
|
+
|
|
855
|
+
if (record.expiresAt < new Date())
|
|
856
|
+
throw Errors.oauth('invalid_grant', 'Refresh token expired');
|
|
857
|
+
|
|
858
|
+
// RFC 9700 §2.2.2 replay detection. Reuse of a rotated token means
|
|
859
|
+
// an attacker stole it, OR the legitimate client missed the
|
|
860
|
+
// response and is retrying. Either way, the family is compromised
|
|
861
|
+
// and must die.
|
|
862
|
+
if (record.usedAt) {
|
|
863
|
+
await tx.delete({
|
|
864
|
+
model: 'oauth_refresh_token',
|
|
865
|
+
where: [{ field: 'familyId', operator: '=', value: record.familyId }],
|
|
866
|
+
});
|
|
867
|
+
return { replayDetected: true as const };
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// RFC 6749 §6: refreshed scope MUST NOT include any scope not
|
|
871
|
+
// originally granted. We allow narrowing only.
|
|
872
|
+
let scope = record.scope;
|
|
873
|
+
if (params.scope) {
|
|
874
|
+
const requested = params.scope.split(' ').filter(Boolean);
|
|
875
|
+
const original = (record.scope ?? '').split(' ').filter(Boolean);
|
|
876
|
+
const originalSet = new Set(original);
|
|
877
|
+
const widened = requested.find(s => !originalSet.has(s));
|
|
878
|
+
if (widened) {
|
|
879
|
+
throw Errors.oauth('invalid_scope', `Refreshed scope cannot widen: ${widened}`);
|
|
880
|
+
}
|
|
881
|
+
scope = requested.join(' ');
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// Atomic compare-and-set claim: only one concurrent caller can mark
|
|
885
|
+
// a never-used refresh token as used.
|
|
886
|
+
const claimed = await tx.update<RefreshTokenRecord>({
|
|
887
|
+
model: 'oauth_refresh_token',
|
|
888
|
+
where: [
|
|
889
|
+
{ field: 'id', operator: '=', value: record.id },
|
|
890
|
+
{ field: 'usedAt', operator: 'isNull', value: null },
|
|
891
|
+
],
|
|
892
|
+
data: { usedAt: new Date() },
|
|
893
|
+
});
|
|
894
|
+
if (!claimed) {
|
|
895
|
+
await tx.delete({
|
|
896
|
+
model: 'oauth_refresh_token',
|
|
897
|
+
where: [{ field: 'familyId', operator: '=', value: record.familyId }],
|
|
898
|
+
});
|
|
899
|
+
return { replayDetected: true as const };
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// Mint the new access token.
|
|
903
|
+
const { raw: accessRaw, hash: accessHash } = await generateRefreshToken();
|
|
904
|
+
await tx.create({
|
|
905
|
+
model: 'oauth_access_token',
|
|
906
|
+
data: {
|
|
907
|
+
token: accessHash,
|
|
908
|
+
clientId: params.clientId,
|
|
909
|
+
userId: record.userId,
|
|
910
|
+
scope,
|
|
911
|
+
expiresAt: new Date(Date.now() + accessTokenExpiry * 1000),
|
|
912
|
+
},
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
// OIDC Core allows a refreshed response to carry a fresh id_token
|
|
916
|
+
// when the original grant included `openid`. Many strict OIDC RPs
|
|
917
|
+
// expect this so their session identity view stays current.
|
|
918
|
+
let idToken: string | undefined;
|
|
919
|
+
if ((scope ?? '').split(' ').filter(Boolean).includes('openid')) {
|
|
920
|
+
const user = await tx.findOne<FortressUser>({
|
|
921
|
+
model: 'user',
|
|
922
|
+
where: [{ field: 'id', operator: '=', value: record.userId }],
|
|
923
|
+
});
|
|
924
|
+
if (user) {
|
|
925
|
+
const signingKey = await getActiveSigningKey(tx);
|
|
926
|
+
idToken = await issueIdToken({
|
|
927
|
+
user,
|
|
928
|
+
clientId: params.clientId,
|
|
929
|
+
issuerUrl,
|
|
930
|
+
ttlSeconds: idTokenExpiry,
|
|
931
|
+
// The original authentication time is not stored on the
|
|
932
|
+
// refresh family. Omit auth_time rather than fabricating a
|
|
933
|
+
// fresh authentication event at token rotation.
|
|
934
|
+
scope,
|
|
935
|
+
signingKey,
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// Mint the new refresh token in the same family.
|
|
941
|
+
const { raw: refreshRaw, hash: refreshHash } = await generateRefreshToken();
|
|
942
|
+
await tx.create({
|
|
943
|
+
model: 'oauth_refresh_token',
|
|
944
|
+
data: {
|
|
945
|
+
token: refreshHash,
|
|
946
|
+
familyId: record.familyId,
|
|
947
|
+
clientId: params.clientId,
|
|
948
|
+
userId: record.userId,
|
|
949
|
+
scope,
|
|
950
|
+
issuedAt: new Date(),
|
|
951
|
+
expiresAt: new Date(Date.now() + refreshTokenExpiry * 1000),
|
|
952
|
+
usedAt: null,
|
|
953
|
+
parentId: record.id,
|
|
954
|
+
},
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
return {
|
|
958
|
+
accessToken: accessRaw,
|
|
959
|
+
tokenType: 'Bearer',
|
|
960
|
+
expiresIn: accessTokenExpiry,
|
|
961
|
+
refreshToken: refreshRaw,
|
|
962
|
+
...(scope ? { scope } : {}),
|
|
963
|
+
...(idToken ? { idToken } : {}),
|
|
964
|
+
};
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
if ('replayDetected' in grant)
|
|
968
|
+
throw Errors.oauth('invalid_grant', 'Refresh token reuse detected; family revoked');
|
|
969
|
+
return grant;
|
|
970
|
+
},
|
|
971
|
+
|
|
972
|
+
/**
|
|
973
|
+
* Client credentials grant — issue token for a service client (no user).
|
|
974
|
+
*/
|
|
975
|
+
async clientCredentialsGrant(params: {
|
|
976
|
+
clientId: string;
|
|
977
|
+
clientSecret: string;
|
|
978
|
+
scope?: string;
|
|
979
|
+
}): Promise<{ accessToken: string; tokenType: string; expiresIn: number }> {
|
|
980
|
+
const client = await ctx.db.findOne<OAuthClientRecord>({
|
|
981
|
+
model: 'oauth_client',
|
|
982
|
+
where: [{ field: 'clientId', operator: '=', value: params.clientId }],
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
if (!client)
|
|
986
|
+
throw Errors.oauth('invalid_client', 'Invalid client credentials');
|
|
987
|
+
|
|
988
|
+
// RFC 6749 §4.4 client_credentials inherently requires a secret.
|
|
989
|
+
// Public clients have no secret, so they cannot use this grant.
|
|
990
|
+
if (client.tokenEndpointAuthMethod === 'none')
|
|
991
|
+
throw Errors.oauth('unauthorized_client', 'Public clients cannot use client_credentials');
|
|
992
|
+
|
|
993
|
+
const grantTypes = JSON.parse(client.grantTypes) as string[];
|
|
994
|
+
if (!grantTypes.includes('client_credentials'))
|
|
995
|
+
throw Errors.oauth('unauthorized_client', 'Client does not support client_credentials grant');
|
|
996
|
+
|
|
997
|
+
const secretValid = timingSafeEqualHex(
|
|
998
|
+
await hashToken(params.clientSecret),
|
|
999
|
+
client.clientSecretHash,
|
|
1000
|
+
);
|
|
1001
|
+
if (!secretValid)
|
|
1002
|
+
throw Errors.oauth('invalid_client', 'Invalid client credentials');
|
|
1003
|
+
|
|
1004
|
+
// RFC 6749 §3.3 scope intersection — same as authorize code grant.
|
|
1005
|
+
const effectiveScope = intersectScope(params.scope ?? null, client.allowedScopes);
|
|
1006
|
+
if (params.scope && effectiveScope === '') {
|
|
1007
|
+
throw Errors.oauth('invalid_scope', 'No requested scope is allowed for this client');
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
const { raw: tokenRaw, hash: tokenHash } = await generateRefreshToken();
|
|
1011
|
+
const expiresAt = new Date(Date.now() + accessTokenExpiry * 1000);
|
|
1012
|
+
|
|
1013
|
+
await ctx.db.create({
|
|
1014
|
+
model: 'oauth_access_token',
|
|
1015
|
+
data: {
|
|
1016
|
+
token: tokenHash,
|
|
1017
|
+
clientId: params.clientId,
|
|
1018
|
+
userId: null,
|
|
1019
|
+
scope: effectiveScope || null,
|
|
1020
|
+
expiresAt,
|
|
1021
|
+
},
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
return {
|
|
1025
|
+
accessToken: tokenRaw,
|
|
1026
|
+
tokenType: 'Bearer',
|
|
1027
|
+
expiresIn: accessTokenExpiry,
|
|
1028
|
+
};
|
|
1029
|
+
},
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* Revoke an access token OR refresh token (RFC 7009).
|
|
1033
|
+
*
|
|
1034
|
+
* Per §2.1: revoking a refresh token also revokes the related access
|
|
1035
|
+
* tokens. When a refresh token is revoked here, the entire token
|
|
1036
|
+
* family is dropped — every rotated descendant becomes unusable.
|
|
1037
|
+
*/
|
|
1038
|
+
async revokeToken(token: string): Promise<void> {
|
|
1039
|
+
const tokenHash = await hashToken(token);
|
|
1040
|
+
// Try as access token first.
|
|
1041
|
+
await ctx.db.delete({
|
|
1042
|
+
model: 'oauth_access_token',
|
|
1043
|
+
where: [{ field: 'token', operator: '=', value: tokenHash }],
|
|
1044
|
+
});
|
|
1045
|
+
// Try as refresh token — if the hash matches, kill the whole family.
|
|
1046
|
+
if (refreshEnabled) {
|
|
1047
|
+
const refreshRecord = await ctx.db.findOne<RefreshTokenRecord>({
|
|
1048
|
+
model: 'oauth_refresh_token',
|
|
1049
|
+
where: [{ field: 'token', operator: '=', value: tokenHash }],
|
|
1050
|
+
});
|
|
1051
|
+
if (refreshRecord) {
|
|
1052
|
+
await ctx.db.delete({
|
|
1053
|
+
model: 'oauth_refresh_token',
|
|
1054
|
+
where: [{ field: 'familyId', operator: '=', value: refreshRecord.familyId }],
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
},
|
|
1059
|
+
|
|
1060
|
+
/**
|
|
1061
|
+
* Validate an access token and return associated user/client info.
|
|
1062
|
+
*/
|
|
1063
|
+
async introspectToken(token: string): Promise<{
|
|
1064
|
+
active: boolean;
|
|
1065
|
+
clientId?: string;
|
|
1066
|
+
userId?: string;
|
|
1067
|
+
scope?: string;
|
|
1068
|
+
}> {
|
|
1069
|
+
const tokenHash = await hashToken(token);
|
|
1070
|
+
const record = await ctx.db.findOne<AccessTokenRecord>({
|
|
1071
|
+
model: 'oauth_access_token',
|
|
1072
|
+
where: [{ field: 'token', operator: '=', value: tokenHash }],
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
if (!record || record.expiresAt < new Date()) {
|
|
1076
|
+
return { active: false };
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
return {
|
|
1080
|
+
active: true,
|
|
1081
|
+
clientId: record.clientId,
|
|
1082
|
+
userId: record.userId ?? undefined,
|
|
1083
|
+
scope: record.scope ?? undefined,
|
|
1084
|
+
};
|
|
1085
|
+
},
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* Create a pending OAuth flow for unauthenticated users (identity broker pattern).
|
|
1089
|
+
*/
|
|
1090
|
+
async createPendingFlow(params: {
|
|
1091
|
+
clientId: string;
|
|
1092
|
+
redirectUri: string;
|
|
1093
|
+
scope?: string;
|
|
1094
|
+
state: string;
|
|
1095
|
+
codeChallenge?: string;
|
|
1096
|
+
codeChallengeMethod?: string;
|
|
1097
|
+
userId?: string;
|
|
1098
|
+
}): Promise<{ flowId: string }> {
|
|
1099
|
+
const expiresAt = new Date(Date.now() + pendingFlowExpiry * 1000);
|
|
1100
|
+
const { raw: flowId } = await generateRefreshToken();
|
|
1101
|
+
|
|
1102
|
+
const flow = await ctx.db.create<PendingFlowRecord>({
|
|
1103
|
+
model: 'oauth_pending_flow',
|
|
1104
|
+
data: {
|
|
1105
|
+
flowId,
|
|
1106
|
+
clientId: params.clientId,
|
|
1107
|
+
redirectUri: params.redirectUri,
|
|
1108
|
+
scope: params.scope ?? null,
|
|
1109
|
+
state: params.state,
|
|
1110
|
+
codeChallenge: params.codeChallenge ?? null,
|
|
1111
|
+
codeChallengeMethod: params.codeChallengeMethod ?? null,
|
|
1112
|
+
userId: params.userId ?? null,
|
|
1113
|
+
usedAt: null,
|
|
1114
|
+
expiresAt,
|
|
1115
|
+
},
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
return { flowId: flow.flowId };
|
|
1119
|
+
},
|
|
1120
|
+
|
|
1121
|
+
/**
|
|
1122
|
+
* Read a pending OAuth flow without consuming it.
|
|
1123
|
+
*
|
|
1124
|
+
* Used by the consent UI to fetch flow metadata (client, scopes, redirect URI)
|
|
1125
|
+
* before the user approves or denies. Safe to call multiple times.
|
|
1126
|
+
*
|
|
1127
|
+
* @throws {FortressError} `not_found` if the flow doesn't exist.
|
|
1128
|
+
* @throws {FortressError} `bad_request` if the flow has expired.
|
|
1129
|
+
*/
|
|
1130
|
+
async getPendingFlow(
|
|
1131
|
+
flowId: string | number,
|
|
1132
|
+
context?: { userId?: string },
|
|
1133
|
+
): Promise<PendingFlowRecord> {
|
|
1134
|
+
const flow = await ctx.db.findOne<PendingFlowRecord>({
|
|
1135
|
+
model: 'oauth_pending_flow',
|
|
1136
|
+
where: pendingFlowLookupWhere(flowId),
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
if (!flow)
|
|
1140
|
+
throw Errors.notFound('Pending flow not found');
|
|
1141
|
+
|
|
1142
|
+
if (flow.usedAt)
|
|
1143
|
+
throw Errors.notFound('Pending flow not found');
|
|
1144
|
+
|
|
1145
|
+
if (flow.expiresAt < new Date())
|
|
1146
|
+
throw Errors.badRequest('Pending flow expired');
|
|
1147
|
+
|
|
1148
|
+
// H6 fix — owner check (only when the caller supplied a context).
|
|
1149
|
+
// We deliberately return 404, not 403, to avoid confirming that the
|
|
1150
|
+
// flow exists when a different user probes its id.
|
|
1151
|
+
if (
|
|
1152
|
+
context?.userId !== undefined
|
|
1153
|
+
&& flow.userId !== null
|
|
1154
|
+
&& flow.userId !== context.userId
|
|
1155
|
+
) {
|
|
1156
|
+
throw Errors.notFound('Pending flow not found');
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
return flow;
|
|
1160
|
+
},
|
|
1161
|
+
|
|
1162
|
+
/**
|
|
1163
|
+
* Resume a pending OAuth flow after user authenticates (single-use consume).
|
|
1164
|
+
*
|
|
1165
|
+
* Returns the stored flow params and deletes the row so the same flow can't
|
|
1166
|
+
* be replayed. Use {@link OAuthMethods.getPendingFlow} for non-destructive reads.
|
|
1167
|
+
*/
|
|
1168
|
+
async resumePendingFlow(
|
|
1169
|
+
flowId: string | number,
|
|
1170
|
+
context?: { userId?: string },
|
|
1171
|
+
): Promise<PendingFlowRecord> {
|
|
1172
|
+
const flow = await ctx.db.findOne<PendingFlowRecord>({
|
|
1173
|
+
model: 'oauth_pending_flow',
|
|
1174
|
+
where: pendingFlowLookupWhere(flowId),
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
if (!flow)
|
|
1178
|
+
throw Errors.notFound('Pending flow not found');
|
|
1179
|
+
|
|
1180
|
+
if (flow.usedAt)
|
|
1181
|
+
throw Errors.notFound('Pending flow not found');
|
|
1182
|
+
|
|
1183
|
+
if (flow.expiresAt < new Date())
|
|
1184
|
+
throw Errors.badRequest('Pending flow expired');
|
|
1185
|
+
|
|
1186
|
+
// H6 fix — same owner check semantics as getPendingFlow.
|
|
1187
|
+
if (
|
|
1188
|
+
context?.userId !== undefined
|
|
1189
|
+
&& flow.userId !== null
|
|
1190
|
+
&& flow.userId !== context.userId
|
|
1191
|
+
) {
|
|
1192
|
+
throw Errors.notFound('Pending flow not found');
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
// Delete the flow (single-use)
|
|
1196
|
+
await ctx.db.delete({
|
|
1197
|
+
model: 'oauth_pending_flow',
|
|
1198
|
+
where: [{ field: 'id', operator: '=', value: flow.id }],
|
|
1199
|
+
});
|
|
1200
|
+
|
|
1201
|
+
return flow;
|
|
1202
|
+
},
|
|
1203
|
+
|
|
1204
|
+
/**
|
|
1205
|
+
* Get userinfo for an access token (OpenID Connect userinfo endpoint).
|
|
1206
|
+
*/
|
|
1207
|
+
/**
|
|
1208
|
+
* Handle the authorize endpoint. Validates the OAuth query, creates a
|
|
1209
|
+
* pending flow row, and decides whether the browser should be sent to
|
|
1210
|
+
* the host app's login page or consent page next.
|
|
1211
|
+
*
|
|
1212
|
+
* The dispatcher turns `redirectUrl` into a 302 response. Errors are
|
|
1213
|
+
* thrown as {@link FortressError}s so they surface as a 4xx JSON body
|
|
1214
|
+
* (the OAuth client is expected to handle that).
|
|
1215
|
+
*/
|
|
1216
|
+
async handleAuthorizeRequest(
|
|
1217
|
+
query: Record<string, string | undefined>,
|
|
1218
|
+
context: { userId?: string },
|
|
1219
|
+
): Promise<{ redirectUrl: string; flowId: string }> {
|
|
1220
|
+
const nonce = query.nonce;
|
|
1221
|
+
if (!config.loginUrl || !config.consentUrl) {
|
|
1222
|
+
throw Errors.badRequest(
|
|
1223
|
+
'OAuth plugin is missing loginUrl/consentUrl configuration',
|
|
1224
|
+
);
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
const clientId = query.client_id;
|
|
1228
|
+
const redirectUri = query.redirect_uri;
|
|
1229
|
+
const responseType = query.response_type;
|
|
1230
|
+
const state = query.state;
|
|
1231
|
+
const scope = query.scope;
|
|
1232
|
+
const codeChallenge = query.code_challenge;
|
|
1233
|
+
const codeChallengeMethod = query.code_challenge_method;
|
|
1234
|
+
|
|
1235
|
+
if (!clientId)
|
|
1236
|
+
throw Errors.oauth('invalid_request', 'client_id is required');
|
|
1237
|
+
if (!redirectUri)
|
|
1238
|
+
throw Errors.oauth('invalid_request', 'redirect_uri is required');
|
|
1239
|
+
if (responseType !== 'code')
|
|
1240
|
+
throw Errors.oauth('unsupported_response_type', 'response_type must be "code"');
|
|
1241
|
+
if (!state)
|
|
1242
|
+
throw Errors.oauth('invalid_request', 'state is required');
|
|
1243
|
+
|
|
1244
|
+
// Validate the client and redirect URI up front so a bogus client
|
|
1245
|
+
// never reaches the user-facing pages. Per RFC 6749 §4.1.2.1, the AS
|
|
1246
|
+
// MUST NOT redirect when the client_id or redirect_uri can't be
|
|
1247
|
+
// trusted — returning the error directly here is the correct shape.
|
|
1248
|
+
const client = await ctx.db.findOne<OAuthClientRecord>({
|
|
1249
|
+
model: 'oauth_client',
|
|
1250
|
+
where: [{ field: 'clientId', operator: '=', value: clientId }],
|
|
1251
|
+
});
|
|
1252
|
+
if (!client)
|
|
1253
|
+
throw Errors.oauth('invalid_request', 'Unknown client_id');
|
|
1254
|
+
|
|
1255
|
+
const allowedRedirects = JSON.parse(client.redirectUris) as string[];
|
|
1256
|
+
if (!allowedRedirects.some(r => matchRedirectUri(r, redirectUri)))
|
|
1257
|
+
throw Errors.oauth('invalid_request', 'Invalid redirect_uri');
|
|
1258
|
+
|
|
1259
|
+
// RFC 9700 §2.1.1: PKCE is mandatory. The escape hatch
|
|
1260
|
+
// `allowNonPkceConfidentialClients` exists for legacy server-side RPs
|
|
1261
|
+
// and is documented as discouraged.
|
|
1262
|
+
if (!codeChallenge && !config.allowNonPkceConfidentialClients) {
|
|
1263
|
+
throw Errors.oauth('invalid_request', 'code_challenge is required (PKCE)');
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// PKCE method validation — we only support S256 (matches the
|
|
1267
|
+
// discovery document).
|
|
1268
|
+
if (codeChallenge && codeChallengeMethod && codeChallengeMethod !== 'S256') {
|
|
1269
|
+
throw Errors.oauth('invalid_request', 'Only S256 code_challenge_method is supported');
|
|
1270
|
+
}
|
|
1271
|
+
// RFC 7636 §4.3 default for `code_challenge_method` when only the
|
|
1272
|
+
// challenge is sent is `plain`, which we don't support — require the
|
|
1273
|
+
// method to be explicit.
|
|
1274
|
+
if (codeChallenge && !codeChallengeMethod) {
|
|
1275
|
+
throw Errors.oauth(
|
|
1276
|
+
'invalid_request',
|
|
1277
|
+
'code_challenge_method is required when code_challenge is set; only S256 is supported',
|
|
1278
|
+
);
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
// RFC 6749 §3.3: intersect the requested scopes against the client's
|
|
1282
|
+
// allow-list. Empty intersection (after non-empty request) returns
|
|
1283
|
+
// `invalid_scope`; subset is silently narrowed.
|
|
1284
|
+
const effectiveScope = intersectScope(scope ?? null, client.allowedScopes);
|
|
1285
|
+
if (scope && effectiveScope === '') {
|
|
1286
|
+
throw Errors.oauth('invalid_scope', 'No requested scope is allowed for this client');
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
const expiresAt = new Date(Date.now() + pendingFlowExpiry * 1000);
|
|
1290
|
+
const { raw: flowId } = await generateRefreshToken();
|
|
1291
|
+
const flow = await ctx.db.create<PendingFlowRecord>({
|
|
1292
|
+
model: 'oauth_pending_flow',
|
|
1293
|
+
data: {
|
|
1294
|
+
flowId,
|
|
1295
|
+
clientId,
|
|
1296
|
+
redirectUri,
|
|
1297
|
+
scope: effectiveScope || null,
|
|
1298
|
+
state,
|
|
1299
|
+
codeChallenge: codeChallenge ?? null,
|
|
1300
|
+
codeChallengeMethod: codeChallengeMethod ?? null,
|
|
1301
|
+
nonce: nonce ?? null,
|
|
1302
|
+
// H6 fix — bind the flow to the authenticated user up front when
|
|
1303
|
+
// the authorize endpoint already knows them. The login redirect
|
|
1304
|
+
// path leaves userId null and lets the first authenticated
|
|
1305
|
+
// GetFlow / Approve claim it.
|
|
1306
|
+
userId: context.userId ?? null,
|
|
1307
|
+
usedAt: null,
|
|
1308
|
+
expiresAt,
|
|
1309
|
+
},
|
|
1310
|
+
});
|
|
1311
|
+
|
|
1312
|
+
const target = context.userId ? config.consentUrl : config.loginUrl;
|
|
1313
|
+
const url = new URL(target);
|
|
1314
|
+
url.searchParams.set('flow', flow.flowId);
|
|
1315
|
+
return { redirectUrl: url.toString(), flowId: flow.flowId };
|
|
1316
|
+
},
|
|
1317
|
+
|
|
1318
|
+
/**
|
|
1319
|
+
* Read a pending flow as the consent UI's data source. Strips fields
|
|
1320
|
+
* that should never leave the server (PKCE challenge / method).
|
|
1321
|
+
*/
|
|
1322
|
+
async handleGetFlow(
|
|
1323
|
+
flowId: string | number,
|
|
1324
|
+
context: { userId: string },
|
|
1325
|
+
): Promise<{
|
|
1326
|
+
flowId: string;
|
|
1327
|
+
client: { clientId: string; name: string };
|
|
1328
|
+
redirectUri: string;
|
|
1329
|
+
scopes: string[];
|
|
1330
|
+
state: string;
|
|
1331
|
+
}> {
|
|
1332
|
+
const flow = await this.getPendingFlow(flowId, context);
|
|
1333
|
+
// TOFU claim: if the flow was created on the login path (no user
|
|
1334
|
+
// yet) and this is the first authenticated touch, bind it to the
|
|
1335
|
+
// caller. Subsequent reads from a different user will 404.
|
|
1336
|
+
if (flow.userId === null) {
|
|
1337
|
+
await ctx.db.update({
|
|
1338
|
+
model: 'oauth_pending_flow',
|
|
1339
|
+
where: [
|
|
1340
|
+
{ field: 'id', operator: '=', value: flow.id },
|
|
1341
|
+
{ field: 'userId', operator: 'isNull', value: null },
|
|
1342
|
+
],
|
|
1343
|
+
data: { userId: context.userId },
|
|
1344
|
+
});
|
|
1345
|
+
}
|
|
1346
|
+
const client = await ctx.db.findOne<OAuthClientRecord>({
|
|
1347
|
+
model: 'oauth_client',
|
|
1348
|
+
where: [{ field: 'clientId', operator: '=', value: flow.clientId }],
|
|
1349
|
+
});
|
|
1350
|
+
if (!client)
|
|
1351
|
+
throw Errors.notFound('OAuth client not found');
|
|
1352
|
+
|
|
1353
|
+
return {
|
|
1354
|
+
flowId: flow.flowId,
|
|
1355
|
+
client: { clientId: client.clientId, name: client.name },
|
|
1356
|
+
redirectUri: flow.redirectUri,
|
|
1357
|
+
scopes: flow.scope ? flow.scope.split(' ').filter(Boolean) : [],
|
|
1358
|
+
state: flow.state,
|
|
1359
|
+
};
|
|
1360
|
+
},
|
|
1361
|
+
|
|
1362
|
+
/**
|
|
1363
|
+
* Approve a pending flow on behalf of the authenticated user. Creates
|
|
1364
|
+
* the authorization code, consumes the pending flow, and returns the
|
|
1365
|
+
* client's redirect URI with `?code=...&state=...` appended.
|
|
1366
|
+
*/
|
|
1367
|
+
async handleApproveFlow(
|
|
1368
|
+
flowId: string | number,
|
|
1369
|
+
context: { userId: string; authTimeSeconds?: number },
|
|
1370
|
+
): Promise<{ redirectUrl: string }> {
|
|
1371
|
+
const { flow, code } = await ctx.db.transaction(async (tx) => {
|
|
1372
|
+
const flow = await tx.findOne<PendingFlowRecord>({
|
|
1373
|
+
model: 'oauth_pending_flow',
|
|
1374
|
+
where: pendingFlowLookupWhere(flowId),
|
|
1375
|
+
});
|
|
1376
|
+
|
|
1377
|
+
if (!flow)
|
|
1378
|
+
throw Errors.notFound('Pending flow not found');
|
|
1379
|
+
if (flow.usedAt)
|
|
1380
|
+
throw Errors.notFound('Pending flow not found');
|
|
1381
|
+
if (flow.expiresAt < new Date())
|
|
1382
|
+
throw Errors.badRequest('Pending flow expired');
|
|
1383
|
+
if (flow.userId !== null && flow.userId !== context.userId)
|
|
1384
|
+
throw Errors.notFound('Pending flow not found');
|
|
1385
|
+
|
|
1386
|
+
// Claim before minting a code. The `usedAt IS NULL` predicate is
|
|
1387
|
+
// the single-use compare-and-set that makes concurrent Approve
|
|
1388
|
+
// requests yield exactly one authorization code.
|
|
1389
|
+
const claimed = await tx.update<PendingFlowRecord>({
|
|
1390
|
+
model: 'oauth_pending_flow',
|
|
1391
|
+
where: [
|
|
1392
|
+
{ field: 'id', operator: '=', value: flow.id },
|
|
1393
|
+
{ field: 'usedAt', operator: 'isNull', value: null },
|
|
1394
|
+
],
|
|
1395
|
+
data: {
|
|
1396
|
+
usedAt: new Date(),
|
|
1397
|
+
userId: flow.userId ?? context.userId,
|
|
1398
|
+
},
|
|
1399
|
+
});
|
|
1400
|
+
if (!claimed)
|
|
1401
|
+
throw Errors.notFound('Pending flow not found');
|
|
1402
|
+
|
|
1403
|
+
// Validate the client and redirect URI inside the same transaction
|
|
1404
|
+
// before creating the code. This mirrors createAuthorizationCode
|
|
1405
|
+
// while keeping the pending-flow claim + code mint atomic.
|
|
1406
|
+
const client = await tx.findOne<OAuthClientRecord>({
|
|
1407
|
+
model: 'oauth_client',
|
|
1408
|
+
where: [{ field: 'clientId', operator: '=', value: flow.clientId }],
|
|
1409
|
+
});
|
|
1410
|
+
if (!client)
|
|
1411
|
+
throw Errors.oauth('invalid_request', 'Invalid client_id');
|
|
1412
|
+
|
|
1413
|
+
const uris = JSON.parse(client.redirectUris) as string[];
|
|
1414
|
+
if (!uris.some(r => matchRedirectUri(r, flow.redirectUri)))
|
|
1415
|
+
throw Errors.oauth('invalid_request', 'Invalid redirect_uri');
|
|
1416
|
+
|
|
1417
|
+
const isPublic = client.tokenEndpointAuthMethod === 'none';
|
|
1418
|
+
if (isPublic && (!flow.codeChallenge || flow.codeChallengeMethod !== 'S256'))
|
|
1419
|
+
throw Errors.oauth('invalid_request', 'PKCE (S256) is required for public clients');
|
|
1420
|
+
|
|
1421
|
+
const { raw: code, hash: codeHash } = await generateRefreshToken();
|
|
1422
|
+
await tx.create({
|
|
1423
|
+
model: 'oauth_authorization_code',
|
|
1424
|
+
data: {
|
|
1425
|
+
code: codeHash,
|
|
1426
|
+
clientId: flow.clientId,
|
|
1427
|
+
userId: context.userId,
|
|
1428
|
+
redirectUri: flow.redirectUri,
|
|
1429
|
+
scope: flow.scope ?? null,
|
|
1430
|
+
codeChallenge: flow.codeChallenge ?? null,
|
|
1431
|
+
codeChallengeMethod: flow.codeChallengeMethod ?? null,
|
|
1432
|
+
nonce: flow.nonce ?? null,
|
|
1433
|
+
authTime: context.authTimeSeconds ?? Math.floor(Date.now() / 1000),
|
|
1434
|
+
expiresAt: new Date(Date.now() + authCodeExpiry * 1000),
|
|
1435
|
+
usedAt: null,
|
|
1436
|
+
},
|
|
1437
|
+
});
|
|
1438
|
+
|
|
1439
|
+
// Delete inside the transaction after minting. If code creation
|
|
1440
|
+
// fails, the transaction rolls the claim back; if it commits, the
|
|
1441
|
+
// flow is gone and cannot be reused.
|
|
1442
|
+
await tx.delete({
|
|
1443
|
+
model: 'oauth_pending_flow',
|
|
1444
|
+
where: [{ field: 'id', operator: '=', value: flow.id }],
|
|
1445
|
+
});
|
|
1446
|
+
|
|
1447
|
+
return { flow, code };
|
|
1448
|
+
});
|
|
1449
|
+
|
|
1450
|
+
const url = new URL(flow.redirectUri);
|
|
1451
|
+
url.searchParams.set('code', code);
|
|
1452
|
+
url.searchParams.set('state', flow.state);
|
|
1453
|
+
// RFC 9207 §2: the AS MUST identify itself in the authorization
|
|
1454
|
+
// response so the RP can detect mix-up attacks (RFC 9700 §4.4).
|
|
1455
|
+
url.searchParams.set('iss', issuerUrl);
|
|
1456
|
+
return { redirectUrl: url.toString() };
|
|
1457
|
+
},
|
|
1458
|
+
|
|
1459
|
+
/**
|
|
1460
|
+
* Deny a pending flow. Consumes the flow row and returns the OAuth
|
|
1461
|
+
* client's redirect URI with `?error=access_denied&state=...&iss=...`.
|
|
1462
|
+
*
|
|
1463
|
+
* Per RFC 9207 §2, the issuer parameter is included on error responses
|
|
1464
|
+
* too — the RP needs to validate the AS identity before trusting any
|
|
1465
|
+
* field, including `error`.
|
|
1466
|
+
*/
|
|
1467
|
+
async handleDenyFlow(
|
|
1468
|
+
flowId: string | number,
|
|
1469
|
+
context: { userId: string },
|
|
1470
|
+
): Promise<{ redirectUrl: string }> {
|
|
1471
|
+
// H6: owner check enforced via the context arg.
|
|
1472
|
+
const flow = await this.resumePendingFlow(flowId, context);
|
|
1473
|
+
const url = new URL(flow.redirectUri);
|
|
1474
|
+
url.searchParams.set('error', 'access_denied');
|
|
1475
|
+
url.searchParams.set('state', flow.state);
|
|
1476
|
+
url.searchParams.set('iss', issuerUrl);
|
|
1477
|
+
return { redirectUrl: url.toString() };
|
|
1478
|
+
},
|
|
1479
|
+
|
|
1480
|
+
async getUserInfo(token: string): Promise<FortressUser | null> {
|
|
1481
|
+
const result = await this._lookupBearer(token);
|
|
1482
|
+
return result?.user ?? null;
|
|
1483
|
+
},
|
|
1484
|
+
|
|
1485
|
+
/**
|
|
1486
|
+
* Internal: resolve a bearer token to its access-token row + user.
|
|
1487
|
+
* Shared by `getUserInfo` (returns the raw user) and
|
|
1488
|
+
* `handleUserInfoRequest` (maps to OIDC claims).
|
|
1489
|
+
*/
|
|
1490
|
+
async _lookupBearer(token: string): Promise<{ user: FortressUser; scope: string | null } | null> {
|
|
1491
|
+
const tokenHash = await hashToken(token);
|
|
1492
|
+
const record = await ctx.db.findOne<AccessTokenRecord>({
|
|
1493
|
+
model: 'oauth_access_token',
|
|
1494
|
+
where: [{ field: 'token', operator: '=', value: tokenHash }],
|
|
1495
|
+
});
|
|
1496
|
+
|
|
1497
|
+
if (!record || !record.userId || record.expiresAt < new Date())
|
|
1498
|
+
return null;
|
|
1499
|
+
|
|
1500
|
+
const user = await ctx.db.findOne<FortressUser>({
|
|
1501
|
+
model: 'user',
|
|
1502
|
+
where: [{ field: 'id', operator: '=', value: record.userId }],
|
|
1503
|
+
});
|
|
1504
|
+
if (!user)
|
|
1505
|
+
return null;
|
|
1506
|
+
return { user, scope: record.scope };
|
|
1507
|
+
},
|
|
1508
|
+
|
|
1509
|
+
// --- HTTP handler methods (RFC 6749 / 7662 / 7009) ---
|
|
1510
|
+
|
|
1511
|
+
/**
|
|
1512
|
+
* Handle POST /oauth/token — dispatches to exchangeCode or clientCredentialsGrant.
|
|
1513
|
+
*/
|
|
1514
|
+
async handleTokenRequest(
|
|
1515
|
+
body: TokenRequestBody,
|
|
1516
|
+
clientAuth?: ClientAuth,
|
|
1517
|
+
): Promise<Record<string, unknown>> {
|
|
1518
|
+
const clientId = clientAuth?.clientId ?? body.client_id;
|
|
1519
|
+
const clientSecret = clientAuth?.clientSecret ?? body.client_secret;
|
|
1520
|
+
|
|
1521
|
+
if (!clientId)
|
|
1522
|
+
throw Errors.oauth('invalid_client', 'Client authentication required');
|
|
1523
|
+
|
|
1524
|
+
// Resolve client to know whether it's public; we only short-circuit
|
|
1525
|
+
// the secret check if the registered method is `'none'`.
|
|
1526
|
+
const clientRecord = await ctx.db.findOne<OAuthClientRecord>({
|
|
1527
|
+
model: 'oauth_client',
|
|
1528
|
+
where: [{ field: 'clientId', operator: '=', value: clientId }],
|
|
1529
|
+
});
|
|
1530
|
+
if (!clientRecord)
|
|
1531
|
+
throw Errors.oauth('invalid_client', 'Invalid client credentials');
|
|
1532
|
+
const isPublic = clientRecord.tokenEndpointAuthMethod === 'none';
|
|
1533
|
+
if (!isPublic && !clientSecret)
|
|
1534
|
+
throw Errors.oauth('invalid_client', 'Client authentication required');
|
|
1535
|
+
|
|
1536
|
+
if (body.grant_type === 'authorization_code') {
|
|
1537
|
+
if (!body.code || !body.redirect_uri)
|
|
1538
|
+
throw Errors.oauth('invalid_request', 'Missing required parameters: code, redirect_uri');
|
|
1539
|
+
// RFC 7636 §4.1: PKCE verifier is REQUIRED for public clients.
|
|
1540
|
+
if (isPublic && !body.code_verifier)
|
|
1541
|
+
throw Errors.oauth('invalid_grant', 'code_verifier required for public clients');
|
|
1542
|
+
|
|
1543
|
+
const result = await this.exchangeCode({
|
|
1544
|
+
code: body.code,
|
|
1545
|
+
clientId,
|
|
1546
|
+
clientSecret: isPublic ? undefined : clientSecret,
|
|
1547
|
+
redirectUri: body.redirect_uri,
|
|
1548
|
+
codeVerifier: body.code_verifier,
|
|
1549
|
+
}) as { accessToken: string; tokenType: string; expiresIn: number; scope?: string; refreshToken?: string; idToken?: string };
|
|
1550
|
+
|
|
1551
|
+
return {
|
|
1552
|
+
access_token: result.accessToken,
|
|
1553
|
+
token_type: result.tokenType,
|
|
1554
|
+
expires_in: result.expiresIn,
|
|
1555
|
+
...(result.refreshToken ? { refresh_token: result.refreshToken } : {}),
|
|
1556
|
+
...(result.idToken ? { id_token: result.idToken } : {}),
|
|
1557
|
+
...(result.scope ? { scope: result.scope } : {}),
|
|
1558
|
+
};
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
if (body.grant_type === 'refresh_token') {
|
|
1562
|
+
if (!body.refresh_token)
|
|
1563
|
+
throw Errors.oauth('invalid_request', 'refresh_token is required');
|
|
1564
|
+
const result = await this.refreshTokenGrant({
|
|
1565
|
+
clientId,
|
|
1566
|
+
clientSecret: isPublic ? undefined : clientSecret,
|
|
1567
|
+
refreshToken: body.refresh_token,
|
|
1568
|
+
scope: body.scope,
|
|
1569
|
+
});
|
|
1570
|
+
return {
|
|
1571
|
+
access_token: result.accessToken,
|
|
1572
|
+
token_type: result.tokenType,
|
|
1573
|
+
expires_in: result.expiresIn,
|
|
1574
|
+
refresh_token: result.refreshToken,
|
|
1575
|
+
...(result.idToken ? { id_token: result.idToken } : {}),
|
|
1576
|
+
...(result.scope ? { scope: result.scope } : {}),
|
|
1577
|
+
};
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
if (body.grant_type === 'client_credentials') {
|
|
1581
|
+
if (!clientSecret)
|
|
1582
|
+
throw Errors.oauth('invalid_client', 'client_credentials requires a client secret');
|
|
1583
|
+
const result = await this.clientCredentialsGrant({
|
|
1584
|
+
clientId,
|
|
1585
|
+
clientSecret,
|
|
1586
|
+
scope: body.scope,
|
|
1587
|
+
});
|
|
1588
|
+
|
|
1589
|
+
return {
|
|
1590
|
+
access_token: result.accessToken,
|
|
1591
|
+
token_type: result.tokenType,
|
|
1592
|
+
expires_in: result.expiresIn,
|
|
1593
|
+
};
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
throw Errors.oauth('unsupported_grant_type', `Unsupported grant_type: ${body.grant_type}`);
|
|
1597
|
+
},
|
|
1598
|
+
|
|
1599
|
+
/**
|
|
1600
|
+
* Handle POST /oauth/introspect (RFC 7662).
|
|
1601
|
+
*/
|
|
1602
|
+
async handleIntrospectRequest(
|
|
1603
|
+
body: { token: string },
|
|
1604
|
+
clientAuth: ClientAuth,
|
|
1605
|
+
): Promise<Record<string, unknown>> {
|
|
1606
|
+
// Validate client credentials
|
|
1607
|
+
const client = await ctx.db.findOne<OAuthClientRecord>({
|
|
1608
|
+
model: 'oauth_client',
|
|
1609
|
+
where: [{ field: 'clientId', operator: '=', value: clientAuth.clientId }],
|
|
1610
|
+
});
|
|
1611
|
+
|
|
1612
|
+
if (!client)
|
|
1613
|
+
throw Errors.unauthorized('Invalid client credentials');
|
|
1614
|
+
|
|
1615
|
+
const secretValid = timingSafeEqualHex(
|
|
1616
|
+
await hashToken(clientAuth.clientSecret),
|
|
1617
|
+
client.clientSecretHash,
|
|
1618
|
+
);
|
|
1619
|
+
if (!secretValid)
|
|
1620
|
+
throw Errors.oauth('invalid_client', 'Invalid client credentials');
|
|
1621
|
+
|
|
1622
|
+
const result = await this.introspectToken(body.token);
|
|
1623
|
+
|
|
1624
|
+
if (!result.active || result.clientId !== clientAuth.clientId)
|
|
1625
|
+
return { active: false };
|
|
1626
|
+
|
|
1627
|
+
return {
|
|
1628
|
+
active: true,
|
|
1629
|
+
client_id: result.clientId,
|
|
1630
|
+
...(result.userId != null ? { sub: String(result.userId) } : {}),
|
|
1631
|
+
...(result.scope ? { scope: result.scope } : {}),
|
|
1632
|
+
token_type: 'Bearer',
|
|
1633
|
+
};
|
|
1634
|
+
},
|
|
1635
|
+
|
|
1636
|
+
/**
|
|
1637
|
+
* Handle POST /oauth/revoke (RFC 7009). Always returns success.
|
|
1638
|
+
*/
|
|
1639
|
+
async handleRevokeRequest(body: { token: string }, clientAuth: ClientAuth): Promise<void> {
|
|
1640
|
+
const client = await ctx.db.findOne<OAuthClientRecord>({
|
|
1641
|
+
model: 'oauth_client',
|
|
1642
|
+
where: [{ field: 'clientId', operator: '=', value: clientAuth.clientId }],
|
|
1643
|
+
});
|
|
1644
|
+
if (!client || !timingSafeEqualHex(await hashToken(clientAuth.clientSecret), client.clientSecretHash))
|
|
1645
|
+
throw Errors.oauth('invalid_client', 'Invalid client credentials');
|
|
1646
|
+
|
|
1647
|
+
const tokenHash = await hashToken(body.token);
|
|
1648
|
+
// RFC 7009 prevents one authenticated client from revoking another
|
|
1649
|
+
// client's credentials, while still returning success for unknown
|
|
1650
|
+
// tokens to avoid an oracle.
|
|
1651
|
+
await ctx.db.delete({
|
|
1652
|
+
model: 'oauth_access_token',
|
|
1653
|
+
where: [
|
|
1654
|
+
{ field: 'token', operator: '=', value: tokenHash },
|
|
1655
|
+
{ field: 'clientId', operator: '=', value: clientAuth.clientId },
|
|
1656
|
+
],
|
|
1657
|
+
});
|
|
1658
|
+
if (refreshEnabled) {
|
|
1659
|
+
const refreshRecord = await ctx.db.findOne<RefreshTokenRecord>({
|
|
1660
|
+
model: 'oauth_refresh_token',
|
|
1661
|
+
where: [
|
|
1662
|
+
{ field: 'token', operator: '=', value: tokenHash },
|
|
1663
|
+
{ field: 'clientId', operator: '=', value: clientAuth.clientId },
|
|
1664
|
+
],
|
|
1665
|
+
});
|
|
1666
|
+
if (refreshRecord) {
|
|
1667
|
+
await ctx.db.delete({
|
|
1668
|
+
model: 'oauth_refresh_token',
|
|
1669
|
+
where: [
|
|
1670
|
+
{ field: 'familyId', operator: '=', value: refreshRecord.familyId },
|
|
1671
|
+
{ field: 'clientId', operator: '=', value: clientAuth.clientId },
|
|
1672
|
+
],
|
|
1673
|
+
});
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
},
|
|
1677
|
+
|
|
1678
|
+
/**
|
|
1679
|
+
* Handle GET /oauth/userinfo (OpenID Connect Core §5.3).
|
|
1680
|
+
*
|
|
1681
|
+
* Returns the OIDC-shaped claims object expected by strict RPs (Moodle
|
|
1682
|
+
* `core\oauth2\client::get_userinfo()`, openid-client, Keycloak
|
|
1683
|
+
* federation, Spring Security). The pre-1.0 fortress shape that
|
|
1684
|
+
* leaked DB-internal fields (`id`, `isActive`, `createdAt`) is gone
|
|
1685
|
+
* — callers must rely on the spec-conformant fields below.
|
|
1686
|
+
*
|
|
1687
|
+
* Standard claim coverage (OIDC Core §5.1) gated by access-token
|
|
1688
|
+
* scope (§5.4):
|
|
1689
|
+
* - `sub` always (stringified `user.id`)
|
|
1690
|
+
* - `email`, `email_verified` only when scope contains `email`
|
|
1691
|
+
* - `name`, `preferred_username` only when scope contains `profile`
|
|
1692
|
+
* - `updated_at` always (Unix seconds, per §5.1)
|
|
1693
|
+
*
|
|
1694
|
+
* Extra claims can be added per-deployment via
|
|
1695
|
+
* {@link OAuthConfig.userinfoClaims}.
|
|
1696
|
+
*/
|
|
1697
|
+
async handleUserInfoRequest(bearerToken: string): Promise<Record<string, unknown>> {
|
|
1698
|
+
const result = await this._lookupBearer(bearerToken);
|
|
1699
|
+
if (!result)
|
|
1700
|
+
throw Errors.unauthorized('Invalid or expired access token');
|
|
1701
|
+
|
|
1702
|
+
const claims = toOidcUserinfo(result.user, result.scope);
|
|
1703
|
+
if (config.userinfoClaims) {
|
|
1704
|
+
const extra = await config.userinfoClaims(result.user, result.scope);
|
|
1705
|
+
Object.assign(claims, extra);
|
|
1706
|
+
}
|
|
1707
|
+
return claims;
|
|
1708
|
+
},
|
|
1709
|
+
|
|
1710
|
+
/**
|
|
1711
|
+
* Handle GET /.well-known/openid-configuration (RFC 8414 + OIDC
|
|
1712
|
+
* Discovery 1.0). Includes the RFC 9207 metadata flag and the OIDC
|
|
1713
|
+
* fields strict RPs (Moodle, openid-client, Spring Security) require
|
|
1714
|
+
* for autoconfig.
|
|
1715
|
+
*/
|
|
1716
|
+
handleDiscovery(): Record<string, unknown> {
|
|
1717
|
+
const issuer = issuerUrl;
|
|
1718
|
+
const mappedScopes = scopePermissionMap ? Object.keys(scopePermissionMap) : [];
|
|
1719
|
+
const scopesSupported = Array.from(new Set([
|
|
1720
|
+
'openid',
|
|
1721
|
+
'email',
|
|
1722
|
+
'profile',
|
|
1723
|
+
...(config.scopesSupported ?? []),
|
|
1724
|
+
...mappedScopes,
|
|
1725
|
+
]));
|
|
1726
|
+
return {
|
|
1727
|
+
issuer,
|
|
1728
|
+
authorization_endpoint: `${issuer}/oauth/authorize`,
|
|
1729
|
+
token_endpoint: `${issuer}/oauth/token`,
|
|
1730
|
+
introspection_endpoint: `${issuer}/oauth/introspect`,
|
|
1731
|
+
revocation_endpoint: `${issuer}/oauth/revoke`,
|
|
1732
|
+
userinfo_endpoint: `${issuer}/oauth/userinfo`,
|
|
1733
|
+
jwks_uri: `${issuer}/oauth/.well-known/jwks.json`,
|
|
1734
|
+
response_types_supported: ['code'],
|
|
1735
|
+
response_modes_supported: ['query'],
|
|
1736
|
+
grant_types_supported: refreshEnabled
|
|
1737
|
+
? ['authorization_code', 'client_credentials', 'refresh_token']
|
|
1738
|
+
: ['authorization_code', 'client_credentials'],
|
|
1739
|
+
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post', 'none'],
|
|
1740
|
+
code_challenge_methods_supported: ['S256'],
|
|
1741
|
+
subject_types_supported: ['public'],
|
|
1742
|
+
// OIDC Discovery §3 REQUIRED when id_tokens are issued.
|
|
1743
|
+
id_token_signing_alg_values_supported: ['RS256'],
|
|
1744
|
+
scopes_supported: scopesSupported,
|
|
1745
|
+
claims_supported: [
|
|
1746
|
+
'sub',
|
|
1747
|
+
'email',
|
|
1748
|
+
'email_verified',
|
|
1749
|
+
'name',
|
|
1750
|
+
'preferred_username',
|
|
1751
|
+
'updated_at',
|
|
1752
|
+
'iss',
|
|
1753
|
+
'aud',
|
|
1754
|
+
'exp',
|
|
1755
|
+
'iat',
|
|
1756
|
+
'auth_time',
|
|
1757
|
+
'nonce',
|
|
1758
|
+
],
|
|
1759
|
+
// RFC 9207 §3.
|
|
1760
|
+
authorization_response_iss_parameter_supported: true,
|
|
1761
|
+
};
|
|
1762
|
+
},
|
|
1763
|
+
|
|
1764
|
+
/**
|
|
1765
|
+
* Handle GET /oauth/.well-known/jwks.json (RFC 7517).
|
|
1766
|
+
*
|
|
1767
|
+
* Returns the AS's JSON Web Key Set: the active RS256 public key
|
|
1768
|
+
* plus any rotated keys still in the verification grace window. RPs
|
|
1769
|
+
* fetch this URL (advertised via discovery's `jwks_uri`) and use it
|
|
1770
|
+
* to verify id_token signatures by `kid`.
|
|
1771
|
+
*/
|
|
1772
|
+
async rotateSigningKey(): Promise<{ kid: string }> {
|
|
1773
|
+
const key = await rotateSigningKey(ctx.db, signingKeyGraceSeconds);
|
|
1774
|
+
return { kid: key.kid };
|
|
1775
|
+
},
|
|
1776
|
+
|
|
1777
|
+
async handleJwksRequest(): Promise<Record<string, unknown>> {
|
|
1778
|
+
const jwks = await listJwks(ctx.db, signingKeyGraceSeconds);
|
|
1779
|
+
// First call materialises the active key if none exists yet —
|
|
1780
|
+
// ensures discovery + JWKS stay in sync without a deploy step.
|
|
1781
|
+
if (jwks.keys.length === 0) {
|
|
1782
|
+
await getActiveSigningKey(ctx.db);
|
|
1783
|
+
return await listJwks(ctx.db, signingKeyGraceSeconds);
|
|
1784
|
+
}
|
|
1785
|
+
return jwks;
|
|
1786
|
+
},
|
|
1787
|
+
|
|
1788
|
+
/**
|
|
1789
|
+
* Resolve an access token's scopes to IAM permissions using the configured scopePermissionMap.
|
|
1790
|
+
*/
|
|
1791
|
+
async resolveTokenPermissions(token: string): Promise<{ resource: string; action: string }[]> {
|
|
1792
|
+
const info = await this.introspectToken(token);
|
|
1793
|
+
if (!info.active || !info.scope || !scopePermissionMap)
|
|
1794
|
+
return [];
|
|
1795
|
+
|
|
1796
|
+
const scopes = info.scope.split(' ');
|
|
1797
|
+
const permissions: { resource: string; action: string }[] = [];
|
|
1798
|
+
|
|
1799
|
+
for (const scope of scopes) {
|
|
1800
|
+
const mapping = scopePermissionMap[scope];
|
|
1801
|
+
if (mapping) {
|
|
1802
|
+
permissions.push(mapping);
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
return permissions;
|
|
1807
|
+
},
|
|
1808
|
+
}),
|
|
1809
|
+
|
|
1810
|
+
routes: {
|
|
1811
|
+
// Front door for the auth-code flow. Opt-in: only mounted when the
|
|
1812
|
+
// host app has wired up `loginUrl` and `consentUrl`.
|
|
1813
|
+
...(config.enableAuthorizeEndpoint
|
|
1814
|
+
? {
|
|
1815
|
+
handleAuthorizeRequest: {
|
|
1816
|
+
method: 'GET' as const,
|
|
1817
|
+
path: '/oauth/authorize',
|
|
1818
|
+
handler: 'handleAuthorizeRequest',
|
|
1819
|
+
meta: {
|
|
1820
|
+
summary: 'Start an OAuth authorization-code flow',
|
|
1821
|
+
tags: ['OAuth'],
|
|
1822
|
+
security: ['none'] as ('none' | 'basic' | 'bearer')[],
|
|
1823
|
+
bearerKind: 'oauth' as const,
|
|
1824
|
+
},
|
|
1825
|
+
input: {
|
|
1826
|
+
query: {
|
|
1827
|
+
type: 'object' as const,
|
|
1828
|
+
properties: {
|
|
1829
|
+
client_id: { type: 'string' as const, description: 'Registered OAuth client ID' },
|
|
1830
|
+
redirect_uri: { type: 'string' as const, format: 'uri', description: 'Must match a redirect URI registered with the client' },
|
|
1831
|
+
response_type: { type: 'string' as const, enum: ['code'], description: 'Only "code" is supported' },
|
|
1832
|
+
scope: { type: 'string' as const, description: 'Space-separated requested scopes' },
|
|
1833
|
+
state: { type: 'string' as const, description: 'Opaque value returned to the client; required to prevent CSRF' },
|
|
1834
|
+
code_challenge: { type: 'string' as const, description: 'PKCE code challenge (recommended for public clients)' },
|
|
1835
|
+
code_challenge_method: { type: 'string' as const, enum: ['S256'], description: 'PKCE method — only S256 is supported' },
|
|
1836
|
+
},
|
|
1837
|
+
required: ['client_id', 'redirect_uri', 'response_type', 'state'],
|
|
1838
|
+
},
|
|
1839
|
+
},
|
|
1840
|
+
responses: {
|
|
1841
|
+
302: { description: 'Redirect to login (unauthenticated) or consent (authenticated) page with ?flow=<id>' },
|
|
1842
|
+
400: { description: 'Invalid request' },
|
|
1843
|
+
},
|
|
1844
|
+
},
|
|
1845
|
+
}
|
|
1846
|
+
: {}),
|
|
1847
|
+
// Consent API — SPA-friendly flow inspection + approve/deny.
|
|
1848
|
+
...(config.enableConsentApi
|
|
1849
|
+
? {
|
|
1850
|
+
handleGetFlow: {
|
|
1851
|
+
method: 'GET' as const,
|
|
1852
|
+
path: '/oauth/flows/:flowId',
|
|
1853
|
+
handler: 'handleGetFlow',
|
|
1854
|
+
meta: {
|
|
1855
|
+
summary: 'Fetch pending OAuth flow metadata',
|
|
1856
|
+
tags: ['OAuth'],
|
|
1857
|
+
security: ['bearer'] as ('none' | 'basic' | 'bearer')[],
|
|
1858
|
+
},
|
|
1859
|
+
responses: {
|
|
1860
|
+
200: {
|
|
1861
|
+
description: 'Flow metadata',
|
|
1862
|
+
schema: {
|
|
1863
|
+
type: 'object' as const,
|
|
1864
|
+
properties: {
|
|
1865
|
+
flowId: { type: 'string' as const },
|
|
1866
|
+
client: {
|
|
1867
|
+
type: 'object' as const,
|
|
1868
|
+
properties: {
|
|
1869
|
+
clientId: { type: 'string' as const },
|
|
1870
|
+
name: { type: 'string' as const },
|
|
1871
|
+
},
|
|
1872
|
+
},
|
|
1873
|
+
redirectUri: { type: 'string' as const },
|
|
1874
|
+
scopes: { type: 'array' as const, items: { type: 'string' as const } },
|
|
1875
|
+
state: { type: 'string' as const },
|
|
1876
|
+
},
|
|
1877
|
+
},
|
|
1878
|
+
},
|
|
1879
|
+
401: { description: 'Authentication required' },
|
|
1880
|
+
404: { description: 'Flow not found' },
|
|
1881
|
+
},
|
|
1882
|
+
},
|
|
1883
|
+
handleApproveFlow: {
|
|
1884
|
+
method: 'POST' as const,
|
|
1885
|
+
path: '/oauth/flows/:flowId/approve',
|
|
1886
|
+
handler: 'handleApproveFlow',
|
|
1887
|
+
meta: {
|
|
1888
|
+
summary: 'Approve a pending OAuth flow',
|
|
1889
|
+
tags: ['OAuth'],
|
|
1890
|
+
security: ['bearer'] as ('none' | 'basic' | 'bearer')[],
|
|
1891
|
+
},
|
|
1892
|
+
responses: {
|
|
1893
|
+
200: {
|
|
1894
|
+
description: 'Authorization code issued',
|
|
1895
|
+
schema: {
|
|
1896
|
+
type: 'object' as const,
|
|
1897
|
+
properties: { redirectUrl: { type: 'string' as const } },
|
|
1898
|
+
},
|
|
1899
|
+
},
|
|
1900
|
+
401: { description: 'Authentication required' },
|
|
1901
|
+
404: { description: 'Flow not found' },
|
|
1902
|
+
},
|
|
1903
|
+
},
|
|
1904
|
+
handleDenyFlow: {
|
|
1905
|
+
method: 'POST' as const,
|
|
1906
|
+
path: '/oauth/flows/:flowId/deny',
|
|
1907
|
+
handler: 'handleDenyFlow',
|
|
1908
|
+
meta: {
|
|
1909
|
+
summary: 'Deny a pending OAuth flow',
|
|
1910
|
+
tags: ['OAuth'],
|
|
1911
|
+
security: ['bearer'] as ('none' | 'basic' | 'bearer')[],
|
|
1912
|
+
},
|
|
1913
|
+
responses: {
|
|
1914
|
+
200: {
|
|
1915
|
+
description: 'Flow denied',
|
|
1916
|
+
schema: {
|
|
1917
|
+
type: 'object' as const,
|
|
1918
|
+
properties: { redirectUrl: { type: 'string' as const } },
|
|
1919
|
+
},
|
|
1920
|
+
},
|
|
1921
|
+
401: { description: 'Authentication required' },
|
|
1922
|
+
404: { description: 'Flow not found' },
|
|
1923
|
+
},
|
|
1924
|
+
},
|
|
1925
|
+
}
|
|
1926
|
+
: {}),
|
|
1927
|
+
handleTokenRequest: {
|
|
1928
|
+
method: 'POST',
|
|
1929
|
+
path: '/oauth/token',
|
|
1930
|
+
handler: 'handleTokenRequest',
|
|
1931
|
+
meta: { summary: 'Exchange credentials for tokens', tags: ['OAuth'], security: ['basic'], bearerKind: 'oauth' as const },
|
|
1932
|
+
input: {
|
|
1933
|
+
body: {
|
|
1934
|
+
type: 'object',
|
|
1935
|
+
properties: {
|
|
1936
|
+
grant_type: { type: 'string', enum: ['authorization_code', 'client_credentials', 'refresh_token'], description: 'OAuth grant type' },
|
|
1937
|
+
code: { type: 'string', description: 'Authorization code' },
|
|
1938
|
+
redirect_uri: { type: 'string', format: 'uri', description: 'Redirect URI used in authorize' },
|
|
1939
|
+
client_id: { type: 'string', description: 'Client ID (if not using Basic auth)' },
|
|
1940
|
+
client_secret: { type: 'string', description: 'Client secret (if not using Basic auth)' },
|
|
1941
|
+
code_verifier: { type: 'string', description: 'PKCE code verifier' },
|
|
1942
|
+
refresh_token: { type: 'string', description: 'Refresh token for grant_type=refresh_token' },
|
|
1943
|
+
scope: { type: 'string', description: 'Space-separated scopes' },
|
|
1944
|
+
},
|
|
1945
|
+
required: ['grant_type'],
|
|
1946
|
+
},
|
|
1947
|
+
},
|
|
1948
|
+
responses: {
|
|
1949
|
+
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' } } } },
|
|
1950
|
+
400: { description: 'Invalid request' },
|
|
1951
|
+
401: { description: 'Invalid client credentials' },
|
|
1952
|
+
},
|
|
1953
|
+
},
|
|
1954
|
+
handleIntrospectRequest: {
|
|
1955
|
+
method: 'POST',
|
|
1956
|
+
path: '/oauth/introspect',
|
|
1957
|
+
handler: 'handleIntrospectRequest',
|
|
1958
|
+
meta: { summary: 'Introspect a token (RFC 7662)', tags: ['OAuth'], security: ['basic'], bearerKind: 'oauth' as const },
|
|
1959
|
+
input: { body: { type: 'object', properties: { token: { type: 'string' } }, required: ['token'] } },
|
|
1960
|
+
responses: {
|
|
1961
|
+
200: { description: 'Token info', schema: { type: 'object', properties: { active: { type: 'boolean' }, sub: { type: 'string' }, scope: { type: 'string' }, exp: { type: 'number' } } } },
|
|
1962
|
+
401: { description: 'Client authentication required' },
|
|
1963
|
+
},
|
|
1964
|
+
},
|
|
1965
|
+
handleRevokeRequest: {
|
|
1966
|
+
method: 'POST',
|
|
1967
|
+
path: '/oauth/revoke',
|
|
1968
|
+
handler: 'handleRevokeRequest',
|
|
1969
|
+
meta: { summary: 'Revoke a token (RFC 7009)', tags: ['OAuth'], security: ['basic'], bearerKind: 'oauth' as const },
|
|
1970
|
+
input: { body: { type: 'object', properties: { token: { type: 'string' } }, required: ['token'] } },
|
|
1971
|
+
responses: { 200: { description: 'Token revoked' } },
|
|
1972
|
+
},
|
|
1973
|
+
handleUserInfoRequest: {
|
|
1974
|
+
method: 'GET',
|
|
1975
|
+
path: '/oauth/userinfo',
|
|
1976
|
+
handler: 'handleUserInfoRequest',
|
|
1977
|
+
meta: { summary: 'Get user info (OIDC Core §5.3)', tags: ['OAuth'], security: ['bearer'], bearerKind: 'oauth' as const },
|
|
1978
|
+
responses: {
|
|
1979
|
+
200: {
|
|
1980
|
+
description: 'OIDC userinfo response',
|
|
1981
|
+
schema: {
|
|
1982
|
+
type: 'object',
|
|
1983
|
+
properties: {
|
|
1984
|
+
sub: { type: 'string', description: 'Stringified user identifier' },
|
|
1985
|
+
email: { type: 'string', format: 'email' },
|
|
1986
|
+
email_verified: { type: 'boolean' },
|
|
1987
|
+
name: { type: 'string' },
|
|
1988
|
+
preferred_username: { type: 'string' },
|
|
1989
|
+
updated_at: { type: 'number', description: 'Unix seconds since the user record was last updated' },
|
|
1990
|
+
},
|
|
1991
|
+
required: ['sub'],
|
|
1992
|
+
additionalProperties: true,
|
|
1993
|
+
},
|
|
1994
|
+
},
|
|
1995
|
+
401: { description: 'Invalid or expired bearer token' },
|
|
1996
|
+
},
|
|
1997
|
+
},
|
|
1998
|
+
handleDiscovery: {
|
|
1999
|
+
method: 'GET',
|
|
2000
|
+
path: '/oauth/.well-known/openid-configuration',
|
|
2001
|
+
handler: 'handleDiscovery',
|
|
2002
|
+
meta: { summary: 'OIDC discovery document', tags: ['OAuth'], security: ['none'], bearerKind: 'oauth' as const },
|
|
2003
|
+
responses: { 200: { description: 'OIDC configuration', schema: { type: 'object', additionalProperties: true } } },
|
|
2004
|
+
},
|
|
2005
|
+
handleJwksRequest: {
|
|
2006
|
+
method: 'GET',
|
|
2007
|
+
path: '/oauth/.well-known/jwks.json',
|
|
2008
|
+
handler: 'handleJwksRequest',
|
|
2009
|
+
meta: { summary: 'JSON Web Key Set (RFC 7517)', tags: ['OAuth'], security: ['none'], bearerKind: 'oauth' as const },
|
|
2010
|
+
responses: {
|
|
2011
|
+
200: {
|
|
2012
|
+
description: 'JWKS for verifying id_token signatures',
|
|
2013
|
+
schema: {
|
|
2014
|
+
type: 'object',
|
|
2015
|
+
properties: {
|
|
2016
|
+
keys: {
|
|
2017
|
+
type: 'array',
|
|
2018
|
+
items: { type: 'object', additionalProperties: true },
|
|
2019
|
+
},
|
|
2020
|
+
},
|
|
2021
|
+
required: ['keys'],
|
|
2022
|
+
},
|
|
2023
|
+
},
|
|
2024
|
+
},
|
|
2025
|
+
},
|
|
2026
|
+
},
|
|
2027
|
+
};
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
/**
|
|
2031
|
+
* RFC 8252 §8.4 redirect-URI matcher with the loopback exception.
|
|
2032
|
+
*
|
|
2033
|
+
* Confidential clients still get exact-match (the registered URI must equal
|
|
2034
|
+
* the inbound URI byte-for-byte). Native / public clients that registered an
|
|
2035
|
+
* `http://127.0.0.1/<path>` or `http://[::1]/<path>` redirect MUST be allowed
|
|
2036
|
+
* to vary the port at runtime, because the loopback HTTP server picks one
|
|
2037
|
+
* dynamically. Path, query, and fragment still have to match.
|
|
2038
|
+
*
|
|
2039
|
+
* This is intentionally narrow: only `127.0.0.1` and `[::1]` get the
|
|
2040
|
+
* any-port leniency. `localhost` (DNS-resolved) is NOT widened — RFC 8252
|
|
2041
|
+
* §8.3 actively recommends against it for security reasons (DNS rebinding).
|
|
2042
|
+
*/
|
|
2043
|
+
export function matchRedirectUri(registered: string, inbound: string): boolean {
|
|
2044
|
+
if (registered === inbound)
|
|
2045
|
+
return true;
|
|
2046
|
+
let r: URL, i: URL;
|
|
2047
|
+
try {
|
|
2048
|
+
r = new URL(registered);
|
|
2049
|
+
i = new URL(inbound);
|
|
2050
|
+
}
|
|
2051
|
+
catch {
|
|
2052
|
+
return false;
|
|
2053
|
+
}
|
|
2054
|
+
const isLoopback = r.protocol === 'http:'
|
|
2055
|
+
&& (r.hostname === '127.0.0.1' || r.hostname === '[::1]')
|
|
2056
|
+
&& r.hostname === i.hostname;
|
|
2057
|
+
if (!isLoopback)
|
|
2058
|
+
return false;
|
|
2059
|
+
return (
|
|
2060
|
+
r.protocol === i.protocol
|
|
2061
|
+
&& r.pathname === i.pathname
|
|
2062
|
+
&& r.search === i.search
|
|
2063
|
+
);
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
/**
|
|
2067
|
+
* RFC 6749 §3.3 scope-intersection helper.
|
|
2068
|
+
*
|
|
2069
|
+
* - When `clientAllowed` is `null` (legacy / unset), the request scope is
|
|
2070
|
+
* passed through unchanged.
|
|
2071
|
+
* - When `clientAllowed` is `[]`, no scope is allowed — returns `''`.
|
|
2072
|
+
* - Otherwise returns the intersection as a space-separated string,
|
|
2073
|
+
* preserving the original ordering of the requested scopes (so RPs see
|
|
2074
|
+
* the scopes back in the order they asked for, minus the dropped ones).
|
|
2075
|
+
*/
|
|
2076
|
+
function pendingFlowLookupWhere(flowId: string | number): WhereClause[] {
|
|
2077
|
+
// Accept numeric ids for direct legacy/test calls, but every newly-created
|
|
2078
|
+
// flow returns and routes by the opaque `flowId` token. This closes the
|
|
2079
|
+
// enumerable consent-flow IDOR vector without breaking internal migration
|
|
2080
|
+
// tooling that may still have an integer id in hand.
|
|
2081
|
+
return typeof flowId === 'number'
|
|
2082
|
+
? [{ field: 'id', operator: '=', value: flowId }]
|
|
2083
|
+
: [{ field: 'flowId', operator: '=', value: flowId }];
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
function intersectScope(
|
|
2087
|
+
requested: string | null,
|
|
2088
|
+
clientAllowedJson: string | null | undefined,
|
|
2089
|
+
): string {
|
|
2090
|
+
// No requested scope: empty result, regardless of client allow-list.
|
|
2091
|
+
if (!requested)
|
|
2092
|
+
return '';
|
|
2093
|
+
// Legacy v0 clients (created before `allowedScopes` existed) have a
|
|
2094
|
+
// missing column — the adapter returns `null` or `undefined`. Treat
|
|
2095
|
+
// both as "unset" and pass scope through.
|
|
2096
|
+
if (clientAllowedJson == null)
|
|
2097
|
+
return requested;
|
|
2098
|
+
const allowed = JSON.parse(clientAllowedJson) as string[];
|
|
2099
|
+
const allowedSet = new Set(allowed);
|
|
2100
|
+
return requested
|
|
2101
|
+
.split(' ')
|
|
2102
|
+
.filter(Boolean)
|
|
2103
|
+
.filter(s => allowedSet.has(s))
|
|
2104
|
+
.join(' ');
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
export { generateCodeChallenge, generateCodeVerifier, verifyCodeChallenge } from './pkce';
|
|
2108
|
+
|
|
2109
|
+
/**
|
|
2110
|
+
* Map a fortress user record to an OIDC-Core-§5.1 claims object, gated by
|
|
2111
|
+
* the access token's scope per §5.4.
|
|
2112
|
+
*
|
|
2113
|
+
* Exported so host apps with a custom `handleUserInfoRequest` (e.g. one
|
|
2114
|
+
* that adds tenant claims) can compose on top of the same baseline rather
|
|
2115
|
+
* than re-implementing the OIDC shape from scratch.
|
|
2116
|
+
*/
|
|
2117
|
+
export function toOidcUserinfo(
|
|
2118
|
+
user: FortressUser,
|
|
2119
|
+
scope: string | null | undefined,
|
|
2120
|
+
): Record<string, unknown> {
|
|
2121
|
+
const scopes = scope ? scope.split(' ').filter(Boolean) : [];
|
|
2122
|
+
// Identity claims are always scope-gated, including when a generic OAuth
|
|
2123
|
+
// token calls this endpoint. Possessing an unrelated API scope must not
|
|
2124
|
+
// disclose profile PII.
|
|
2125
|
+
const exposeEmail = scopes.includes('email');
|
|
2126
|
+
const exposeProfile = scopes.includes('profile');
|
|
2127
|
+
|
|
2128
|
+
const claims: Record<string, unknown> = { sub: String(user.id) };
|
|
2129
|
+
if (exposeEmail) {
|
|
2130
|
+
claims.email = user.email;
|
|
2131
|
+
if (typeof user.emailVerified === 'boolean')
|
|
2132
|
+
claims.email_verified = user.emailVerified;
|
|
2133
|
+
}
|
|
2134
|
+
if (exposeProfile) {
|
|
2135
|
+
claims.name = user.name;
|
|
2136
|
+
// OIDC has no canonical "username" field on a fortress user record
|
|
2137
|
+
// (email serves that role); use email as the stable, human-readable
|
|
2138
|
+
// identifier RPs expect in `preferred_username`.
|
|
2139
|
+
claims.preferred_username = user.email;
|
|
2140
|
+
}
|
|
2141
|
+
if (user.updatedAt) {
|
|
2142
|
+
claims.updated_at = Math.floor(new Date(user.updatedAt).getTime() / 1000);
|
|
2143
|
+
}
|
|
2144
|
+
return claims;
|
|
2145
|
+
}
|