@udibo/oauth2 0.1.0
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/LICENSE +20 -0
- package/README.md +174 -0
- package/SECURITY.md +77 -0
- package/esm/_dnt.polyfills.d.ts +17 -0
- package/esm/_dnt.polyfills.js +15 -0
- package/esm/adapters/hono/_common.d.ts +66 -0
- package/esm/adapters/hono/_common.js +108 -0
- package/esm/adapters/hono/authorization-server.d.ts +211 -0
- package/esm/adapters/hono/authorization-server.js +214 -0
- package/esm/adapters/hono/bff/auth-request-store.d.ts +164 -0
- package/esm/adapters/hono/bff/auth-request-store.js +154 -0
- package/esm/adapters/hono/bff/bff.d.ts +852 -0
- package/esm/adapters/hono/bff/bff.js +1194 -0
- package/esm/adapters/hono/bff/cookie-prefix.d.ts +72 -0
- package/esm/adapters/hono/bff/cookie-prefix.js +85 -0
- package/esm/adapters/hono/bff/mod.d.ts +19 -0
- package/esm/adapters/hono/bff/mod.js +19 -0
- package/esm/adapters/hono/bff/proxy.d.ts +108 -0
- package/esm/adapters/hono/bff/proxy.js +314 -0
- package/esm/adapters/hono/bff/session-store.d.ts +250 -0
- package/esm/adapters/hono/bff/session-store.js +216 -0
- package/esm/adapters/hono/bff/testing.d.ts +165 -0
- package/esm/adapters/hono/bff/testing.js +254 -0
- package/esm/adapters/hono/identity.d.ts +117 -0
- package/esm/adapters/hono/identity.js +215 -0
- package/esm/adapters/hono/log.d.ts +49 -0
- package/esm/adapters/hono/log.js +66 -0
- package/esm/adapters/hono/resource-server.d.ts +101 -0
- package/esm/adapters/hono/resource-server.js +108 -0
- package/esm/client/_http.d.ts +91 -0
- package/esm/client/_http.js +303 -0
- package/esm/client/base.d.ts +184 -0
- package/esm/client/base.js +93 -0
- package/esm/client/bff-client.d.ts +235 -0
- package/esm/client/bff-client.js +280 -0
- package/esm/client/browser-storage.d.ts +152 -0
- package/esm/client/browser-storage.js +246 -0
- package/esm/client/check.d.ts +114 -0
- package/esm/client/check.js +97 -0
- package/esm/client/direct-client.d.ts +707 -0
- package/esm/client/direct-client.js +1237 -0
- package/esm/client/discovery-cache.d.ts +138 -0
- package/esm/client/discovery-cache.js +124 -0
- package/esm/client/events.d.ts +54 -0
- package/esm/client/events.js +37 -0
- package/esm/client/mod.d.ts +35 -0
- package/esm/client/mod.js +33 -0
- package/esm/client/storage.d.ts +127 -0
- package/esm/client/storage.js +81 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/almost_equals.d.ts +29 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/almost_equals.js +47 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/array_includes.d.ts +28 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/array_includes.js +65 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/assert.d.ts +16 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/assert.js +23 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/assertion_error.d.ts +25 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/assertion_error.js +30 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/equal.d.ts +24 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/equal.js +193 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/equals.d.ts +36 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/equals.js +61 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/exists.d.ts +17 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/exists.js +27 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/fail.d.ts +14 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/fail.js +20 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/false.d.ts +18 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/false.js +23 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/greater.d.ts +19 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/greater.js +29 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/greater_or_equal.d.ts +19 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/greater_or_equal.js +29 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/instance_of.d.ts +22 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/instance_of.js +52 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/is_error.d.ts +24 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/is_error.js +53 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/less.d.ts +18 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/less.js +28 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/less_or_equal.d.ts +19 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/less_or_equal.js +29 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/match.d.ts +17 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/match.js +26 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/mod.d.ts +43 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/mod.js +45 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/not_equals.d.ts +26 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/not_equals.js +39 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/not_instance_of.d.ts +19 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/not_instance_of.js +29 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/not_match.d.ts +17 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/not_match.js +26 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/not_strict_equals.d.ts +22 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/not_strict_equals.js +32 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/object_match.d.ts +28 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/object_match.js +177 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/rejects.d.ts +41 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/rejects.js +53 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/strict_equals.d.ts +23 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/strict_equals.js +58 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/string_includes.d.ts +17 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/string_includes.js +26 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/throws.d.ts +44 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/throws.js +44 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/unimplemented.d.ts +14 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/unimplemented.js +20 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/unreachable.d.ts +14 -0
- package/esm/deps/jsr.io/@std/assert/1.0.19/unreachable.js +20 -0
- package/esm/deps/jsr.io/@std/async/1.5.0/delay.d.ts +44 -0
- package/esm/deps/jsr.io/@std/async/1.5.0/delay.js +84 -0
- package/esm/deps/jsr.io/@std/crypto/1.1.0/timing_safe_equal.d.ts +39 -0
- package/esm/deps/jsr.io/@std/crypto/1.1.0/timing_safe_equal.js +61 -0
- package/esm/deps/jsr.io/@std/encoding/1.0.11/_common16.d.ts +22 -0
- package/esm/deps/jsr.io/@std/encoding/1.0.11/_common16.js +51 -0
- package/esm/deps/jsr.io/@std/encoding/1.0.11/_common32.d.ts +34 -0
- package/esm/deps/jsr.io/@std/encoding/1.0.11/_common32.js +192 -0
- package/esm/deps/jsr.io/@std/encoding/1.0.11/_common64.d.ts +34 -0
- package/esm/deps/jsr.io/@std/encoding/1.0.11/_common64.js +113 -0
- package/esm/deps/jsr.io/@std/encoding/1.0.11/_common_detach.d.ts +3 -0
- package/esm/deps/jsr.io/@std/encoding/1.0.11/_common_detach.js +13 -0
- package/esm/deps/jsr.io/@std/encoding/1.0.11/_types.d.ts +8 -0
- package/esm/deps/jsr.io/@std/encoding/1.0.11/_types.js +2 -0
- package/esm/deps/jsr.io/@std/encoding/1.0.11/_validate_binary_like.d.ts +1 -0
- package/esm/deps/jsr.io/@std/encoding/1.0.11/_validate_binary_like.js +26 -0
- package/esm/deps/jsr.io/@std/encoding/1.0.11/ascii85.d.ts +60 -0
- package/esm/deps/jsr.io/@std/encoding/1.0.11/ascii85.js +152 -0
- package/esm/deps/jsr.io/@std/encoding/1.0.11/base32.d.ts +39 -0
- package/esm/deps/jsr.io/@std/encoding/1.0.11/base32.js +87 -0
- package/esm/deps/jsr.io/@std/encoding/1.0.11/base58.d.ts +39 -0
- package/esm/deps/jsr.io/@std/encoding/1.0.11/base58.js +131 -0
- package/esm/deps/jsr.io/@std/encoding/1.0.11/base64.d.ts +39 -0
- package/esm/deps/jsr.io/@std/encoding/1.0.11/base64.js +82 -0
- package/esm/deps/jsr.io/@std/encoding/1.0.11/base64url.d.ts +39 -0
- package/esm/deps/jsr.io/@std/encoding/1.0.11/base64url.js +72 -0
- package/esm/deps/jsr.io/@std/encoding/1.0.11/hex.d.ts +38 -0
- package/esm/deps/jsr.io/@std/encoding/1.0.11/hex.js +87 -0
- package/esm/deps/jsr.io/@std/encoding/1.0.11/mod.d.ts +97 -0
- package/esm/deps/jsr.io/@std/encoding/1.0.11/mod.js +99 -0
- package/esm/deps/jsr.io/@std/encoding/1.0.11/varint.d.ts +119 -0
- package/esm/deps/jsr.io/@std/encoding/1.0.11/varint.js +209 -0
- package/esm/deps/jsr.io/@std/http/1.1.3/status.d.ts +343 -0
- package/esm/deps/jsr.io/@std/http/1.1.3/status.js +343 -0
- package/esm/deps/jsr.io/@std/internal/1.0.14/assertion_state.d.ts +159 -0
- package/esm/deps/jsr.io/@std/internal/1.0.14/assertion_state.js +223 -0
- package/esm/deps/jsr.io/@std/internal/1.0.14/build_message.d.ts +79 -0
- package/esm/deps/jsr.io/@std/internal/1.0.14/build_message.js +116 -0
- package/esm/deps/jsr.io/@std/internal/1.0.14/diff.d.ts +139 -0
- package/esm/deps/jsr.io/@std/internal/1.0.14/diff.js +277 -0
- package/esm/deps/jsr.io/@std/internal/1.0.14/diff_str.d.ts +98 -0
- package/esm/deps/jsr.io/@std/internal/1.0.14/diff_str.js +181 -0
- package/esm/deps/jsr.io/@std/internal/1.0.14/format.d.ts +29 -0
- package/esm/deps/jsr.io/@std/internal/1.0.14/format.js +68 -0
- package/esm/deps/jsr.io/@std/internal/1.0.14/styles.d.ts +158 -0
- package/esm/deps/jsr.io/@std/internal/1.0.14/styles.js +206 -0
- package/esm/deps/jsr.io/@std/internal/1.0.14/types.d.ts +24 -0
- package/esm/deps/jsr.io/@std/internal/1.0.14/types.js +3 -0
- package/esm/deps/jsr.io/@std/testing/1.0.20/_test_suite.d.ts +91 -0
- package/esm/deps/jsr.io/@std/testing/1.0.20/_test_suite.js +347 -0
- package/esm/deps/jsr.io/@std/testing/1.0.20/bdd.d.ts +382 -0
- package/esm/deps/jsr.io/@std/testing/1.0.20/bdd.js +1110 -0
- package/esm/deps/jsr.io/@udibo/http-error/0.11.1/mod.d.ts +363 -0
- package/esm/deps/jsr.io/@udibo/http-error/0.11.1/mod.js +616 -0
- package/esm/errors.d.ts +203 -0
- package/esm/errors.js +287 -0
- package/esm/identity/captcha.d.ts +147 -0
- package/esm/identity/captcha.js +64 -0
- package/esm/identity/delivery.d.ts +96 -0
- package/esm/identity/delivery.js +41 -0
- package/esm/identity/errors.d.ts +39 -0
- package/esm/identity/errors.js +46 -0
- package/esm/identity/events.d.ts +242 -0
- package/esm/identity/events.js +39 -0
- package/esm/identity/external/_shared.d.ts +86 -0
- package/esm/identity/external/_shared.js +107 -0
- package/esm/identity/external/_token-exchange.d.ts +61 -0
- package/esm/identity/external/_token-exchange.js +179 -0
- package/esm/identity/external/apple-client-secret.d.ts +82 -0
- package/esm/identity/external/apple-client-secret.js +133 -0
- package/esm/identity/external/apple.d.ts +64 -0
- package/esm/identity/external/apple.js +248 -0
- package/esm/identity/external/discord.d.ts +38 -0
- package/esm/identity/external/discord.js +62 -0
- package/esm/identity/external/errors.d.ts +48 -0
- package/esm/identity/external/errors.js +36 -0
- package/esm/identity/external/flow.d.ts +107 -0
- package/esm/identity/external/flow.js +158 -0
- package/esm/identity/external/github.d.ts +42 -0
- package/esm/identity/external/github.js +164 -0
- package/esm/identity/external/google.d.ts +52 -0
- package/esm/identity/external/google.js +38 -0
- package/esm/identity/external/mod.d.ts +63 -0
- package/esm/identity/external/mod.js +61 -0
- package/esm/identity/external/oauth2.d.ts +110 -0
- package/esm/identity/external/oauth2.js +158 -0
- package/esm/identity/external/oidc.d.ts +81 -0
- package/esm/identity/external/oidc.js +250 -0
- package/esm/identity/external/provider.d.ts +132 -0
- package/esm/identity/external/provider.js +12 -0
- package/esm/identity/hibp.d.ts +67 -0
- package/esm/identity/hibp.js +87 -0
- package/esm/identity/identifier.d.ts +44 -0
- package/esm/identity/identifier.js +47 -0
- package/esm/identity/lockout.d.ts +155 -0
- package/esm/identity/lockout.js +117 -0
- package/esm/identity/mfa/mod.d.ts +42 -0
- package/esm/identity/mfa/mod.js +42 -0
- package/esm/identity/mfa/recovery-codes.d.ts +96 -0
- package/esm/identity/mfa/recovery-codes.js +80 -0
- package/esm/identity/mfa/service.d.ts +338 -0
- package/esm/identity/mfa/service.js +351 -0
- package/esm/identity/mfa/totp.d.ts +186 -0
- package/esm/identity/mfa/totp.js +194 -0
- package/esm/identity/migration.d.ts +155 -0
- package/esm/identity/migration.js +243 -0
- package/esm/identity/mod.d.ts +65 -0
- package/esm/identity/mod.js +64 -0
- package/esm/identity/otp.d.ts +255 -0
- package/esm/identity/otp.js +245 -0
- package/esm/identity/password-policy.d.ts +45 -0
- package/esm/identity/password-policy.js +60 -0
- package/esm/identity/password.d.ts +171 -0
- package/esm/identity/password.js +160 -0
- package/esm/identity/rate-limit.d.ts +192 -0
- package/esm/identity/rate-limit.js +168 -0
- package/esm/identity/service.d.ts +579 -0
- package/esm/identity/service.js +835 -0
- package/esm/identity/session.d.ts +122 -0
- package/esm/identity/session.js +48 -0
- package/esm/identity/token-flow.d.ts +214 -0
- package/esm/identity/token-flow.js +173 -0
- package/esm/models/authentication.d.ts +24 -0
- package/esm/models/authentication.js +1 -0
- package/esm/models/authorization-code.d.ts +63 -0
- package/esm/models/authorization-code.js +1 -0
- package/esm/models/authorization.d.ts +165 -0
- package/esm/models/authorization.js +160 -0
- package/esm/models/client.d.ts +78 -0
- package/esm/models/client.js +1 -0
- package/esm/models/device-authorization.d.ts +30 -0
- package/esm/models/device-authorization.js +1 -0
- package/esm/models/responses.d.ts +93 -0
- package/esm/models/responses.js +1 -0
- package/esm/models/scope.d.ts +81 -0
- package/esm/models/scope.js +125 -0
- package/esm/models/token.d.ts +135 -0
- package/esm/models/token.js +1 -0
- package/esm/package.json +3 -0
- package/esm/react/callback.d.ts +52 -0
- package/esm/react/callback.js +71 -0
- package/esm/react/components/class-names.d.ts +91 -0
- package/esm/react/components/class-names.js +118 -0
- package/esm/react/components/internal.d.ts +62 -0
- package/esm/react/components/internal.js +102 -0
- package/esm/react/components/mfa-challenge-form.d.ts +66 -0
- package/esm/react/components/mfa-challenge-form.js +46 -0
- package/esm/react/components/mfa-enrollment-form.d.ts +83 -0
- package/esm/react/components/mfa-enrollment-form.js +41 -0
- package/esm/react/components/mod.d.ts +103 -0
- package/esm/react/components/mod.js +93 -0
- package/esm/react/components/request-password-reset-form.d.ts +61 -0
- package/esm/react/components/request-password-reset-form.js +41 -0
- package/esm/react/components/reset-password-form.d.ts +66 -0
- package/esm/react/components/reset-password-form.js +56 -0
- package/esm/react/components/sign-in-form.d.ts +74 -0
- package/esm/react/components/sign-in-form.js +43 -0
- package/esm/react/components/sign-up-form.d.ts +85 -0
- package/esm/react/components/sign-up-form.js +57 -0
- package/esm/react/components/types.d.ts +208 -0
- package/esm/react/components/types.js +14 -0
- package/esm/react/components/use-auth-form.d.ts +118 -0
- package/esm/react/components/use-auth-form.js +172 -0
- package/esm/react/components/user-menu.d.ts +79 -0
- package/esm/react/components/user-menu.js +73 -0
- package/esm/react/context.d.ts +51 -0
- package/esm/react/context.js +16 -0
- package/esm/react/mod.d.ts +26 -0
- package/esm/react/mod.js +26 -0
- package/esm/react/provider.d.ts +40 -0
- package/esm/react/provider.js +189 -0
- package/esm/react/require-auth.d.ts +31 -0
- package/esm/react/require-auth.js +37 -0
- package/esm/react/testing.d.ts +322 -0
- package/esm/react/testing.js +461 -0
- package/esm/react/use-authorization.d.ts +31 -0
- package/esm/react/use-authorization.js +36 -0
- package/esm/react/use-oauth2.d.ts +112 -0
- package/esm/react/use-oauth2.js +127 -0
- package/esm/server/authorization-server.d.ts +736 -0
- package/esm/server/authorization-server.js +1304 -0
- package/esm/server/client-authentication.d.ts +28 -0
- package/esm/server/client-authentication.js +51 -0
- package/esm/server/grants/authorization-code.d.ts +179 -0
- package/esm/server/grants/authorization-code.js +232 -0
- package/esm/server/grants/client-credentials.d.ts +52 -0
- package/esm/server/grants/client-credentials.js +53 -0
- package/esm/server/grants/device-authorization.d.ts +103 -0
- package/esm/server/grants/device-authorization.js +156 -0
- package/esm/server/grants/grant.d.ts +165 -0
- package/esm/server/grants/grant.js +173 -0
- package/esm/server/grants/password.d.ts +80 -0
- package/esm/server/grants/password.js +92 -0
- package/esm/server/grants/refresh-token.d.ts +80 -0
- package/esm/server/grants/refresh-token.js +136 -0
- package/esm/server/introspection-token-reader.d.ts +153 -0
- package/esm/server/introspection-token-reader.js +164 -0
- package/esm/server/jwks-token-reader.d.ts +263 -0
- package/esm/server/jwks-token-reader.js +508 -0
- package/esm/server/mod.d.ts +23 -0
- package/esm/server/mod.js +15 -0
- package/esm/server/public-suffix/mod.d.ts +62 -0
- package/esm/server/public-suffix/mod.js +103 -0
- package/esm/server/public-suffix/rules.d.ts +23 -0
- package/esm/server/public-suffix/rules.js +23 -0
- package/esm/server/redirect-uri.d.ts +187 -0
- package/esm/server/redirect-uri.js +331 -0
- package/esm/server/resource-server.d.ts +286 -0
- package/esm/server/resource-server.js +365 -0
- package/esm/server/services/authorization-code.d.ts +84 -0
- package/esm/server/services/authorization-code.js +35 -0
- package/esm/server/services/client.d.ts +35 -0
- package/esm/server/services/client.js +1 -0
- package/esm/server/services/device-authorization.d.ts +134 -0
- package/esm/server/services/device-authorization.js +7 -0
- package/esm/server/services/token.d.ts +242 -0
- package/esm/server/services/token.js +78 -0
- package/esm/server/services/user.d.ts +29 -0
- package/esm/server/services/user.js +7 -0
- package/esm/server/signing-keys.d.ts +201 -0
- package/esm/server/signing-keys.js +225 -0
- package/esm/server/utils/hash.d.ts +6 -0
- package/esm/server/utils/hash.js +11 -0
- package/esm/testing/contract/authorization-code.d.ts +37 -0
- package/esm/testing/contract/authorization-code.js +93 -0
- package/esm/testing/contract/client.d.ts +85 -0
- package/esm/testing/contract/client.js +126 -0
- package/esm/testing/contract/device-authorization.d.ts +37 -0
- package/esm/testing/contract/device-authorization.js +193 -0
- package/esm/testing/contract/lockout-store.d.ts +47 -0
- package/esm/testing/contract/lockout-store.js +122 -0
- package/esm/testing/contract/mfa-store.d.ts +50 -0
- package/esm/testing/contract/mfa-store.js +226 -0
- package/esm/testing/contract/mod.d.ts +39 -0
- package/esm/testing/contract/mod.js +39 -0
- package/esm/testing/contract/otp-store.d.ts +48 -0
- package/esm/testing/contract/otp-store.js +197 -0
- package/esm/testing/contract/rate-limit-store.d.ts +52 -0
- package/esm/testing/contract/rate-limit-store.js +105 -0
- package/esm/testing/contract/token-flow-store.d.ts +54 -0
- package/esm/testing/contract/token-flow-store.js +179 -0
- package/esm/testing/contract/token-reader.d.ts +77 -0
- package/esm/testing/contract/token-reader.js +78 -0
- package/esm/testing/contract/token.d.ts +61 -0
- package/esm/testing/contract/token.js +287 -0
- package/esm/testing/contract/user.d.ts +63 -0
- package/esm/testing/contract/user.js +75 -0
- package/esm/testing/mod.d.ts +34 -0
- package/esm/testing/mod.js +34 -0
- package/esm/testing/server.d.ts +197 -0
- package/esm/testing/server.js +193 -0
- package/esm/testing/services.d.ts +373 -0
- package/esm/testing/services.js +703 -0
- package/esm/utils/_buffer.d.ts +10 -0
- package/esm/utils/_buffer.js +12 -0
- package/esm/utils/_timing-safe.d.ts +13 -0
- package/esm/utils/_timing-safe.js +22 -0
- package/esm/utils/authentication-context.d.ts +3 -0
- package/esm/utils/authentication-context.js +14 -0
- package/esm/utils/basic-auth.d.ts +50 -0
- package/esm/utils/basic-auth.js +106 -0
- package/esm/utils/crypto.d.ts +97 -0
- package/esm/utils/crypto.js +162 -0
- package/esm/utils/pkce.d.ts +151 -0
- package/esm/utils/pkce.js +179 -0
- package/esm/utils/text.d.ts +24 -0
- package/esm/utils/text.js +28 -0
- package/esm/utils/url.d.ts +66 -0
- package/esm/utils/url.js +90 -0
- package/package.json +92 -0
|
@@ -0,0 +1,1194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backend-for-Frontend (BFF) adapter for Hono.
|
|
3
|
+
*
|
|
4
|
+
* Wraps a {@link DirectClient} configured with a client secret
|
|
5
|
+
* and exposes the session endpoints a browser SPA talks to:
|
|
6
|
+
*
|
|
7
|
+
* - `POST /auth/login` — redirects the browser to the authorize endpoint,
|
|
8
|
+
* persisting a per-request state/verifier record and binding its `state` to
|
|
9
|
+
* this browser with a short-lived cookie.
|
|
10
|
+
* - `GET /auth/callback` — requires the browser to present that binding
|
|
11
|
+
* cookie, exchanges the code, opens a session (or, in
|
|
12
|
+
* `sessionMode: "shared"`, attaches the tokens to the app's existing
|
|
13
|
+
* session), and redirects to the app's `return_to` path.
|
|
14
|
+
* - `GET|POST /auth/logout` — destroys the session and (best-effort) revokes
|
|
15
|
+
* the refresh token upstream. Registered for both methods because the
|
|
16
|
+
* shipped React client navigates the browser here (a GET).
|
|
17
|
+
* - `GET /auth/session` — returns `{ isAuthenticated, user }` from the
|
|
18
|
+
* session. **Never** exposes tokens to the browser.
|
|
19
|
+
* - `POST /auth/backchannel` — OIDC Back-Channel Logout receiver, mounted only
|
|
20
|
+
* when `backchannelLogout` is configured. A server-to-server call from the OP
|
|
21
|
+
* that ends the matching server-side session(s).
|
|
22
|
+
*
|
|
23
|
+
* A companion {@link HonoBff.attachToken} middleware reads the session
|
|
24
|
+
* cookie, looks up / refreshes the access token, and sets
|
|
25
|
+
* `Authorization: Bearer <token>` on the inbound request so a co-located
|
|
26
|
+
* resource-server adapter can validate it normally — all in-process, no
|
|
27
|
+
* outbound HTTP.
|
|
28
|
+
*
|
|
29
|
+
* The credentialed surface is **CSRF-protected by default**: a request that
|
|
30
|
+
* carries the session cookie must also carry a custom `x-csrf` header (the
|
|
31
|
+
* shipped `BffClient` and React adapter send it automatically), and the
|
|
32
|
+
* `GET /auth/logout` navigation — which cannot carry one, and is what the
|
|
33
|
+
* shipped client uses — is guarded by a same-origin check that fails closed.
|
|
34
|
+
* `POST /auth/logout` is the stronger path to adopt. See
|
|
35
|
+
* {@link HonoBffCsrfOptions}; pass `csrf: false` to disable.
|
|
36
|
+
*
|
|
37
|
+
* Sign-in itself is bound to the browser that started it, and that binding is
|
|
38
|
+
* **not** configurable: `/auth/login` records the `state` it minted in a
|
|
39
|
+
* short-lived `HttpOnly` `SameSite=Lax` cookie, and `/auth/callback` refuses
|
|
40
|
+
* any authorization response whose `state` that cookie does not vouch for. A
|
|
41
|
+
* pending authorization is therefore never completable from a browser other
|
|
42
|
+
* than the one that began it, whatever
|
|
43
|
+
* {@link HonoBffOptions.authRequestStorage} it is kept in — without it an
|
|
44
|
+
* attacker could start a sign-in and hand the victim the callback URL to log
|
|
45
|
+
* the victim into the attacker's account.
|
|
46
|
+
*
|
|
47
|
+
* @module
|
|
48
|
+
*/
|
|
49
|
+
import { Hono } from "hono";
|
|
50
|
+
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
|
51
|
+
import { InvalidRequestError, InvalidTokenError, isOAuth2Error, TemporarilyUnavailableError, } from "../../../errors.js";
|
|
52
|
+
import { timingSafeMatchIndex } from "../../../utils/_timing-safe.js";
|
|
53
|
+
import { sha256Hash } from "../../../server/utils/hash.js";
|
|
54
|
+
import { OAUTH2_CONTEXT_KEY } from "../_common.js";
|
|
55
|
+
import { loginContinuation as computeLoginContinuation, safeReturnTo, } from "../../../utils/url.js";
|
|
56
|
+
import { resolveCookieName, resolvePrefixConstrainedAttributes, } from "./cookie-prefix.js";
|
|
57
|
+
import { DEFAULT_SESSION_MAX_AGE_MS, MemorySessionStore, sessionStoreMaxAgeMs, supportsBackchannelLogout, } from "./session-store.js";
|
|
58
|
+
import { buildDownstreamHeaders, buildUpstreamHeaders, isInvalidTokenChallenge, resolveForwardHeaders, resolveProxyUrl, } from "./proxy.js";
|
|
59
|
+
/**
|
|
60
|
+
* Whether the authorization server definitively rejected the grant, as opposed
|
|
61
|
+
* to failing to answer. Only these end the session.
|
|
62
|
+
*/
|
|
63
|
+
function isRevokedGrant(error) {
|
|
64
|
+
if (!isOAuth2Error(error))
|
|
65
|
+
return false;
|
|
66
|
+
const code = error.extensions.error;
|
|
67
|
+
return code === "invalid_grant" || code === "invalid_token";
|
|
68
|
+
}
|
|
69
|
+
const BFF_PATH_KEYS = [
|
|
70
|
+
"login",
|
|
71
|
+
"callback",
|
|
72
|
+
"logout",
|
|
73
|
+
"session",
|
|
74
|
+
"backchannel",
|
|
75
|
+
];
|
|
76
|
+
function resolvePaths(paths = {}, options) {
|
|
77
|
+
const basePath = (paths.basePath ?? "/auth").replace(/\/+$/, "");
|
|
78
|
+
const resolved = {
|
|
79
|
+
basePath,
|
|
80
|
+
login: paths.login ?? "/auth/login",
|
|
81
|
+
callback: paths.callback ?? "/auth/callback",
|
|
82
|
+
logout: paths.logout ?? "/auth/logout",
|
|
83
|
+
session: paths.session ?? "/auth/session",
|
|
84
|
+
backchannel: paths.backchannel ?? "/auth/backchannel",
|
|
85
|
+
};
|
|
86
|
+
if (basePath === "")
|
|
87
|
+
return resolved;
|
|
88
|
+
const stray = BFF_PATH_KEYS
|
|
89
|
+
.filter((key) => key !== "backchannel" || options.backchannelMounted)
|
|
90
|
+
.filter((key) => !resolved[key].startsWith(`${basePath}/`))
|
|
91
|
+
.map((key) => `${key} ("${resolved[key]}")`);
|
|
92
|
+
if (stray.length > 0) {
|
|
93
|
+
throw new Error(`HonoBff paths must sit under paths.basePath "${basePath}", but ` +
|
|
94
|
+
`${stray.join(", ")} ${stray.length === 1 ? "does" : "do"} not. ` +
|
|
95
|
+
`routes() strips the base to mount each handler while ` +
|
|
96
|
+
`loginContinuation() and the session probe's logoutUrl hand out the ` +
|
|
97
|
+
`unstripped paths, so a stray path is served at one URL and ` +
|
|
98
|
+
`advertised at another. Move it under "${basePath}/", or set ` +
|
|
99
|
+
`paths.basePath to "" to mount every path verbatim.`);
|
|
100
|
+
}
|
|
101
|
+
return resolved;
|
|
102
|
+
}
|
|
103
|
+
function sessionCookieName(opts) {
|
|
104
|
+
return resolveCookieName({
|
|
105
|
+
name: opts?.name,
|
|
106
|
+
base: "oauth2_session",
|
|
107
|
+
attributes: resolvePrefixConstrainedAttributes(opts),
|
|
108
|
+
consequence: "the session would silently never persist",
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
function resolveCsrf(opt) {
|
|
112
|
+
if (opt === false)
|
|
113
|
+
return null;
|
|
114
|
+
return {
|
|
115
|
+
headerName: (opt?.headerName ?? "x-csrf").toLowerCase(),
|
|
116
|
+
headerValue: opt?.headerValue,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const RESERVED_AUTHORIZE_PARAMS = [
|
|
120
|
+
"response_type",
|
|
121
|
+
"client_id",
|
|
122
|
+
"redirect_uri",
|
|
123
|
+
"state",
|
|
124
|
+
"code_challenge",
|
|
125
|
+
"code_challenge_method",
|
|
126
|
+
"response_mode",
|
|
127
|
+
"nonce",
|
|
128
|
+
"request",
|
|
129
|
+
"request_uri",
|
|
130
|
+
];
|
|
131
|
+
const RESERVED_AUTHORIZE_PARAM_SET = new Set(RESERVED_AUTHORIZE_PARAMS);
|
|
132
|
+
function assertUsableAuthorizeParamName(name, option) {
|
|
133
|
+
if (name === "" || name !== name.trim()) {
|
|
134
|
+
throw new Error(`${option} may not use ${JSON.stringify(name)} as an ` +
|
|
135
|
+
"authorize-parameter name: a name must be non-empty and free of " +
|
|
136
|
+
"surrounding whitespace. A blank name addresses nothing at the " +
|
|
137
|
+
"authorization server, and a padded one is not the parameter it " +
|
|
138
|
+
`looks like — " state" is sent as "+state", so it would slip past ` +
|
|
139
|
+
"the reserved list below while reading as a name on that list. " +
|
|
140
|
+
"Splitting a list out of an environment variable without trimming " +
|
|
141
|
+
"is the usual source.");
|
|
142
|
+
}
|
|
143
|
+
if (!RESERVED_AUTHORIZE_PARAM_SET.has(name.toLowerCase()))
|
|
144
|
+
return;
|
|
145
|
+
throw new Error(`${option} may not name the authorize parameter "${name}". The login ` +
|
|
146
|
+
`flow reserves ${RESERVED_AUTHORIZE_PARAMS.join(", ")}, and must be ` +
|
|
147
|
+
"the only source of their values: one supplied from outside it would " +
|
|
148
|
+
"variously redirect the authorization code, break the PKCE binding " +
|
|
149
|
+
"the token exchange depends on, unpick the state the login-state " +
|
|
150
|
+
"cookie commits to, move the authorization response off the callback " +
|
|
151
|
+
"URL, pass off replay protection nothing verifies, or supersede the " +
|
|
152
|
+
"parameters beside it. None of them has a supported override.");
|
|
153
|
+
}
|
|
154
|
+
function resolveExtraAuthorizeParams(options) {
|
|
155
|
+
const fixed = Object.freeze({ ...options.extraParams });
|
|
156
|
+
const forwarded = Object.freeze([...(options.forwardedParams ?? [])]);
|
|
157
|
+
const pinnedByFoldedName = new Map();
|
|
158
|
+
for (const name of Object.keys(fixed)) {
|
|
159
|
+
assertUsableAuthorizeParamName(name, "extraParams");
|
|
160
|
+
if (name.toLowerCase() === "scope" && options.scope !== undefined) {
|
|
161
|
+
throw new Error(`extraParams may not set ${JSON.stringify(name)} while ` +
|
|
162
|
+
"HonoBffOptions.scope is set: both fix the scope every authorize " +
|
|
163
|
+
"request carries, so which one a sign-in should send would be " +
|
|
164
|
+
"ambiguous. The two are compared case-insensitively, since an " +
|
|
165
|
+
"authorization server that folds parameter-name case would read " +
|
|
166
|
+
"them as one parameter. Keep HonoBffOptions.scope, which is also " +
|
|
167
|
+
"the value the login flow records as the scope it requested.");
|
|
168
|
+
}
|
|
169
|
+
pinnedByFoldedName.set(name.toLowerCase(), name);
|
|
170
|
+
}
|
|
171
|
+
for (const name of forwarded) {
|
|
172
|
+
assertUsableAuthorizeParamName(name, "forwardedParams");
|
|
173
|
+
if (["acr_values", "max_age"].includes(name.toLowerCase())) {
|
|
174
|
+
throw new Error(`forwardedParams may not name the authentication control "${name}"; ` +
|
|
175
|
+
"pin it in extraParams so the server chooses the assurance and freshness requirements.");
|
|
176
|
+
}
|
|
177
|
+
const pinned = pinnedByFoldedName.get(name.toLowerCase());
|
|
178
|
+
if (pinned !== undefined) {
|
|
179
|
+
throw new Error(`forwardedParams may not list "${name}" while extraParams sets ` +
|
|
180
|
+
`"${pinned}": one pins the value every authorize request carries ` +
|
|
181
|
+
"and the other lets the browser choose it, so which value a " +
|
|
182
|
+
"sign-in should send would be ambiguous. The two are compared " +
|
|
183
|
+
"case-insensitively, since an authorization server that folds " +
|
|
184
|
+
"parameter-name case would read them as one parameter. Keep the " +
|
|
185
|
+
"fixed value in extraParams, or list the name in forwardedParams " +
|
|
186
|
+
"alone.");
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return { fixed, forwarded };
|
|
190
|
+
}
|
|
191
|
+
function authorizeParamsForRequest(resolved, requestParams) {
|
|
192
|
+
const entries = Object.entries(resolved.fixed);
|
|
193
|
+
for (const name of resolved.forwarded) {
|
|
194
|
+
const value = requestParams.get(name);
|
|
195
|
+
if (value !== null)
|
|
196
|
+
entries.push([name, value]);
|
|
197
|
+
}
|
|
198
|
+
const resolvedParams = {};
|
|
199
|
+
const extraEntries = [];
|
|
200
|
+
for (const [name, value] of entries) {
|
|
201
|
+
const folded = name.toLowerCase();
|
|
202
|
+
if (folded === "scope")
|
|
203
|
+
resolvedParams.scope = value || undefined;
|
|
204
|
+
else if (folded === "prompt")
|
|
205
|
+
resolvedParams.prompt = value;
|
|
206
|
+
else
|
|
207
|
+
extraEntries.push([name, value]);
|
|
208
|
+
}
|
|
209
|
+
if (extraEntries.length > 0) {
|
|
210
|
+
resolvedParams.extraParams = Object.fromEntries(extraEntries);
|
|
211
|
+
}
|
|
212
|
+
return resolvedParams;
|
|
213
|
+
}
|
|
214
|
+
function cookieOpts(opts, maxAgeSeconds) {
|
|
215
|
+
const { secure, path, domain } = resolvePrefixConstrainedAttributes(opts);
|
|
216
|
+
return {
|
|
217
|
+
path,
|
|
218
|
+
secure,
|
|
219
|
+
httpOnly: opts?.httpOnly ?? true,
|
|
220
|
+
sameSite: (opts?.sameSite ?? "Strict"),
|
|
221
|
+
domain,
|
|
222
|
+
...(maxAgeSeconds === undefined ? {} : { maxAge: maxAgeSeconds }),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
const MAX_COOKIE_MAX_AGE_SECONDS = 400 * 24 * 60 * 60;
|
|
226
|
+
const MAX_SESSION_MAX_AGE_MS = MAX_COOKIE_MAX_AGE_SECONDS * 1000;
|
|
227
|
+
function positiveSeconds(value, field) {
|
|
228
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
229
|
+
throw new Error(`${field} must be a positive number of seconds, got ${value}.`);
|
|
230
|
+
}
|
|
231
|
+
if (value > MAX_COOKIE_MAX_AGE_SECONDS) {
|
|
232
|
+
throw new Error(`${field} must not exceed 400 days (${MAX_COOKIE_MAX_AGE_SECONDS} ` +
|
|
233
|
+
`seconds), got ${value}. Browsers cap cookie lifetimes there, so a ` +
|
|
234
|
+
"larger value would fail when the cookie is written, not here.");
|
|
235
|
+
}
|
|
236
|
+
return value;
|
|
237
|
+
}
|
|
238
|
+
function positiveMs(value, field) {
|
|
239
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
240
|
+
throw new Error(`${field} must be a positive number of milliseconds, got ${value}.`);
|
|
241
|
+
}
|
|
242
|
+
if (value > MAX_SESSION_MAX_AGE_MS) {
|
|
243
|
+
throw new Error(`${field} must not exceed 400 days (${MAX_SESSION_MAX_AGE_MS} ` +
|
|
244
|
+
`milliseconds), got ${value}. Browsers cap cookie lifetimes there, so ` +
|
|
245
|
+
"a larger value would fail when the cookie is written, not here.");
|
|
246
|
+
}
|
|
247
|
+
return value;
|
|
248
|
+
}
|
|
249
|
+
const LOGIN_STATE_BASE_NAME = "oauth2_login_state";
|
|
250
|
+
const DEFAULT_LOGIN_STATE_TTL_MS = 10 * 60 * 1000;
|
|
251
|
+
const MAX_PENDING_LOGIN_STATES = 3;
|
|
252
|
+
const LOGIN_STATE_SEPARATOR = ".";
|
|
253
|
+
function loginStateCookieName(opts) {
|
|
254
|
+
return resolveCookieName({
|
|
255
|
+
name: undefined,
|
|
256
|
+
base: LOGIN_STATE_BASE_NAME,
|
|
257
|
+
attributes: resolvePrefixConstrainedAttributes(opts),
|
|
258
|
+
consequence: "every sign-in would fail at the callback",
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
function loginStateCookieOpts(opts, maxAgeSeconds) {
|
|
262
|
+
const { secure, path, domain } = resolvePrefixConstrainedAttributes(opts);
|
|
263
|
+
return {
|
|
264
|
+
path,
|
|
265
|
+
secure,
|
|
266
|
+
httpOnly: true,
|
|
267
|
+
sameSite: "Lax",
|
|
268
|
+
...(domain ? { domain } : {}),
|
|
269
|
+
...(maxAgeSeconds === undefined ? {} : { maxAge: maxAgeSeconds }),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Backend-for-Frontend adapter for Hono. Construct once per app, mount
|
|
274
|
+
* `routes()` under `/auth`, and use `attachToken()` as middleware on
|
|
275
|
+
* protected API routes. Apps that own their login surface (a custom sign-in
|
|
276
|
+
* form, create-account, forgot-password) call `loginContinuation()` to hand
|
|
277
|
+
* back to the OAuth2 flow after authenticating a user.
|
|
278
|
+
*/
|
|
279
|
+
export class HonoBff {
|
|
280
|
+
#options;
|
|
281
|
+
#client;
|
|
282
|
+
#store;
|
|
283
|
+
#paths;
|
|
284
|
+
#cookieName;
|
|
285
|
+
#loginStateCookieName;
|
|
286
|
+
#loginStateTtlMs;
|
|
287
|
+
#sessionMaxAgeMs;
|
|
288
|
+
#cookieMaxAgeSeconds;
|
|
289
|
+
#warnedLogoutIsLocalOnly = false;
|
|
290
|
+
#sessionMode;
|
|
291
|
+
#csrf;
|
|
292
|
+
#extraAuthorizeParams;
|
|
293
|
+
#refreshes = new Map();
|
|
294
|
+
/**
|
|
295
|
+
* Defaults are applied for the session store ({@linkcode MemorySessionStore}),
|
|
296
|
+
* session mode (`"own"`), cookie name, and CSRF.
|
|
297
|
+
*
|
|
298
|
+
* @throws {Error} When {@linkcode HonoBffOptions.backchannelLogout} is
|
|
299
|
+
* configured against a store that cannot enumerate sessions (e.g.
|
|
300
|
+
* {@linkcode EncryptedCookieSessionStore}), when an explicit
|
|
301
|
+
* {@linkcode HonoBffCookieOptions.name} carries a `__Host-`/`__Secure-`
|
|
302
|
+
* prefix the other cookie options contradict, when
|
|
303
|
+
* {@linkcode HonoBffCookieOptions.sameSite} is `"None"` without
|
|
304
|
+
* {@linkcode HonoBffCookieOptions.secure}, when any
|
|
305
|
+
* {@linkcode HonoBffPaths} entry falls outside a non-empty
|
|
306
|
+
* {@linkcode HonoBffPaths.basePath}, when the session cookie would die
|
|
307
|
+
* before the session behind it (see
|
|
308
|
+
* {@linkcode HonoBffOptions.sessionMaxAgeMs}), or when
|
|
309
|
+
* {@linkcode HonoBffOptions.extraParams} or
|
|
310
|
+
* {@linkcode HonoBffOptions.forwardedParams} names a reserved authorize
|
|
311
|
+
* parameter, gives one a name that is empty or not equal to its trimmed
|
|
312
|
+
* form (`" state"` and `"organization "` are refused rather than trimmed),
|
|
313
|
+
* names the same parameter in both, or pins `scope` in
|
|
314
|
+
* {@linkcode HonoBffOptions.extraParams} while
|
|
315
|
+
* {@linkcode HonoBffOptions.scope} is also set.
|
|
316
|
+
*/
|
|
317
|
+
constructor(options) {
|
|
318
|
+
this.#options = options;
|
|
319
|
+
this.#client = options.client;
|
|
320
|
+
this.#store = options.sessionStore ?? new MemorySessionStore();
|
|
321
|
+
this.#paths = resolvePaths(options.paths, {
|
|
322
|
+
backchannelMounted: options.backchannelLogout !== undefined,
|
|
323
|
+
});
|
|
324
|
+
this.#cookieName = sessionCookieName(options.cookie);
|
|
325
|
+
this.#loginStateCookieName = loginStateCookieName(options.cookie);
|
|
326
|
+
this.#loginStateTtlMs = options.loginStateTtlMs ??
|
|
327
|
+
DEFAULT_LOGIN_STATE_TTL_MS;
|
|
328
|
+
this.#sessionMode = options.sessionMode ?? "own";
|
|
329
|
+
this.#csrf = resolveCsrf(options.csrf);
|
|
330
|
+
this.#extraAuthorizeParams = resolveExtraAuthorizeParams(options);
|
|
331
|
+
const sessionMaxAgeMs = positiveMs(options.sessionMaxAgeMs ?? DEFAULT_SESSION_MAX_AGE_MS, "sessionMaxAgeMs");
|
|
332
|
+
this.#sessionMaxAgeMs = sessionMaxAgeMs;
|
|
333
|
+
if (this.#sessionMode === "shared") {
|
|
334
|
+
const maxAge = options.cookie?.maxAge;
|
|
335
|
+
this.#cookieMaxAgeSeconds = maxAge === undefined || maxAge === "session"
|
|
336
|
+
? undefined
|
|
337
|
+
: positiveSeconds(maxAge, "cookie.maxAge");
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
const configuredMaxAge = options.cookie?.maxAge ??
|
|
341
|
+
Math.ceil(sessionMaxAgeMs / 1000);
|
|
342
|
+
this.#cookieMaxAgeSeconds = configuredMaxAge === "session"
|
|
343
|
+
? undefined
|
|
344
|
+
: positiveSeconds(configuredMaxAge, "cookie.maxAge");
|
|
345
|
+
if (this.#cookieMaxAgeSeconds !== undefined) {
|
|
346
|
+
const storeMaxAgeMs = sessionStoreMaxAgeMs(this.#store);
|
|
347
|
+
const sessionSeconds = Math.ceil(Math.max(sessionMaxAgeMs, storeMaxAgeMs ?? 0) / 1000);
|
|
348
|
+
if (this.#cookieMaxAgeSeconds < sessionSeconds) {
|
|
349
|
+
const cookieSource = options.cookie?.maxAge !== undefined
|
|
350
|
+
? `cookie.maxAge (${this.#cookieMaxAgeSeconds}s)`
|
|
351
|
+
: `the session cookie's Max-Age (${this.#cookieMaxAgeSeconds}s, ` +
|
|
352
|
+
"taken from sessionMaxAgeMs)";
|
|
353
|
+
const storeDrivesIt = (storeMaxAgeMs ?? 0) > sessionMaxAgeMs;
|
|
354
|
+
const remedy = storeDrivesIt
|
|
355
|
+
? `Set sessionMaxAgeMs to ${sessionSeconds * 1000} so it matches ` +
|
|
356
|
+
"the session store's maxAgeMs, or lower that maxAgeMs to match " +
|
|
357
|
+
"the lifetime you want."
|
|
358
|
+
: "Raise cookie.maxAge, lower sessionMaxAgeMs (and the " +
|
|
359
|
+
'store\'s maxAgeMs) to match, or pass cookie.maxAge: "session" ' +
|
|
360
|
+
"for a deliberately ephemeral cookie.";
|
|
361
|
+
throw new Error(`${cookieSource} is shorter than the session lifetime ` +
|
|
362
|
+
`(${sessionSeconds}s): the browser drops the cookie while the ` +
|
|
363
|
+
"session stays active and listed, so the user is signed out " +
|
|
364
|
+
`with no way to reach the session they still have. ${remedy}`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (options.backchannelLogout && !supportsBackchannelLogout(this.#store)) {
|
|
369
|
+
throw new Error("backchannelLogout requires a SessionStore that implements " +
|
|
370
|
+
"destroyByLogout() (a stateful store). The stateless " +
|
|
371
|
+
"EncryptedCookieSessionStore cannot enumerate sessions.");
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Cookie name used for the session cookie. Read-only; configured via
|
|
376
|
+
* {@link HonoBffOptions.cookie}.
|
|
377
|
+
*/
|
|
378
|
+
get cookieName() {
|
|
379
|
+
return this.#cookieName;
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Name of the cookie `/auth/login` binds the pending sign-in's `state` to
|
|
383
|
+
* and `/auth/callback` requires. Read-only; derived from
|
|
384
|
+
* {@link HonoBffOptions.cookie}'s `secure` / `path` / `domain` (it carries
|
|
385
|
+
* the `__Host-` prefix whenever they allow it). A test that drives the
|
|
386
|
+
* login flow by hand must carry this cookie from the login response into
|
|
387
|
+
* the callback request, exactly as a browser would.
|
|
388
|
+
*/
|
|
389
|
+
get loginStateCookieName() {
|
|
390
|
+
return this.#loginStateCookieName;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* The CSRF header name required on credentialed requests, or `undefined`
|
|
394
|
+
* when CSRF is disabled. Useful in tests that seed a session and then call a
|
|
395
|
+
* protected route — they must send `{ [bff.csrfHeaderName!]: "1" }` alongside
|
|
396
|
+
* the cookie. The shipped `BffClient` / React adapter send it
|
|
397
|
+
* automatically in real apps.
|
|
398
|
+
*/
|
|
399
|
+
get csrfHeaderName() {
|
|
400
|
+
return this.#csrf?.headerName;
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* The underlying session store. Exposed primarily for testing helpers
|
|
404
|
+
* (e.g. seeding a session directly without driving the login flow);
|
|
405
|
+
* production code should not reach in here.
|
|
406
|
+
*/
|
|
407
|
+
get sessionStore() {
|
|
408
|
+
return this.#store;
|
|
409
|
+
}
|
|
410
|
+
#hasSessionCookie(c) {
|
|
411
|
+
return getCookie(c, this.#cookieName) !== undefined;
|
|
412
|
+
}
|
|
413
|
+
#cookieOptions() {
|
|
414
|
+
return cookieOpts(this.#options.cookie, this.#cookieMaxAgeSeconds);
|
|
415
|
+
}
|
|
416
|
+
#isBeyondSessionMaxAge(data) {
|
|
417
|
+
if (this.#sessionMode !== "own")
|
|
418
|
+
return false;
|
|
419
|
+
const { createdAt } = data;
|
|
420
|
+
if (!Number.isFinite(createdAt) || createdAt <= 0)
|
|
421
|
+
return false;
|
|
422
|
+
return Date.now() - createdAt > this.#sessionMaxAgeMs;
|
|
423
|
+
}
|
|
424
|
+
async #readLiveSession(c, cookieValue) {
|
|
425
|
+
const data = await this.#store.read(cookieValue);
|
|
426
|
+
if (!data)
|
|
427
|
+
return null;
|
|
428
|
+
if (!this.#isBeyondSessionMaxAge(data))
|
|
429
|
+
return data;
|
|
430
|
+
await this.#store.destroy(cookieValue);
|
|
431
|
+
deleteCookie(c, this.#cookieName, this.#cookieOptions());
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
#noStore(c) {
|
|
435
|
+
c.header("Cache-Control", "no-store");
|
|
436
|
+
c.header("Vary", "Cookie");
|
|
437
|
+
}
|
|
438
|
+
#loginStateHashes(c) {
|
|
439
|
+
const value = getCookie(c, this.#loginStateCookieName);
|
|
440
|
+
if (!value)
|
|
441
|
+
return [];
|
|
442
|
+
return value.split(LOGIN_STATE_SEPARATOR).filter((hash) => hash.length > 0);
|
|
443
|
+
}
|
|
444
|
+
#writeLoginStateHashes(c, hashes) {
|
|
445
|
+
if (hashes.length === 0) {
|
|
446
|
+
deleteCookie(c, this.#loginStateCookieName, loginStateCookieOpts(this.#options.cookie));
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
setCookie(c, this.#loginStateCookieName, hashes.slice(-MAX_PENDING_LOGIN_STATES).join(LOGIN_STATE_SEPARATOR), loginStateCookieOpts(this.#options.cookie, Math.ceil(this.#loginStateTtlMs / 1000)));
|
|
450
|
+
}
|
|
451
|
+
#derivedRedirectUri(c, origin) {
|
|
452
|
+
if (!this.#options.deriveRedirectUri)
|
|
453
|
+
return undefined;
|
|
454
|
+
const base = origin ?? new URL(c.req.url).origin;
|
|
455
|
+
return new URL(this.#paths.callback, base).toString();
|
|
456
|
+
}
|
|
457
|
+
#csrfHeaderOk(c) {
|
|
458
|
+
if (!this.#csrf)
|
|
459
|
+
return true;
|
|
460
|
+
const value = c.req.header(this.#csrf.headerName);
|
|
461
|
+
if (this.#csrf.headerValue !== undefined) {
|
|
462
|
+
return value === this.#csrf.headerValue;
|
|
463
|
+
}
|
|
464
|
+
return typeof value === "string" && value.length > 0;
|
|
465
|
+
}
|
|
466
|
+
#sameOriginOk(c) {
|
|
467
|
+
const fetchSite = c.req.header("sec-fetch-site");
|
|
468
|
+
if (fetchSite)
|
|
469
|
+
return fetchSite === "same-origin" || fetchSite === "none";
|
|
470
|
+
const target = new URL(c.req.url).origin;
|
|
471
|
+
const check = (val) => {
|
|
472
|
+
if (!val)
|
|
473
|
+
return undefined;
|
|
474
|
+
try {
|
|
475
|
+
return new URL(val).origin === target;
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
return false;
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
const fromOrigin = check(c.req.header("origin"));
|
|
482
|
+
if (fromOrigin !== undefined)
|
|
483
|
+
return fromOrigin;
|
|
484
|
+
const fromReferer = check(c.req.header("referer"));
|
|
485
|
+
if (fromReferer !== undefined)
|
|
486
|
+
return fromReferer;
|
|
487
|
+
return false;
|
|
488
|
+
}
|
|
489
|
+
#csrfReject(c) {
|
|
490
|
+
return c.json({
|
|
491
|
+
error: "csrf_validation_failed",
|
|
492
|
+
error_description: "Missing or invalid CSRF header on a credentialed request.",
|
|
493
|
+
}, 403);
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Returns a Hono app with all session endpoints mounted, each registered
|
|
497
|
+
* relative to {@link HonoBffPaths.basePath} (default `/auth`). Mount it under
|
|
498
|
+
* that same base — `app.route("/auth", bff.routes())` — so the served URLs
|
|
499
|
+
* are the configured {@link HonoBffPaths}. To mount at the app root instead,
|
|
500
|
+
* set `paths.basePath` to `""`.
|
|
501
|
+
*/
|
|
502
|
+
routes() {
|
|
503
|
+
const app = new Hono();
|
|
504
|
+
const p = this.#pathsRelative();
|
|
505
|
+
app.get(p.login, this.loginHandler());
|
|
506
|
+
app.post(p.login, this.loginHandler());
|
|
507
|
+
app.get(p.callback, this.callbackHandler());
|
|
508
|
+
app.get(p.logout, this.logoutHandler());
|
|
509
|
+
app.post(p.logout, this.logoutHandler());
|
|
510
|
+
app.get(p.session, this.sessionHandler());
|
|
511
|
+
if (this.#options.backchannelLogout) {
|
|
512
|
+
app.post(p.backchannel, this.backchannelHandler());
|
|
513
|
+
}
|
|
514
|
+
return app;
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Builds the URL that (re)starts sign-in toward `returnTo`, for an app that
|
|
518
|
+
* owns its login surface (a custom sign-in form, create-account,
|
|
519
|
+
* forgot-password) and needs to hand back to the OAuth2/BFF flow after it
|
|
520
|
+
* authenticates the user.
|
|
521
|
+
*
|
|
522
|
+
* - When `returnTo` is an **in-flight authorize URL** — its path matches the
|
|
523
|
+
* client's authorization endpoint, i.e. the user was bounced to your login
|
|
524
|
+
* surface mid-authorize with the PKCE `state` already minted — it is
|
|
525
|
+
* returned unchanged so the flow **resumes** without a redundant
|
|
526
|
+
* `/auth/login` hop.
|
|
527
|
+
* - Otherwise it returns `${login}?return_to=<returnTo>` so the BFF **starts**
|
|
528
|
+
* a fresh login (mints `state` at the login endpoint, attaches tokens to the
|
|
529
|
+
* session at the callback).
|
|
530
|
+
*
|
|
531
|
+
* `returnTo` is open-redirect-guarded (`safeReturnTo`), so the result is
|
|
532
|
+
* always a safe same-origin path. This is the mechanism of the own-auth + BFF
|
|
533
|
+
* topology; the credential form, validation, and copy stay in your app. It
|
|
534
|
+
* replaces hand-rolled helpers that hardcode the authorize + login paths —
|
|
535
|
+
* the BFF already knows both from its client config and `paths`.
|
|
536
|
+
*
|
|
537
|
+
* ```ts
|
|
538
|
+
* // after your sign-in / create-account action authenticates the user:
|
|
539
|
+
* return c.redirect(bff.loginContinuation(returnTo));
|
|
540
|
+
* ```
|
|
541
|
+
*
|
|
542
|
+
* Note: the in-flight-authorize check needs the client's authorization
|
|
543
|
+
* endpoint to be known synchronously (an explicit `endpoints.authorization`,
|
|
544
|
+
* or after `client.discover()` has run). For an issuer-only client that has
|
|
545
|
+
* not discovered yet, every `returnTo` is treated as "start a fresh login".
|
|
546
|
+
*/
|
|
547
|
+
loginContinuation(returnTo) {
|
|
548
|
+
return computeLoginContinuation(returnTo, {
|
|
549
|
+
authorizeEndpoint: this.#client.authorizationEndpoint,
|
|
550
|
+
loginPath: this.#paths.login,
|
|
551
|
+
defaultReturnTo: this.#options.defaultReturnTo,
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
#pathsRelative() {
|
|
555
|
+
const { basePath } = this.#paths;
|
|
556
|
+
const strip = (p) => basePath && p.startsWith(`${basePath}/`) ? p.slice(basePath.length) : p;
|
|
557
|
+
return {
|
|
558
|
+
basePath,
|
|
559
|
+
login: strip(this.#paths.login),
|
|
560
|
+
callback: strip(this.#paths.callback),
|
|
561
|
+
logout: strip(this.#paths.logout),
|
|
562
|
+
session: strip(this.#paths.session),
|
|
563
|
+
backchannel: strip(this.#paths.backchannel),
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Login handler — builds the authorize URL, binds the `state` it minted to
|
|
568
|
+
* this browser with the login-state cookie, and redirects the browser.
|
|
569
|
+
*
|
|
570
|
+
* The authorize request carries every {@link HonoBffOptions.extraParams}
|
|
571
|
+
* entry, plus each {@link HonoBffOptions.forwardedParams} name this
|
|
572
|
+
* request's query string supplies a value for. Nothing else off this
|
|
573
|
+
* request's query string reaches the authorize URL: `scope` falls back to
|
|
574
|
+
* {@link HonoBffOptions.scope} and `prompt` is sent only when one of those
|
|
575
|
+
* two options supplies it.
|
|
576
|
+
*/
|
|
577
|
+
loginHandler() {
|
|
578
|
+
return async (c) => {
|
|
579
|
+
this.#noStore(c);
|
|
580
|
+
const returnTo = safeReturnTo(c.req.query("return_to"), this.#options.defaultReturnTo ?? "/");
|
|
581
|
+
const origin = this.#options.resolveOrigin?.(c);
|
|
582
|
+
const requested = authorizeParamsForRequest(this.#extraAuthorizeParams, new URL(c.req.url).searchParams);
|
|
583
|
+
const { url, state } = await this.#client.login({
|
|
584
|
+
returnTo,
|
|
585
|
+
scope: requested.scope ?? this.#options.scope,
|
|
586
|
+
origin,
|
|
587
|
+
prompt: requested.prompt,
|
|
588
|
+
extraParams: requested.extraParams,
|
|
589
|
+
redirectUri: this.#derivedRedirectUri(c, origin),
|
|
590
|
+
authRequestStorage: this.#options.authRequestStorage?.forRequest(c),
|
|
591
|
+
});
|
|
592
|
+
this.#writeLoginStateHashes(c, [
|
|
593
|
+
...this.#loginStateHashes(c),
|
|
594
|
+
await sha256Hash(state),
|
|
595
|
+
]);
|
|
596
|
+
return c.redirect(url);
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Callback handler — requires the login-state cookie to vouch for the
|
|
601
|
+
* returned `state`, then exchanges the code and opens a session.
|
|
602
|
+
*/
|
|
603
|
+
callbackHandler() {
|
|
604
|
+
return async (c) => {
|
|
605
|
+
this.#noStore(c);
|
|
606
|
+
const params = new URL(c.req.url).searchParams;
|
|
607
|
+
const fail = (error) => this.#options.onCallbackError
|
|
608
|
+
? this.#options.onCallbackError(c, error)
|
|
609
|
+
: c.json({ error: error.error, error_description: error.error_description }, 400);
|
|
610
|
+
if (params.get("error")) {
|
|
611
|
+
return await fail({
|
|
612
|
+
error: params.get("error"),
|
|
613
|
+
error_description: params.get("error_description") ?? "",
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
const code = params.get("code");
|
|
617
|
+
const state = params.get("state");
|
|
618
|
+
if (!code || !state) {
|
|
619
|
+
return await fail({
|
|
620
|
+
error: "invalid_request",
|
|
621
|
+
error_description: "missing code/state",
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
const pendingStates = this.#loginStateHashes(c);
|
|
625
|
+
const matched = timingSafeMatchIndex(pendingStates, await sha256Hash(state));
|
|
626
|
+
if (matched === undefined) {
|
|
627
|
+
return await fail({
|
|
628
|
+
error: "invalid_request",
|
|
629
|
+
error_description: "state was not started in this browser",
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
this.#writeLoginStateHashes(c, pendingStates.filter((_, index) => index !== matched));
|
|
633
|
+
try {
|
|
634
|
+
const origin = this.#options.resolveOrigin?.(c);
|
|
635
|
+
const { tokens, refreshToken, returnTo, raw } = await this.#client
|
|
636
|
+
.exchangeAuthorizationCode(code, state, {
|
|
637
|
+
origin,
|
|
638
|
+
redirectUri: this.#derivedRedirectUri(c, origin),
|
|
639
|
+
authRequestStorage: this.#options.authRequestStorage?.forRequest(c),
|
|
640
|
+
});
|
|
641
|
+
let user = null;
|
|
642
|
+
let sid;
|
|
643
|
+
const idToken = raw.id_token;
|
|
644
|
+
if (idToken) {
|
|
645
|
+
try {
|
|
646
|
+
const claims = this.#client.decodeIdToken(idToken);
|
|
647
|
+
user = claims;
|
|
648
|
+
const claimSid = claims.sid;
|
|
649
|
+
if (typeof claimSid === "string")
|
|
650
|
+
sid = claimSid;
|
|
651
|
+
}
|
|
652
|
+
catch {
|
|
653
|
+
user = null;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
if (this.#options.resolveUser) {
|
|
657
|
+
user = await this.#options.resolveUser(tokens, user);
|
|
658
|
+
}
|
|
659
|
+
const now = Date.now();
|
|
660
|
+
const data = {
|
|
661
|
+
tokens,
|
|
662
|
+
refreshToken,
|
|
663
|
+
user,
|
|
664
|
+
sid,
|
|
665
|
+
createdAt: now,
|
|
666
|
+
updatedAt: now,
|
|
667
|
+
};
|
|
668
|
+
const existing = this.#sessionMode === "shared"
|
|
669
|
+
? getCookie(c, this.#cookieName)
|
|
670
|
+
: undefined;
|
|
671
|
+
const cookieValue = existing
|
|
672
|
+
? await this.#store.update(existing, data)
|
|
673
|
+
: await this.#store.create(data);
|
|
674
|
+
if (cookieValue !== existing) {
|
|
675
|
+
setCookie(c, this.#cookieName, cookieValue, this.#cookieOptions());
|
|
676
|
+
}
|
|
677
|
+
return c.redirect(safeReturnTo(returnTo, this.#options.defaultReturnTo ?? "/"));
|
|
678
|
+
}
|
|
679
|
+
catch (cause) {
|
|
680
|
+
return await fail({
|
|
681
|
+
error: "invalid_grant",
|
|
682
|
+
error_description: "code exchange failed",
|
|
683
|
+
cause,
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
/** Logout handler — revokes the refresh token and clears the session cookie. */
|
|
689
|
+
logoutHandler() {
|
|
690
|
+
return async (c) => {
|
|
691
|
+
this.#noStore(c);
|
|
692
|
+
const cookieValue = getCookie(c, this.#cookieName);
|
|
693
|
+
if (cookieValue && this.#csrf) {
|
|
694
|
+
const ok = c.req.method === "GET"
|
|
695
|
+
? this.#sameOriginOk(c)
|
|
696
|
+
: this.#csrfHeaderOk(c);
|
|
697
|
+
if (!ok)
|
|
698
|
+
return this.#csrfReject(c);
|
|
699
|
+
}
|
|
700
|
+
let idToken;
|
|
701
|
+
if (cookieValue) {
|
|
702
|
+
const data = await this.#store.read(cookieValue);
|
|
703
|
+
idToken = data?.tokens?.idToken;
|
|
704
|
+
if (data?.refreshToken) {
|
|
705
|
+
try {
|
|
706
|
+
await this.#client.revoke(data.refreshToken, {
|
|
707
|
+
tokenTypeHint: "refresh_token",
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
catch {
|
|
711
|
+
// best-effort
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
await this.#store.destroy(cookieValue);
|
|
715
|
+
deleteCookie(c, this.#cookieName, this.#cookieOptions());
|
|
716
|
+
}
|
|
717
|
+
const returnTo = safeReturnTo(c.req.query("return_to"), this.#options.defaultReturnTo ?? "/");
|
|
718
|
+
if (this.#options.rpInitiatedLogout) {
|
|
719
|
+
const endSession = this.#client.endSessionEndpoint;
|
|
720
|
+
if (endSession) {
|
|
721
|
+
const origin = this.#options.resolveOrigin?.(c) ??
|
|
722
|
+
new URL(c.req.url).origin;
|
|
723
|
+
const url = new URL(endSession, origin);
|
|
724
|
+
if (idToken)
|
|
725
|
+
url.searchParams.set("id_token_hint", idToken);
|
|
726
|
+
url.searchParams.set("client_id", this.#client.clientId);
|
|
727
|
+
url.searchParams.set("post_logout_redirect_uri", new URL(returnTo, origin).toString());
|
|
728
|
+
return c.redirect(url.toString());
|
|
729
|
+
}
|
|
730
|
+
this.#warnLogoutIsLocalOnly();
|
|
731
|
+
}
|
|
732
|
+
return c.redirect(returnTo);
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
#warnLogoutIsLocalOnly() {
|
|
736
|
+
if (this.#warnedLogoutIsLocalOnly)
|
|
737
|
+
return;
|
|
738
|
+
this.#warnedLogoutIsLocalOnly = true;
|
|
739
|
+
console.warn("[@udibo/oauth2] rpInitiatedLogout is enabled but no end_session " +
|
|
740
|
+
"endpoint is known, so logout only clears this app's session — the " +
|
|
741
|
+
"identity provider's stays alive and the next sign-in will be " +
|
|
742
|
+
"silent. Give the client an `issuer` so discovery can find the " +
|
|
743
|
+
"endpoint, or add `endpoints.endSession` explicitly.");
|
|
744
|
+
}
|
|
745
|
+
/** Session probe — returns `{ isAuthenticated, user }` without tokens. */
|
|
746
|
+
sessionHandler() {
|
|
747
|
+
return async (c) => {
|
|
748
|
+
this.#noStore(c);
|
|
749
|
+
const cookieValue = getCookie(c, this.#cookieName);
|
|
750
|
+
if (!cookieValue) {
|
|
751
|
+
return c.json({ isAuthenticated: false, user: null });
|
|
752
|
+
}
|
|
753
|
+
if (this.#csrf && !this.#csrfHeaderOk(c))
|
|
754
|
+
return this.#csrfReject(c);
|
|
755
|
+
const data = await this.#readLiveSession(c, cookieValue);
|
|
756
|
+
if (!data) {
|
|
757
|
+
return c.json({ isAuthenticated: false, user: null });
|
|
758
|
+
}
|
|
759
|
+
const expiresAt = data.tokens.accessTokenExpiresAt;
|
|
760
|
+
const sessionExpiresIn = expiresAt !== undefined
|
|
761
|
+
? Math.max(0, Math.round((expiresAt - Date.now()) / 1000))
|
|
762
|
+
: null;
|
|
763
|
+
return c.json({
|
|
764
|
+
isAuthenticated: true,
|
|
765
|
+
user: data.user ?? null,
|
|
766
|
+
sessionExpiresIn,
|
|
767
|
+
logoutUrl: this.#paths.logout,
|
|
768
|
+
});
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* OIDC Back-Channel Logout receiver. A server-to-server `POST` from the OP
|
|
773
|
+
* carrying a signed `logout_token`; the configured verifier validates it (and
|
|
774
|
+
* returns the subject / OP session id), then the matching server-side
|
|
775
|
+
* session(s) are destroyed. Responds `200` on success and `400` on a
|
|
776
|
+
* missing/invalid token, with `Cache-Control: no-store`, per the spec.
|
|
777
|
+
*/
|
|
778
|
+
backchannelHandler() {
|
|
779
|
+
return async (c) => {
|
|
780
|
+
c.header("Cache-Control", "no-store");
|
|
781
|
+
const opts = this.#options.backchannelLogout;
|
|
782
|
+
if (!opts)
|
|
783
|
+
return c.json({ error: "not_supported" }, 400);
|
|
784
|
+
let logoutToken;
|
|
785
|
+
try {
|
|
786
|
+
logoutToken = (await c.req.formData()).get("logout_token")?.toString();
|
|
787
|
+
}
|
|
788
|
+
catch {
|
|
789
|
+
logoutToken = undefined;
|
|
790
|
+
}
|
|
791
|
+
if (!logoutToken) {
|
|
792
|
+
return c.json({
|
|
793
|
+
error: "invalid_request",
|
|
794
|
+
error_description: "missing logout_token",
|
|
795
|
+
}, 400);
|
|
796
|
+
}
|
|
797
|
+
let subject;
|
|
798
|
+
try {
|
|
799
|
+
subject = await opts.verifyLogoutToken(logoutToken);
|
|
800
|
+
}
|
|
801
|
+
catch {
|
|
802
|
+
subject = null;
|
|
803
|
+
}
|
|
804
|
+
if (!subject || (subject.sub === undefined && subject.sid === undefined)) {
|
|
805
|
+
return c.json({
|
|
806
|
+
error: "invalid_request",
|
|
807
|
+
error_description: "invalid logout_token",
|
|
808
|
+
}, 400);
|
|
809
|
+
}
|
|
810
|
+
if (!supportsBackchannelLogout(this.#store)) {
|
|
811
|
+
return c.json({ error: "not_supported" }, 400);
|
|
812
|
+
}
|
|
813
|
+
await this.#store.destroyByLogout({ sub: subject.sub, sid: subject.sid });
|
|
814
|
+
return c.body(null, 200);
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
async #resolveSessionEntry(c) {
|
|
818
|
+
const cookieValue = getCookie(c, this.#cookieName);
|
|
819
|
+
if (!cookieValue)
|
|
820
|
+
return { status: "anonymous" };
|
|
821
|
+
const data = await this.#readLiveSession(c, cookieValue);
|
|
822
|
+
if (!data)
|
|
823
|
+
return { status: "anonymous" };
|
|
824
|
+
const skew = (this.#options.refreshSkewSeconds ?? 30) * 1000;
|
|
825
|
+
const needsRefresh = data.tokens.accessTokenExpiresAt !== undefined
|
|
826
|
+
? data.tokens.accessTokenExpiresAt - skew < Date.now()
|
|
827
|
+
: false;
|
|
828
|
+
if (!needsRefresh || !data.refreshToken) {
|
|
829
|
+
return { status: "active", entry: { cookieValue, data } };
|
|
830
|
+
}
|
|
831
|
+
const outcome = await this.#refreshSessionEntry(c, { cookieValue, data });
|
|
832
|
+
if (outcome.status === "refreshed") {
|
|
833
|
+
return { status: "active", entry: outcome.entry };
|
|
834
|
+
}
|
|
835
|
+
return outcome.status === "unavailable"
|
|
836
|
+
? { status: "unavailable" }
|
|
837
|
+
: { status: "anonymous" };
|
|
838
|
+
}
|
|
839
|
+
#temporarilyUnavailable(c) {
|
|
840
|
+
return this.#errorResponse(c, new TemporarilyUnavailableError(502, "token refresh temporarily unavailable"));
|
|
841
|
+
}
|
|
842
|
+
async #refreshSessionEntry(c, entry) {
|
|
843
|
+
const outcome = await this.#sharedRefresh(entry);
|
|
844
|
+
if (outcome.status === "refreshed") {
|
|
845
|
+
if (outcome.entry.cookieValue !== entry.cookieValue) {
|
|
846
|
+
setCookie(c, this.#cookieName, outcome.entry.cookieValue, this.#cookieOptions());
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
else if (outcome.status === "revoked") {
|
|
850
|
+
deleteCookie(c, this.#cookieName, this.#cookieOptions());
|
|
851
|
+
}
|
|
852
|
+
return outcome;
|
|
853
|
+
}
|
|
854
|
+
#sharedRefresh(entry) {
|
|
855
|
+
const inFlight = this.#refreshes.get(entry.cookieValue);
|
|
856
|
+
if (inFlight)
|
|
857
|
+
return inFlight;
|
|
858
|
+
const pending = this.#exchangeRefresh(entry).finally(() => {
|
|
859
|
+
this.#refreshes.delete(entry.cookieValue);
|
|
860
|
+
});
|
|
861
|
+
this.#refreshes.set(entry.cookieValue, pending);
|
|
862
|
+
return pending;
|
|
863
|
+
}
|
|
864
|
+
async #exchangeRefresh(entry) {
|
|
865
|
+
const { cookieValue, data } = entry;
|
|
866
|
+
if (!data.refreshToken)
|
|
867
|
+
return { status: "unavailable" };
|
|
868
|
+
try {
|
|
869
|
+
const result = await this.#client.exchangeRefreshToken(data.refreshToken);
|
|
870
|
+
const refreshed = {
|
|
871
|
+
tokens: result.tokens,
|
|
872
|
+
refreshToken: result.refreshToken ?? data.refreshToken,
|
|
873
|
+
user: data.user,
|
|
874
|
+
sid: data.sid,
|
|
875
|
+
createdAt: data.createdAt,
|
|
876
|
+
updatedAt: Date.now(),
|
|
877
|
+
};
|
|
878
|
+
const newCookieValue = await this.#store.update(cookieValue, refreshed);
|
|
879
|
+
return {
|
|
880
|
+
status: "refreshed",
|
|
881
|
+
entry: { cookieValue: newCookieValue, data: refreshed },
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
catch (error) {
|
|
885
|
+
if (!isRevokedGrant(error))
|
|
886
|
+
return { status: "unavailable" };
|
|
887
|
+
const current = await this.#store.read(cookieValue);
|
|
888
|
+
if (current && current.refreshToken !== data.refreshToken) {
|
|
889
|
+
return { status: "refreshed", entry: { cookieValue, data: current } };
|
|
890
|
+
}
|
|
891
|
+
await this.#store.destroy(cookieValue);
|
|
892
|
+
return { status: "revoked" };
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
/**
|
|
896
|
+
* Middleware that reads the session cookie, refreshes the access token
|
|
897
|
+
* if it's near expiry, and sets `Authorization: Bearer <token>` on the
|
|
898
|
+
* inbound request so downstream middleware can read it from the
|
|
899
|
+
* standard header.
|
|
900
|
+
*
|
|
901
|
+
* Prefer {@link protect} for the common case — it authenticates against
|
|
902
|
+
* the configured `resourceServer` directly without mutating the
|
|
903
|
+
* request, so middleware ordering doesn't matter. Reach for
|
|
904
|
+
* `attachToken` only when composing with downstream middleware that
|
|
905
|
+
* expects a bearer token in the `Authorization` header (e.g. a custom
|
|
906
|
+
* resource-server-like guard, or an in-process proxy).
|
|
907
|
+
*
|
|
908
|
+
* **Ordering caveat**: mount `attachToken` *before* any middleware
|
|
909
|
+
* that reads the request body (CSRF parsers, validators, etc.).
|
|
910
|
+
* `attachToken` replaces `c.req.raw` with a clone that carries the
|
|
911
|
+
* Authorization header, which transfers the body stream from the
|
|
912
|
+
* original Request to the clone. Downstream middleware that reads
|
|
913
|
+
* through `c.req` is fine; middleware that captured a pre-clone
|
|
914
|
+
* reference to `c.req.raw` will see a "used" body on it.
|
|
915
|
+
*
|
|
916
|
+
* Unauthenticated requests fall through without a header — the
|
|
917
|
+
* downstream guard returns the proper 401 with `WWW-Authenticate`.
|
|
918
|
+
*/
|
|
919
|
+
attachToken() {
|
|
920
|
+
return async (c, next) => {
|
|
921
|
+
if (this.#csrf && this.#hasSessionCookie(c)) {
|
|
922
|
+
const inboundAuth = c.req.header("Authorization") ?? "";
|
|
923
|
+
const hasBearer = /^bearer\s+\S/i.test(inboundAuth);
|
|
924
|
+
if (!hasBearer && !this.#csrfHeaderOk(c))
|
|
925
|
+
return this.#csrfReject(c);
|
|
926
|
+
}
|
|
927
|
+
const resolution = await this.#resolveSessionEntry(c);
|
|
928
|
+
if (resolution.status === "unavailable") {
|
|
929
|
+
return this.#temporarilyUnavailable(c);
|
|
930
|
+
}
|
|
931
|
+
if (resolution.status !== "active") {
|
|
932
|
+
await next();
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
const headers = new Headers(c.req.raw.headers);
|
|
936
|
+
headers.set("Authorization", `Bearer ${resolution.entry.data.tokens.accessToken}`);
|
|
937
|
+
c.req.raw = new Request(c.req.raw, { headers });
|
|
938
|
+
await next();
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* Returns a Hono middleware that asserts an **already-authenticated**
|
|
943
|
+
* request (guarded upstream by {@link protect}) carries `requiredScope`,
|
|
944
|
+
* **without** re-validating the token — the per-route companion to
|
|
945
|
+
* `protect` for mounts whose routes need different scopes (e.g. `read` vs
|
|
946
|
+
* `write`). Delegates to the configured resource server's `requireScope`;
|
|
947
|
+
* see {@link HonoResourceServer.requireScope}.
|
|
948
|
+
*
|
|
949
|
+
* @throws {Error} When the BFF was constructed without a `resourceServer` —
|
|
950
|
+
* thrown when the middleware is built, not per request.
|
|
951
|
+
*
|
|
952
|
+
* @example
|
|
953
|
+
* ```ts
|
|
954
|
+
* app.use("/api/*", bff.protect()); // session cookie OR bearer
|
|
955
|
+
* app.get("/api/items", bff.requireScope("read"), list);
|
|
956
|
+
* app.post("/api/items", bff.requireScope("write"), create);
|
|
957
|
+
* ```
|
|
958
|
+
*/
|
|
959
|
+
requireScope(requiredScope) {
|
|
960
|
+
const resourceServer = this.#options.resourceServer;
|
|
961
|
+
if (!resourceServer) {
|
|
962
|
+
throw new Error("HonoBff.requireScope() requires a `resourceServer` option (the same " +
|
|
963
|
+
"one `protect` authenticates against).");
|
|
964
|
+
}
|
|
965
|
+
return resourceServer.requireScope(requiredScope);
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* Middleware that authenticates the request against the configured
|
|
969
|
+
* `resourceServer`, accepting **either** auth style so one guard protects an
|
|
970
|
+
* API used by both the BFF's own frontend and machine-to-machine clients:
|
|
971
|
+
*
|
|
972
|
+
* - **Browser frontend** — no `Authorization` header; the access token is
|
|
973
|
+
* resolved from the BFF session cookie (refreshed if near expiry) and
|
|
974
|
+
* validated. Available only when the BFF was constructed with a
|
|
975
|
+
* `resourceServer` option.
|
|
976
|
+
* - **Machine-to-machine** — an inbound `Authorization: Bearer <token>` is
|
|
977
|
+
* validated directly (e.g. a `client_credentials` token), so direct API
|
|
978
|
+
* clients work without a session cookie.
|
|
979
|
+
*
|
|
980
|
+
* Unlike `attachToken + resourceServer.protect()`, this does **not**
|
|
981
|
+
* mutate `c.req.raw` for the session path — the token is read from the
|
|
982
|
+
* session and handed to `resourceServer.authenticate()` directly, so
|
|
983
|
+
* middleware ordering doesn't matter and earlier middleware that already
|
|
984
|
+
* touched the request body is unaffected.
|
|
985
|
+
*
|
|
986
|
+
* ```ts
|
|
987
|
+
* app.use("/api/*", bff.protect("read")); // session cookie OR bearer token
|
|
988
|
+
* ```
|
|
989
|
+
*
|
|
990
|
+
* Without `resourceServer` configured, throws an error with a hint to
|
|
991
|
+
* either pass `resourceServer` or compose the middleware chain manually
|
|
992
|
+
* (`app.use(bff.attachToken(), resourceServer.protect(scope))`).
|
|
993
|
+
*/
|
|
994
|
+
protect(requiredScope) {
|
|
995
|
+
const resourceServer = this.#options.resourceServer;
|
|
996
|
+
if (!resourceServer) {
|
|
997
|
+
throw new Error("HonoBff.protect() requires a `resourceServer` option. Either pass " +
|
|
998
|
+
"one to the constructor or compose middleware manually: " +
|
|
999
|
+
"`app.use(bff.attachToken(), resourceServer.protect(scope))`.");
|
|
1000
|
+
}
|
|
1001
|
+
return async (c, next) => {
|
|
1002
|
+
try {
|
|
1003
|
+
const inboundAuth = c.req.header("Authorization") ?? "";
|
|
1004
|
+
const hasBearer = /^bearer\s+\S/i.test(inboundAuth);
|
|
1005
|
+
if (!hasBearer && this.#csrf && this.#hasSessionCookie(c) &&
|
|
1006
|
+
!this.#csrfHeaderOk(c)) {
|
|
1007
|
+
return this.#csrfReject(c);
|
|
1008
|
+
}
|
|
1009
|
+
let authRequest;
|
|
1010
|
+
if (hasBearer) {
|
|
1011
|
+
authRequest = c.req.raw;
|
|
1012
|
+
}
|
|
1013
|
+
else {
|
|
1014
|
+
const resolution = await this.#resolveSessionEntry(c);
|
|
1015
|
+
if (resolution.status === "unavailable") {
|
|
1016
|
+
return this.#temporarilyUnavailable(c);
|
|
1017
|
+
}
|
|
1018
|
+
if (resolution.status === "active") {
|
|
1019
|
+
const headers = new Headers(c.req.raw.headers);
|
|
1020
|
+
headers.set("Authorization", `Bearer ${resolution.entry.data.tokens.accessToken}`);
|
|
1021
|
+
authRequest = new Request(c.req.raw.url, { headers });
|
|
1022
|
+
}
|
|
1023
|
+
else {
|
|
1024
|
+
authRequest = new Request(c.req.raw.url);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
const ctx = await resourceServer.authenticate(authRequest, requiredScope);
|
|
1028
|
+
c.set(OAUTH2_CONTEXT_KEY, ctx);
|
|
1029
|
+
await next();
|
|
1030
|
+
}
|
|
1031
|
+
catch (error) {
|
|
1032
|
+
return resourceServer.handleAuthError(error);
|
|
1033
|
+
}
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
#errorResponse(c, error) {
|
|
1037
|
+
if (error.status === 401) {
|
|
1038
|
+
c.header("WWW-Authenticate", `Bearer error="${error.extensions.error ?? "invalid_token"}"`);
|
|
1039
|
+
}
|
|
1040
|
+
return c.json({
|
|
1041
|
+
error: error.extensions.error,
|
|
1042
|
+
error_description: error.message,
|
|
1043
|
+
}, error.status);
|
|
1044
|
+
}
|
|
1045
|
+
/**
|
|
1046
|
+
* Handler that forwards the request to a **separate** resource server with
|
|
1047
|
+
* the session's access token attached, and streams the response back — the
|
|
1048
|
+
* full-proxy BFF of the IETF "OAuth 2.0 for Browser-Based Apps" BCP §6.1.1.
|
|
1049
|
+
* Tokens stay server-side and the browser only ever talks to its own origin,
|
|
1050
|
+
* so there is no CORS to configure.
|
|
1051
|
+
*
|
|
1052
|
+
* Reach for this when the API lives in another service. When the resource
|
|
1053
|
+
* server is co-located in the same process, {@link protect} is faster and
|
|
1054
|
+
* simpler — it validates in-process with no outbound HTTP.
|
|
1055
|
+
*
|
|
1056
|
+
* Mount it as a catch-all and strip the mount prefix:
|
|
1057
|
+
*
|
|
1058
|
+
* @example
|
|
1059
|
+
* ```ts
|
|
1060
|
+
* app.all(
|
|
1061
|
+
* "/api/*",
|
|
1062
|
+
* bff.proxy("https://api.example.com/v1", { stripPrefix: "/api" }),
|
|
1063
|
+
* );
|
|
1064
|
+
* ```
|
|
1065
|
+
*
|
|
1066
|
+
* Semantics:
|
|
1067
|
+
*
|
|
1068
|
+
* - **Session only.** The access token comes from the session cookie
|
|
1069
|
+
* (refreshed when within {@link HonoBffOptions.refreshSkewSeconds} of
|
|
1070
|
+
* expiry, exactly as {@link protect} does). An inbound `Authorization`
|
|
1071
|
+
* header is *not* honored and never forwarded — point machine-to-machine
|
|
1072
|
+
* clients at the resource server directly. No session is `401`
|
|
1073
|
+
* `invalid_token`.
|
|
1074
|
+
* - **CSRF.** The same custom-header check the rest of the credentialed BFF
|
|
1075
|
+
* surface enforces, so mounting a proxy cannot reopen it. See
|
|
1076
|
+
* {@link HonoBffCsrfOptions}.
|
|
1077
|
+
* - **Retry.** An upstream `401` carrying `error="invalid_token"` triggers
|
|
1078
|
+
* one refresh-and-retry, when the session has a refresh token. A refresh
|
|
1079
|
+
* the authorization server rejects clears the session and answers `401`; a
|
|
1080
|
+
* refresh that merely fails to complete (network error, 5xx, 429) leaves
|
|
1081
|
+
* the session intact and answers `502` `temporarily_unavailable`. Bodied
|
|
1082
|
+
* requests are not retried (the body was streamed). Disable with
|
|
1083
|
+
* `retryOn401: false`.
|
|
1084
|
+
* - **Headers.** Only {@link DEFAULT_PROXY_FORWARD_HEADERS} (or your
|
|
1085
|
+
* `forwardHeaders`) travel upstream; the session cookie, inbound
|
|
1086
|
+
* `Authorization`, and hop-by-hop headers never do. Coming back, upstream
|
|
1087
|
+
* `Set-Cookie` and hop-by-hop headers are dropped and everything else —
|
|
1088
|
+
* including problem-details error bodies and their status — is passed
|
|
1089
|
+
* through verbatim.
|
|
1090
|
+
* - **Bodies stream** in both directions; nothing is buffered. Upstream
|
|
1091
|
+
* redirects are returned to the browser rather than followed, so the token
|
|
1092
|
+
* is never replayed to a `Location` the BFF did not vet.
|
|
1093
|
+
*
|
|
1094
|
+
* A `502` with `temporarily_unavailable` is returned when the upstream
|
|
1095
|
+
* cannot be reached, and a `400` `invalid_request` when a path segment has a
|
|
1096
|
+
* malformed percent-escape or hides a `..`.
|
|
1097
|
+
*
|
|
1098
|
+
* **Ordering caveat**: mount this before any middleware that reads the
|
|
1099
|
+
* request body — the proxy forwards `c.req.raw.body` as a stream, and a
|
|
1100
|
+
* middleware that already consumed it leaves nothing to forward.
|
|
1101
|
+
*
|
|
1102
|
+
* @param target Upstream base URL. Fixed here, never derived from the
|
|
1103
|
+
* request, so the proxy cannot be steered at another host. Query parameters
|
|
1104
|
+
* pinned on it are merged into every proxied request and win over an inbound
|
|
1105
|
+
* parameter of the same name.
|
|
1106
|
+
* @param options Prefix stripping, header allowlist, retry, and `fetch`
|
|
1107
|
+
* injection. See {@link HonoBffProxyOptions}.
|
|
1108
|
+
* @returns A Hono handler to mount on a catch-all route.
|
|
1109
|
+
*/
|
|
1110
|
+
proxy(target, options = {}) {
|
|
1111
|
+
const base = new URL(target);
|
|
1112
|
+
const allowedHeaders = resolveForwardHeaders(options.forwardHeaders, this.#csrf?.headerName);
|
|
1113
|
+
const fetchImpl = options.fetch ??
|
|
1114
|
+
((input, init) => fetch(input, init));
|
|
1115
|
+
const retryOn401 = options.retryOn401 ?? true;
|
|
1116
|
+
const mountPrefix = (options.stripPrefix ?? "").endsWith("/")
|
|
1117
|
+
? options.stripPrefix.slice(0, -1)
|
|
1118
|
+
: options.stripPrefix ?? "";
|
|
1119
|
+
return async (c) => {
|
|
1120
|
+
if (this.#csrf && this.#hasSessionCookie(c) && !this.#csrfHeaderOk(c)) {
|
|
1121
|
+
return this.#csrfReject(c);
|
|
1122
|
+
}
|
|
1123
|
+
let url;
|
|
1124
|
+
try {
|
|
1125
|
+
url = resolveProxyUrl(base, c.req.url, options.stripPrefix);
|
|
1126
|
+
}
|
|
1127
|
+
catch {
|
|
1128
|
+
return this.#errorResponse(c, new InvalidRequestError("invalid proxy path"));
|
|
1129
|
+
}
|
|
1130
|
+
const resolution = await this.#resolveSessionEntry(c);
|
|
1131
|
+
if (resolution.status === "unavailable") {
|
|
1132
|
+
return this.#temporarilyUnavailable(c);
|
|
1133
|
+
}
|
|
1134
|
+
if (resolution.status !== "active") {
|
|
1135
|
+
return this.#errorResponse(c, new InvalidTokenError("no active session"));
|
|
1136
|
+
}
|
|
1137
|
+
const entry = resolution.entry;
|
|
1138
|
+
const body = c.req.raw.body;
|
|
1139
|
+
const send = (accessToken, requestBody) => {
|
|
1140
|
+
const init = {
|
|
1141
|
+
method: c.req.method,
|
|
1142
|
+
headers: buildUpstreamHeaders(c.req.raw.headers, allowedHeaders, accessToken),
|
|
1143
|
+
body: requestBody,
|
|
1144
|
+
redirect: "manual",
|
|
1145
|
+
};
|
|
1146
|
+
if (requestBody)
|
|
1147
|
+
init.duplex = "half";
|
|
1148
|
+
return fetchImpl(url, init);
|
|
1149
|
+
};
|
|
1150
|
+
let response;
|
|
1151
|
+
try {
|
|
1152
|
+
response = await send(entry.data.tokens.accessToken, body);
|
|
1153
|
+
}
|
|
1154
|
+
catch (cause) {
|
|
1155
|
+
return this.#errorResponse(c, new TemporarilyUnavailableError(502, "upstream request failed", {
|
|
1156
|
+
cause,
|
|
1157
|
+
}));
|
|
1158
|
+
}
|
|
1159
|
+
const canRetry = retryOn401 && body === null &&
|
|
1160
|
+
entry.data.refreshToken !== undefined;
|
|
1161
|
+
if (canRetry && isInvalidTokenChallenge(response)) {
|
|
1162
|
+
await response.body?.cancel();
|
|
1163
|
+
const outcome = await this.#refreshSessionEntry(c, entry);
|
|
1164
|
+
if (outcome.status === "revoked") {
|
|
1165
|
+
return this.#errorResponse(c, new InvalidTokenError("session refresh failed"));
|
|
1166
|
+
}
|
|
1167
|
+
if (outcome.status === "unavailable") {
|
|
1168
|
+
return this.#temporarilyUnavailable(c);
|
|
1169
|
+
}
|
|
1170
|
+
try {
|
|
1171
|
+
response = await send(outcome.entry.data.tokens.accessToken, null);
|
|
1172
|
+
}
|
|
1173
|
+
catch (cause) {
|
|
1174
|
+
return this.#errorResponse(c, new TemporarilyUnavailableError(502, "upstream request failed", {
|
|
1175
|
+
cause,
|
|
1176
|
+
}));
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
const downstream = response.body;
|
|
1180
|
+
const init = {
|
|
1181
|
+
status: response.status,
|
|
1182
|
+
headers: buildDownstreamHeaders(response, {
|
|
1183
|
+
upstreamUrl: url,
|
|
1184
|
+
base,
|
|
1185
|
+
mountPrefix,
|
|
1186
|
+
}),
|
|
1187
|
+
};
|
|
1188
|
+
return downstream === null ? c.body(null, init) : c.body(downstream, {
|
|
1189
|
+
...init,
|
|
1190
|
+
status: response.status,
|
|
1191
|
+
});
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
1194
|
+
}
|