@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,1237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth2 client that talks to the authorization server directly and holds the
|
|
3
|
+
* resulting tokens (RFC 6749 + RFC 7636 PKCE + RFC 7009 revocation +
|
|
4
|
+
* RFC 7662 introspection + RFC 8414 metadata + RFC 8628 device authorization).
|
|
5
|
+
*
|
|
6
|
+
* Works against any RFC-compliant authorization server. Use it for SPAs
|
|
7
|
+
* without a Backend-for-Frontend, CLIs, mobile shells, and server-side apps —
|
|
8
|
+
* anywhere this process is the one that keeps the access and refresh tokens.
|
|
9
|
+
* When a BFF holds them instead, use `BffClient`.
|
|
10
|
+
*
|
|
11
|
+
* @module
|
|
12
|
+
*/
|
|
13
|
+
import { delay } from "../deps/jsr.io/@std/async/1.5.0/delay.js";
|
|
14
|
+
import { AccessDeniedError, InvalidGrantError, isOAuth2Error, OAuth2Error as OAuth2ErrorClass, ServerError, } from "../errors.js";
|
|
15
|
+
import { challengeMethods, generateCodeVerifier, generateState, } from "../utils/pkce.js";
|
|
16
|
+
import { encodeBasicAuth } from "../utils/basic-auth.js";
|
|
17
|
+
import { base64urlDecode } from "../utils/crypto.js";
|
|
18
|
+
import { sanitizeProviderText } from "../utils/text.js";
|
|
19
|
+
import { assertField, assertTokenResponse, errorFromResponse, receiveJson, sendGuarded, } from "./_http.js";
|
|
20
|
+
import { mergedHeaders, OAuth2ClientBase, SIGNED_OUT, } from "./base.js";
|
|
21
|
+
import { SessionStorageAuthRequestStorage } from "./browser-storage.js";
|
|
22
|
+
import { DEFAULT_DISCOVERY_TTL_MS, } from "./discovery-cache.js";
|
|
23
|
+
import { MemoryAuthRequestStorage, MemoryRefreshTokenStorage, MemoryTokenStorage, } from "./storage.js";
|
|
24
|
+
const REFRESH_SKEW_MS = 30_000;
|
|
25
|
+
/**
|
|
26
|
+
* OAuth2 client that drives the standard grants against an authorization
|
|
27
|
+
* server and keeps the tokens in its own storage.
|
|
28
|
+
*
|
|
29
|
+
* Public vs confidential is decided by whether `clientSecret` was supplied:
|
|
30
|
+
* with a secret the client authenticates via HTTP Basic (RFC 6749 §2.3.1) and
|
|
31
|
+
* may use `client_credentials` and introspection; without one it sends
|
|
32
|
+
* `client_id` in the body and is limited to the grants a public client may
|
|
33
|
+
* run.
|
|
34
|
+
*
|
|
35
|
+
* **Advanced — multi-session servers.**
|
|
36
|
+
* {@link exchangeAuthorizationCode} and {@link exchangeRefreshToken} are
|
|
37
|
+
* lower-level variants that bypass this client's token storage and event bus.
|
|
38
|
+
* They exist so server-side adapters (e.g. the Hono BFF) can drive many
|
|
39
|
+
* concurrent sessions through a single shared client without their tokens
|
|
40
|
+
* clobbering each other. SPA / mobile / CLI consumers should never reach for
|
|
41
|
+
* them — call {@link handleAuthorizationCallback} and {@link refresh}
|
|
42
|
+
* instead.
|
|
43
|
+
*
|
|
44
|
+
* @example SPA with PKCE against a discovered issuer
|
|
45
|
+
* ```ts
|
|
46
|
+
* import {
|
|
47
|
+
* DirectClient,
|
|
48
|
+
* IndexedDBRefreshTokenStorage,
|
|
49
|
+
* } from "@udibo/oauth2/client";
|
|
50
|
+
*
|
|
51
|
+
* const client = new DirectClient({
|
|
52
|
+
* clientId: "spa",
|
|
53
|
+
* issuer: "https://auth.example.com",
|
|
54
|
+
* redirectUri: "https://app.example.com/callback",
|
|
55
|
+
* scope: "openid profile",
|
|
56
|
+
* refreshTokenStorage: new IndexedDBRefreshTokenStorage({ clientId: "spa" }),
|
|
57
|
+
* });
|
|
58
|
+
*
|
|
59
|
+
* const { url } = await client.login({ returnTo: "/dashboard" });
|
|
60
|
+
* location.assign(url);
|
|
61
|
+
* ```
|
|
62
|
+
*
|
|
63
|
+
* @example Confidential client issuing a machine token
|
|
64
|
+
* ```ts
|
|
65
|
+
* import { DirectClient } from "@udibo/oauth2/client";
|
|
66
|
+
*
|
|
67
|
+
* const client = new DirectClient({
|
|
68
|
+
* clientId: "worker",
|
|
69
|
+
* clientSecret: Deno.env.get("CLIENT_SECRET")!,
|
|
70
|
+
* endpoints: { token: "https://auth.example.com/token" },
|
|
71
|
+
* });
|
|
72
|
+
*
|
|
73
|
+
* const tokens = await client.getClientCredentialsToken({ scope: "jobs:run" });
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
export class DirectClient extends OAuth2ClientBase {
|
|
77
|
+
#clientId;
|
|
78
|
+
#clientSecret;
|
|
79
|
+
#redirectUri;
|
|
80
|
+
#scope;
|
|
81
|
+
#issuer;
|
|
82
|
+
#discoveryCache;
|
|
83
|
+
#tokenStorage;
|
|
84
|
+
#refreshTokenStorage;
|
|
85
|
+
#authRequestStorage;
|
|
86
|
+
#authRequestTtlMs;
|
|
87
|
+
#endpoints;
|
|
88
|
+
#metadata;
|
|
89
|
+
#metadataExpiresAt = 0;
|
|
90
|
+
#discoveryPromise;
|
|
91
|
+
#refreshPromise;
|
|
92
|
+
#refreshSnapshot;
|
|
93
|
+
#sessionVersion = 0;
|
|
94
|
+
#sessionWrites = Promise.resolve();
|
|
95
|
+
#callbackPromises = new Map();
|
|
96
|
+
/**
|
|
97
|
+
* Builds a client for one OAuth2 client registration.
|
|
98
|
+
*
|
|
99
|
+
* @param options Must carry `clientId`, plus either `issuer` (endpoints come
|
|
100
|
+
* from discovery) or `endpoints` (discovery is skipped). Supply
|
|
101
|
+
* `clientSecret` for a confidential client; **omit the property entirely**
|
|
102
|
+
* for a public one.
|
|
103
|
+
* @throws {TypeError} when both `endpoints` and `issuer` are supplied (they
|
|
104
|
+
* are mutually exclusive), or when `clientSecret` is an empty string.
|
|
105
|
+
* @throws {Error} when a secret is supplied in a document context, where it
|
|
106
|
+
* would ship to every visitor in the bundle.
|
|
107
|
+
*/
|
|
108
|
+
constructor(options) {
|
|
109
|
+
super(options);
|
|
110
|
+
if (options.endpoints && options.issuer) {
|
|
111
|
+
throw new TypeError("pass either `endpoints` or `issuer`, not both");
|
|
112
|
+
}
|
|
113
|
+
assertUsableSecret(options.clientSecret);
|
|
114
|
+
this.#clientId = options.clientId;
|
|
115
|
+
this.#clientSecret = options.clientSecret;
|
|
116
|
+
this.#redirectUri = options.redirectUri;
|
|
117
|
+
this.#scope = options.scope;
|
|
118
|
+
this.#issuer = options.issuer;
|
|
119
|
+
this.#discoveryCache = options.discoveryCache;
|
|
120
|
+
this.#endpoints = { ...options.endpoints };
|
|
121
|
+
this.#tokenStorage = options.tokenStorage ?? new MemoryTokenStorage();
|
|
122
|
+
this.#refreshTokenStorage = options.refreshTokenStorage ??
|
|
123
|
+
new MemoryRefreshTokenStorage();
|
|
124
|
+
this.#authRequestStorage = options.authRequestStorage ??
|
|
125
|
+
defaultAuthRequestStorage();
|
|
126
|
+
this.#authRequestTtlMs = options.authRequestTtlMs ?? 10 * 60 * 1000;
|
|
127
|
+
}
|
|
128
|
+
/** True when the client was given a secret and authenticates via Basic. */
|
|
129
|
+
get isConfidential() {
|
|
130
|
+
return this.#clientSecret !== undefined;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Fetches authorization-server metadata and caches it on the instance.
|
|
134
|
+
*
|
|
135
|
+
* Tries `.well-known/oauth-authorization-server` (RFC 8414) first, then
|
|
136
|
+
* falls back to `.well-known/openid-configuration` (OIDC Discovery) — the
|
|
137
|
+
* two use the same field names, and real IdPs serve one or the other, so
|
|
138
|
+
* this works against any compliant server. **Lazy + cached**: it only runs
|
|
139
|
+
* on the first endpoint use of an `issuer`-configured client (never at
|
|
140
|
+
* construction), so there is no startup cost and no per-request fetch.
|
|
141
|
+
* Concurrent calls share one in-flight request; a failure clears the cache
|
|
142
|
+
* so a later call can retry.
|
|
143
|
+
*
|
|
144
|
+
* **Re-resolution contract.** The document this client holds is never
|
|
145
|
+
* permanent: every endpoint use re-resolves once the copy in hand is past
|
|
146
|
+
* its expiry, so a long-lived client picks up a rotated `jwks_uri` or a
|
|
147
|
+
* moved endpoint without a restart. Without a `discoveryCache` the copy
|
|
148
|
+
* expires {@link DEFAULT_DISCOVERY_TTL_MS} after it was fetched. With one,
|
|
149
|
+
* the cache owns freshness: the copy expires when the cache says its entry
|
|
150
|
+
* does (the `expiresAt` it reports), so the client reads the cache once
|
|
151
|
+
* per entry lifetime rather than once per endpoint lookup — a Redis- or
|
|
152
|
+
* file-backed cache costs one round trip per TTL window, not one per call.
|
|
153
|
+
* Either way expiry only schedules the *next* resolve; nothing re-fetches on
|
|
154
|
+
* a timer, and a client that is idle past its expiry makes no requests.
|
|
155
|
+
*
|
|
156
|
+
* A server that builds a client per request should supply a
|
|
157
|
+
* `discoveryCache`, or it re-fetches discovery on every request.
|
|
158
|
+
*
|
|
159
|
+
* @returns The server's metadata document.
|
|
160
|
+
* @throws {Error} when the client was configured with explicit `endpoints`
|
|
161
|
+
* rather than an `issuer` — there is nothing to discover.
|
|
162
|
+
* @throws {ServerError} when both well-known paths fail.
|
|
163
|
+
*
|
|
164
|
+
* @example
|
|
165
|
+
* ```ts
|
|
166
|
+
* import { DirectClient } from "@udibo/oauth2/client";
|
|
167
|
+
*
|
|
168
|
+
* const client = new DirectClient({
|
|
169
|
+
* clientId: "cli",
|
|
170
|
+
* issuer: "https://auth.example.com",
|
|
171
|
+
* });
|
|
172
|
+
* const meta = await client.discover();
|
|
173
|
+
* ```
|
|
174
|
+
*/
|
|
175
|
+
discover() {
|
|
176
|
+
if (this.#hasFreshMetadata())
|
|
177
|
+
return Promise.resolve(this.#metadata);
|
|
178
|
+
if (this.#discoveryPromise)
|
|
179
|
+
return this.#discoveryPromise;
|
|
180
|
+
const issuer = this.#issuer;
|
|
181
|
+
if (!issuer) {
|
|
182
|
+
throw new Error("discovery requires `issuer` in options; pass explicit `endpoints` " +
|
|
183
|
+
"to skip discovery.");
|
|
184
|
+
}
|
|
185
|
+
const cache = this.#discoveryCache;
|
|
186
|
+
const promise = (async () => {
|
|
187
|
+
if (cache) {
|
|
188
|
+
const entry = await cache.resolve(issuer, () => this.#fetchMetadata(issuer));
|
|
189
|
+
return this.#adoptMetadata(entry.metadata, entry.expiresAt);
|
|
190
|
+
}
|
|
191
|
+
const meta = await this.#fetchMetadata(issuer);
|
|
192
|
+
return this.#adoptMetadata(meta, Date.now() + DEFAULT_DISCOVERY_TTL_MS);
|
|
193
|
+
})();
|
|
194
|
+
this.#discoveryPromise = promise;
|
|
195
|
+
const clear = () => {
|
|
196
|
+
if (this.#discoveryPromise === promise) {
|
|
197
|
+
this.#discoveryPromise = undefined;
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
promise.then(clear, clear);
|
|
201
|
+
return promise;
|
|
202
|
+
}
|
|
203
|
+
#adoptMetadata(metadata, expiresAt) {
|
|
204
|
+
this.#metadata = metadata;
|
|
205
|
+
this.#metadataExpiresAt = expiresAt;
|
|
206
|
+
this.#endpoints = {
|
|
207
|
+
...this.#endpoints,
|
|
208
|
+
...metadataToEndpoints(metadata),
|
|
209
|
+
};
|
|
210
|
+
return metadata;
|
|
211
|
+
}
|
|
212
|
+
#hasFreshMetadata() {
|
|
213
|
+
return this.#metadata !== undefined && Date.now() < this.#metadataExpiresAt;
|
|
214
|
+
}
|
|
215
|
+
async #fetchMetadata(issuer) {
|
|
216
|
+
const issuerBase = issuer.endsWith("/") ? issuer : `${issuer}/`;
|
|
217
|
+
const wellKnownPaths = [
|
|
218
|
+
".well-known/oauth-authorization-server",
|
|
219
|
+
".well-known/openid-configuration",
|
|
220
|
+
];
|
|
221
|
+
let lastError;
|
|
222
|
+
for (const path of wellKnownPaths) {
|
|
223
|
+
const url = new URL(path, issuerBase).toString();
|
|
224
|
+
try {
|
|
225
|
+
const res = await sendGuarded((input, init) => this.fetchImpl(input, init), url, { headers: { Accept: "application/json" } }, "discovery endpoint", { redirect: "follow" });
|
|
226
|
+
const meta = await receiveJson(res, "discovery endpoint");
|
|
227
|
+
assertIssuer(meta, issuer);
|
|
228
|
+
return meta;
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
lastError = error;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
throw lastError ?? new ServerError("discovery failed");
|
|
235
|
+
}
|
|
236
|
+
/** Last-seen authorization server metadata, if discovery has run. */
|
|
237
|
+
get metadata() {
|
|
238
|
+
return this.#metadata;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* The configured authorization endpoint URL, if known **synchronously** —
|
|
242
|
+
* set explicitly via `endpoints.authorization`, or learned from a previous
|
|
243
|
+
* {@link discover}. `undefined` for an issuer-only client that has not
|
|
244
|
+
* discovered yet.
|
|
245
|
+
*
|
|
246
|
+
* Exposed so a Backend-for-Frontend can recognize an in-flight authorize URL
|
|
247
|
+
* (e.g. to decide whether to *resume* it or *start* a fresh login) without
|
|
248
|
+
* re-running discovery. See `HonoBff.loginContinuation`.
|
|
249
|
+
*/
|
|
250
|
+
get authorizationEndpoint() {
|
|
251
|
+
return this.#endpoints.authorization;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* The configured OIDC end-session endpoint URL, if known **synchronously** —
|
|
255
|
+
* set explicitly via `endpoints.endSession`, or learned from a previous
|
|
256
|
+
* {@link discover}. `undefined` when the server advertises none.
|
|
257
|
+
*
|
|
258
|
+
* Exposed so a Backend-for-Frontend can drive RP-Initiated Logout (redirect
|
|
259
|
+
* to the OP's end-session endpoint) without re-running discovery. See
|
|
260
|
+
* `HonoBffOptions.rpInitiatedLogout`.
|
|
261
|
+
*/
|
|
262
|
+
get endSessionEndpoint() {
|
|
263
|
+
return this.#endpoints.endSession;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* This client's `client_id`.
|
|
267
|
+
*
|
|
268
|
+
* Exposed for the requests that identify the client without authenticating
|
|
269
|
+
* it — RP-Initiated Logout's `client_id`, which is how the OP knows whose
|
|
270
|
+
* registered `post_logout_redirect_uris` to check when no `id_token_hint` is
|
|
271
|
+
* available. It is a public identifier, never a credential.
|
|
272
|
+
*/
|
|
273
|
+
get clientId() {
|
|
274
|
+
return this.#clientId;
|
|
275
|
+
}
|
|
276
|
+
async #endpoint(key) {
|
|
277
|
+
if (this.#issuer && !this.#hasFreshMetadata())
|
|
278
|
+
await this.discover();
|
|
279
|
+
return this.#endpoints[key];
|
|
280
|
+
}
|
|
281
|
+
async #requireEndpoint(key) {
|
|
282
|
+
const value = await this.#endpoint(key);
|
|
283
|
+
if (typeof value !== "string") {
|
|
284
|
+
throw new Error(`${String(key)} endpoint is not configured`);
|
|
285
|
+
}
|
|
286
|
+
return value;
|
|
287
|
+
}
|
|
288
|
+
#resolveRedirectUri(options) {
|
|
289
|
+
if (options.redirectUri)
|
|
290
|
+
return options.redirectUri;
|
|
291
|
+
if (this.#redirectUri && options.origin) {
|
|
292
|
+
return withOrigin(this.#redirectUri, options.origin);
|
|
293
|
+
}
|
|
294
|
+
return this.#redirectUri;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Builds the PKCE-protected authorize URL and persists the verifier under
|
|
298
|
+
* the returned `state`. Navigate the browser to `url`; the callback route
|
|
299
|
+
* then calls {@link handleAuthorizationCallback}. Non-browser callers can
|
|
300
|
+
* use the URL for out-of-band flows.
|
|
301
|
+
*
|
|
302
|
+
* @param options `returnTo` is stored with the pending request and handed
|
|
303
|
+
* back by the callback. The remaining fields let a server retarget the
|
|
304
|
+
* authorize URL, override `redirect_uri`, or bind the pending request to a
|
|
305
|
+
* per-request store.
|
|
306
|
+
* @returns The authorize URL and the `state` the callback will echo back.
|
|
307
|
+
* @throws {Error} when no authorization endpoint is configured or
|
|
308
|
+
* discoverable.
|
|
309
|
+
*
|
|
310
|
+
* @example
|
|
311
|
+
* ```ts
|
|
312
|
+
* import { DirectClient } from "@udibo/oauth2/client";
|
|
313
|
+
*
|
|
314
|
+
* const client = new DirectClient({
|
|
315
|
+
* clientId: "spa",
|
|
316
|
+
* endpoints: { authorization: "https://auth.example.com/authorize" },
|
|
317
|
+
* redirectUri: "https://app.example.com/callback",
|
|
318
|
+
* });
|
|
319
|
+
* const { url, state } = await client.login({ returnTo: "/dashboard" });
|
|
320
|
+
* ```
|
|
321
|
+
*/
|
|
322
|
+
async login(options = {}) {
|
|
323
|
+
const baseAuthEndpoint = await this.#requireEndpoint("authorization");
|
|
324
|
+
const authEndpoint = options.origin
|
|
325
|
+
? withOrigin(baseAuthEndpoint, options.origin)
|
|
326
|
+
: baseAuthEndpoint;
|
|
327
|
+
const redirectUri = this.#resolveRedirectUri(options);
|
|
328
|
+
const state = generateState();
|
|
329
|
+
const codeVerifier = generateCodeVerifier();
|
|
330
|
+
const codeChallenge = await challengeMethods.S256(codeVerifier);
|
|
331
|
+
const scope = options.scope ?? this.#scope;
|
|
332
|
+
const authRequestStorage = options.authRequestStorage ??
|
|
333
|
+
this.#authRequestStorage;
|
|
334
|
+
await authRequestStorage.set(state, {
|
|
335
|
+
codeVerifier,
|
|
336
|
+
returnTo: options.returnTo,
|
|
337
|
+
scope,
|
|
338
|
+
createdAt: Date.now(),
|
|
339
|
+
});
|
|
340
|
+
const url = new URL(authEndpoint);
|
|
341
|
+
url.searchParams.set("response_type", "code");
|
|
342
|
+
url.searchParams.set("client_id", this.#clientId);
|
|
343
|
+
url.searchParams.set("state", state);
|
|
344
|
+
url.searchParams.set("code_challenge", codeChallenge);
|
|
345
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
346
|
+
if (redirectUri)
|
|
347
|
+
url.searchParams.set("redirect_uri", redirectUri);
|
|
348
|
+
if (scope)
|
|
349
|
+
url.searchParams.set("scope", scope);
|
|
350
|
+
if (options.prompt)
|
|
351
|
+
url.searchParams.set("prompt", options.prompt);
|
|
352
|
+
for (const [key, value] of Object.entries(options.extraParams ?? {})) {
|
|
353
|
+
url.searchParams.set(key, value);
|
|
354
|
+
}
|
|
355
|
+
return { url: url.toString(), state };
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Completes the auth-code flow: reads `code` + `state` from the callback
|
|
359
|
+
* URL, exchanges the code at the token endpoint, persists the token bundle,
|
|
360
|
+
* and emits `authenticated`.
|
|
361
|
+
*
|
|
362
|
+
* Idempotent on duplicate calls with the same `code` — React strict-mode
|
|
363
|
+
* double-mounts would otherwise double-redeem a single-use code and fail the
|
|
364
|
+
* second call, so the first in-flight promise is returned again.
|
|
365
|
+
*
|
|
366
|
+
* @param input The callback URL, its query string, or the parsed params.
|
|
367
|
+
* @returns The persisted tokens and the `returnTo` recorded at login.
|
|
368
|
+
* @throws {OAuth2Error} when the callback carries `?error=…`, or the token
|
|
369
|
+
* exchange fails.
|
|
370
|
+
* @throws {TypeError} when `code` or `state` is missing.
|
|
371
|
+
* @throws {InvalidGrantError} when `state` is unknown or expired.
|
|
372
|
+
*
|
|
373
|
+
* @example
|
|
374
|
+
* ```ts
|
|
375
|
+
* import { DirectClient } from "@udibo/oauth2/client";
|
|
376
|
+
*
|
|
377
|
+
* declare const client: DirectClient;
|
|
378
|
+
* const { returnTo } = await client.handleAuthorizationCallback(
|
|
379
|
+
* location.href,
|
|
380
|
+
* );
|
|
381
|
+
* location.replace(returnTo ?? "/");
|
|
382
|
+
* ```
|
|
383
|
+
*/
|
|
384
|
+
handleAuthorizationCallback(input) {
|
|
385
|
+
const params = toSearchParams(input);
|
|
386
|
+
const error = params.get("error");
|
|
387
|
+
if (error) {
|
|
388
|
+
const description = params.get("error_description") ?? undefined;
|
|
389
|
+
const errorUri = params.get("error_uri") ?? undefined;
|
|
390
|
+
const instance = new OAuth2ErrorClass(description ?? "authorization error", {
|
|
391
|
+
extensions: {
|
|
392
|
+
error,
|
|
393
|
+
error_description: description,
|
|
394
|
+
error_uri: errorUri,
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
this.emit({ type: "error", error: instance });
|
|
398
|
+
return Promise.reject(instance);
|
|
399
|
+
}
|
|
400
|
+
const code = params.get("code");
|
|
401
|
+
const state = params.get("state");
|
|
402
|
+
if (!code)
|
|
403
|
+
return Promise.reject(new TypeError("callback missing `code`"));
|
|
404
|
+
if (!state) {
|
|
405
|
+
return Promise.reject(new TypeError("callback missing `state`"));
|
|
406
|
+
}
|
|
407
|
+
const existing = this.#callbackPromises.get(code);
|
|
408
|
+
if (existing)
|
|
409
|
+
return existing;
|
|
410
|
+
const promise = this.#exchangeCode(code, state);
|
|
411
|
+
this.#callbackPromises.set(code, promise);
|
|
412
|
+
const cleanup = () => queueMicrotask(() => this.#callbackPromises.delete(code));
|
|
413
|
+
promise.then(cleanup, cleanup);
|
|
414
|
+
return promise;
|
|
415
|
+
}
|
|
416
|
+
async #exchangeCode(code, state) {
|
|
417
|
+
const version = ++this.#sessionVersion;
|
|
418
|
+
const result = await this.exchangeAuthorizationCode(code, state);
|
|
419
|
+
const tokens = await this.#saveTokens(result.raw, version);
|
|
420
|
+
this.emit({ type: "authenticated", tokens });
|
|
421
|
+
return { tokens, returnTo: result.returnTo };
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* **Advanced (BFF / multi-session servers).** SPA, mobile, and CLI consumers
|
|
425
|
+
* should call {@link handleAuthorizationCallback} instead — that variant
|
|
426
|
+
* persists the token bundle to this client's storage and emits the
|
|
427
|
+
* `authenticated` event the React adapter listens for.
|
|
428
|
+
*
|
|
429
|
+
* Exchanges an authorization code for tokens **without persisting them**.
|
|
430
|
+
* Used by server-side adapters that manage their own per-session storage
|
|
431
|
+
* (e.g. the Hono BFF, which keeps tokens in a `SessionStore` keyed by an
|
|
432
|
+
* HttpOnly cookie). Calling this from a single-session client splits the
|
|
433
|
+
* token state across this client's storage and the caller's external store,
|
|
434
|
+
* so refresh, revocation, and logout all stop working as expected.
|
|
435
|
+
*
|
|
436
|
+
* The verifier and `returnTo` are looked up under `state`; on success the
|
|
437
|
+
* record is consumed. Emits nothing — the caller owns session semantics.
|
|
438
|
+
*
|
|
439
|
+
* @param code The authorization code from the callback.
|
|
440
|
+
* @param state The `state` the callback echoed back.
|
|
441
|
+
* @param options Per-call `redirect_uri` and auth-request storage overrides,
|
|
442
|
+
* which must match what {@link login} was given.
|
|
443
|
+
* @returns The tokens, the rotated refresh token, and the recorded
|
|
444
|
+
* `returnTo`, plus the raw response.
|
|
445
|
+
* @throws {InvalidGrantError} when `state` is unknown or older than
|
|
446
|
+
* `authRequestTtlMs`.
|
|
447
|
+
*/
|
|
448
|
+
async exchangeAuthorizationCode(code, state, options = {}) {
|
|
449
|
+
const authRequestStorage = options.authRequestStorage ??
|
|
450
|
+
this.#authRequestStorage;
|
|
451
|
+
const record = await authRequestStorage.get(state);
|
|
452
|
+
if (!record)
|
|
453
|
+
throw new InvalidGrantError("unknown state parameter");
|
|
454
|
+
if (this.#authRequestTtlMs !== Infinity &&
|
|
455
|
+
Date.now() - record.createdAt > this.#authRequestTtlMs) {
|
|
456
|
+
await authRequestStorage.delete(state);
|
|
457
|
+
throw new InvalidGrantError("authorization request expired");
|
|
458
|
+
}
|
|
459
|
+
const body = new URLSearchParams({
|
|
460
|
+
grant_type: "authorization_code",
|
|
461
|
+
code,
|
|
462
|
+
code_verifier: record.codeVerifier,
|
|
463
|
+
});
|
|
464
|
+
const redirectUri = this.#resolveRedirectUri(options);
|
|
465
|
+
if (redirectUri)
|
|
466
|
+
body.set("redirect_uri", redirectUri);
|
|
467
|
+
const raw = await this.#postToken(body);
|
|
468
|
+
await authRequestStorage.delete(state);
|
|
469
|
+
return {
|
|
470
|
+
tokens: responseToBundle(raw),
|
|
471
|
+
refreshToken: raw.refresh_token,
|
|
472
|
+
returnTo: record.returnTo,
|
|
473
|
+
raw,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* **Advanced (BFF / multi-session servers).** SPA, mobile, and CLI consumers
|
|
478
|
+
* should call {@link refresh} instead — that variant persists the rotated
|
|
479
|
+
* bundle, dedupes concurrent calls, and emits `token_refreshed`.
|
|
480
|
+
*
|
|
481
|
+
* Exchanges a refresh token for a fresh bundle **without persisting** it.
|
|
482
|
+
* Used by server-side adapters that manage their own per-session storage
|
|
483
|
+
* (the Hono BFF's `attachToken` middleware calls this when refreshing on
|
|
484
|
+
* expiry). Mixing this with this client's own token storage in a
|
|
485
|
+
* single-session deployment leaves the two out of sync.
|
|
486
|
+
*
|
|
487
|
+
* Does not share the single-flight promise with {@link refresh} and emits
|
|
488
|
+
* nothing — the caller owns concurrency control across its sessions.
|
|
489
|
+
*
|
|
490
|
+
* @param refreshToken The refresh token to redeem.
|
|
491
|
+
* @returns The new tokens, the rotated refresh token, and the raw response.
|
|
492
|
+
* @throws {OAuth2Error} when the token endpoint rejects the grant.
|
|
493
|
+
*/
|
|
494
|
+
async exchangeRefreshToken(refreshToken) {
|
|
495
|
+
const raw = await this.#postToken(new URLSearchParams({
|
|
496
|
+
grant_type: "refresh_token",
|
|
497
|
+
refresh_token: refreshToken,
|
|
498
|
+
}));
|
|
499
|
+
return {
|
|
500
|
+
tokens: responseToBundle(raw),
|
|
501
|
+
refreshToken: raw.refresh_token,
|
|
502
|
+
raw,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Issues a token via the `client_credentials` grant, persists it, and emits
|
|
507
|
+
* `authenticated`. For machine-to-machine calls where there is no user.
|
|
508
|
+
*
|
|
509
|
+
* @param options `scope` overrides the client's default scope string.
|
|
510
|
+
* @returns The persisted token bundle.
|
|
511
|
+
* @throws {Error} when the client has no `clientSecret` — RFC 6749 §4.4
|
|
512
|
+
* restricts this grant to confidential clients.
|
|
513
|
+
* @throws {OAuth2Error} when the token endpoint rejects the grant.
|
|
514
|
+
*
|
|
515
|
+
* @example
|
|
516
|
+
* ```ts
|
|
517
|
+
* import { DirectClient } from "@udibo/oauth2/client";
|
|
518
|
+
*
|
|
519
|
+
* declare const client: DirectClient;
|
|
520
|
+
* const { accessToken } = await client.getClientCredentialsToken({
|
|
521
|
+
* scope: "jobs:run",
|
|
522
|
+
* });
|
|
523
|
+
* ```
|
|
524
|
+
*/
|
|
525
|
+
async getClientCredentialsToken(options = {}) {
|
|
526
|
+
if (!this.isConfidential) {
|
|
527
|
+
throw new Error("client_credentials requires a confidential client; construct the " +
|
|
528
|
+
"DirectClient with a `clientSecret`.");
|
|
529
|
+
}
|
|
530
|
+
const body = new URLSearchParams({ grant_type: "client_credentials" });
|
|
531
|
+
const scope = options.scope ?? this.#scope;
|
|
532
|
+
if (scope)
|
|
533
|
+
body.set("scope", scope);
|
|
534
|
+
const version = ++this.#sessionVersion;
|
|
535
|
+
const tokens = await this.#saveTokens(await this.#postToken(body), version);
|
|
536
|
+
this.emit({ type: "authenticated", tokens });
|
|
537
|
+
return tokens;
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Refreshes the access token using the stored refresh token, persists the
|
|
541
|
+
* result, and emits `token_refreshed`. Single-flight: concurrent callers
|
|
542
|
+
* share one in-flight promise.
|
|
543
|
+
*
|
|
544
|
+
* If the refresh fails with `invalid_grant` (the token was revoked or, under
|
|
545
|
+
* rotation, already consumed), all local token state is cleared and
|
|
546
|
+
* `logged_out` is emitted so the framework adapter can redirect to login.
|
|
547
|
+
*
|
|
548
|
+
* Single-flight has one consequence worth knowing: a call that **joins** a
|
|
549
|
+
* refresh already started by {@link renewSession} inherits that call's quiet
|
|
550
|
+
* policy, so it will not emit `error` on a transport failure even though it
|
|
551
|
+
* still rejects. The rejection, not the event, is the contract for a caller
|
|
552
|
+
* that asked directly.
|
|
553
|
+
*
|
|
554
|
+
* A refresh-token storage that fails to read refuses the refresh: the
|
|
555
|
+
* rejection carries the storage's own error, `error` is emitted, and the
|
|
556
|
+
* stored session is left alone — a broken store is not evidence the grant
|
|
557
|
+
* died.
|
|
558
|
+
*
|
|
559
|
+
* @returns The new access token.
|
|
560
|
+
* @throws {InvalidGrantError} when no refresh token is stored, or the stored
|
|
561
|
+
* one is dead.
|
|
562
|
+
* @throws {Error} whatever the configured `RefreshTokenStorage` threw when
|
|
563
|
+
* the read itself failed.
|
|
564
|
+
*/
|
|
565
|
+
refresh() {
|
|
566
|
+
return this.#sharedRefresh(true);
|
|
567
|
+
}
|
|
568
|
+
#sharedRefresh(reportTransient) {
|
|
569
|
+
if (this.#refreshPromise &&
|
|
570
|
+
(this.#refreshSnapshot?.version === undefined ||
|
|
571
|
+
this.#refreshSnapshot.version === this.#sessionVersion))
|
|
572
|
+
return this.#refreshPromise;
|
|
573
|
+
const snapshot = {};
|
|
574
|
+
const promise = this.#doRefresh(reportTransient, snapshot);
|
|
575
|
+
this.#refreshPromise = promise;
|
|
576
|
+
this.#refreshSnapshot = snapshot;
|
|
577
|
+
const clear = () => {
|
|
578
|
+
if (this.#refreshPromise === promise)
|
|
579
|
+
this.#refreshPromise = undefined;
|
|
580
|
+
};
|
|
581
|
+
promise.then(clear, clear);
|
|
582
|
+
return promise;
|
|
583
|
+
}
|
|
584
|
+
async #doRefresh(reportTransient, snapshot) {
|
|
585
|
+
const { version, refreshToken } = await this.#mutateSession(async () => {
|
|
586
|
+
const version = snapshot.version = this.#sessionVersion;
|
|
587
|
+
return {
|
|
588
|
+
version,
|
|
589
|
+
refreshToken: await this.#readRefreshToken(reportTransient),
|
|
590
|
+
};
|
|
591
|
+
});
|
|
592
|
+
if (version !== this.#sessionVersion) {
|
|
593
|
+
throw new InvalidGrantError("session changed during refresh");
|
|
594
|
+
}
|
|
595
|
+
if (!refreshToken) {
|
|
596
|
+
throw new InvalidGrantError("no refresh token available");
|
|
597
|
+
}
|
|
598
|
+
try {
|
|
599
|
+
const response = await this.#postToken(new URLSearchParams({
|
|
600
|
+
grant_type: "refresh_token",
|
|
601
|
+
refresh_token: refreshToken,
|
|
602
|
+
}));
|
|
603
|
+
const tokens = await this.#saveTokens(response, version, true);
|
|
604
|
+
this.emit({ type: "token_refreshed", tokens });
|
|
605
|
+
return tokens.accessToken;
|
|
606
|
+
}
|
|
607
|
+
catch (error) {
|
|
608
|
+
if (version !== this.#sessionVersion) {
|
|
609
|
+
throw new InvalidGrantError("session changed during refresh");
|
|
610
|
+
}
|
|
611
|
+
if (isOAuth2ErrorCode(error, "invalid_grant")) {
|
|
612
|
+
await this.#clearSession();
|
|
613
|
+
this.emit({ type: "logged_out", reason: "invalid_grant" });
|
|
614
|
+
}
|
|
615
|
+
else if (reportTransient) {
|
|
616
|
+
this.emit({ type: "error", error });
|
|
617
|
+
}
|
|
618
|
+
throw error;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
async #readRefreshToken(reportTransient) {
|
|
622
|
+
try {
|
|
623
|
+
return await this.#refreshTokenStorage.get();
|
|
624
|
+
}
|
|
625
|
+
catch (error) {
|
|
626
|
+
if (reportTransient)
|
|
627
|
+
this.emit({ type: "error", error });
|
|
628
|
+
throw error;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Initiates the device authorization flow (RFC 8628 §3.1) for an input-
|
|
633
|
+
* constrained device. Show the returned `user_code` and
|
|
634
|
+
* `verification_uri`, then hand `device_code` to {@link pollDeviceToken}.
|
|
635
|
+
*
|
|
636
|
+
* @param options `scope` overrides the client's default scope string.
|
|
637
|
+
* @returns The server's device authorization response.
|
|
638
|
+
* @throws {Error} when no device authorization endpoint is configured or
|
|
639
|
+
* discoverable.
|
|
640
|
+
* @throws {OAuth2Error} when the server rejects the request.
|
|
641
|
+
*
|
|
642
|
+
* @example
|
|
643
|
+
* ```ts
|
|
644
|
+
* import { DirectClient } from "@udibo/oauth2/client";
|
|
645
|
+
*
|
|
646
|
+
* declare const client: DirectClient;
|
|
647
|
+
* const grant = await client.startDeviceAuthorization();
|
|
648
|
+
* console.log(`go to ${grant.verification_uri}, enter ${grant.user_code}`);
|
|
649
|
+
* const tokens = await client.pollDeviceToken(grant.device_code, {
|
|
650
|
+
* interval: grant.interval,
|
|
651
|
+
* expiresAt: Date.now() + grant.expires_in * 1000,
|
|
652
|
+
* });
|
|
653
|
+
* ```
|
|
654
|
+
*/
|
|
655
|
+
async startDeviceAuthorization(options = {}) {
|
|
656
|
+
const endpoint = await this.#requireEndpoint("deviceAuthorization");
|
|
657
|
+
const body = new URLSearchParams({ client_id: this.#clientId });
|
|
658
|
+
const scope = options.scope ?? this.#scope;
|
|
659
|
+
if (scope)
|
|
660
|
+
body.set("scope", scope);
|
|
661
|
+
const headers = {
|
|
662
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
663
|
+
};
|
|
664
|
+
this.#applyClientAuth(headers, body);
|
|
665
|
+
const grant = await this.#postForm(endpoint, headers, body, "device authorization endpoint");
|
|
666
|
+
assertField(grant, "device_code", "device authorization endpoint");
|
|
667
|
+
return grant;
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* Polls the token endpoint for a device-code exchange (RFC 8628 §3.4),
|
|
671
|
+
* persists the tokens, and emits `authenticated`. Handles
|
|
672
|
+
* `authorization_pending` (keep polling) and `slow_down` (add 5s to the
|
|
673
|
+
* interval) automatically.
|
|
674
|
+
*
|
|
675
|
+
* @param deviceCode The `device_code` from
|
|
676
|
+
* {@link startDeviceAuthorization}.
|
|
677
|
+
* @param options Pass `interval` and `expiresAt` from the device
|
|
678
|
+
* authorization response so polling respects the server's advertised
|
|
679
|
+
* minimum interval and deadline, and `signal` to cancel.
|
|
680
|
+
* @returns The persisted token bundle.
|
|
681
|
+
* @throws {AccessDeniedError} when `signal` aborts, or the user declines.
|
|
682
|
+
* @throws {InvalidGrantError} when the device code expires before approval.
|
|
683
|
+
* @throws {OAuth2Error} on any other terminal error from the server.
|
|
684
|
+
*/
|
|
685
|
+
async pollDeviceToken(deviceCode, options = {}) {
|
|
686
|
+
const version = ++this.#sessionVersion;
|
|
687
|
+
let interval = Math.max(1, options.interval ?? 5);
|
|
688
|
+
const deadline = options.expiresAt ?? Infinity;
|
|
689
|
+
for (;;) {
|
|
690
|
+
if (options.signal?.aborted) {
|
|
691
|
+
throw new AccessDeniedError("polling aborted");
|
|
692
|
+
}
|
|
693
|
+
if (Date.now() >= deadline) {
|
|
694
|
+
throw new InvalidGrantError("device code expired before authorization");
|
|
695
|
+
}
|
|
696
|
+
try {
|
|
697
|
+
const response = await this.#postToken(new URLSearchParams({
|
|
698
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
699
|
+
device_code: deviceCode,
|
|
700
|
+
client_id: this.#clientId,
|
|
701
|
+
}));
|
|
702
|
+
const tokens = await this.#saveTokens(response, version);
|
|
703
|
+
this.emit({ type: "authenticated", tokens });
|
|
704
|
+
return tokens;
|
|
705
|
+
}
|
|
706
|
+
catch (error) {
|
|
707
|
+
if (isOAuth2ErrorCode(error, "authorization_pending")) {
|
|
708
|
+
await pollDelay(interval * 1000, options.signal);
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
if (isOAuth2ErrorCode(error, "slow_down")) {
|
|
712
|
+
interval += 5;
|
|
713
|
+
await pollDelay(interval * 1000, options.signal);
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
716
|
+
throw error;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Calls the introspection endpoint (RFC 7662) to ask the server whether a
|
|
722
|
+
* token is still active and what it carries.
|
|
723
|
+
*
|
|
724
|
+
* @param token The token to introspect.
|
|
725
|
+
* @param options `tokenTypeHint` tells the server which kind to look up
|
|
726
|
+
* first.
|
|
727
|
+
* @returns The introspection response; check `active`.
|
|
728
|
+
* @throws {Error} when the client has no `clientSecret` — RFC 7662 requires
|
|
729
|
+
* an authenticated caller.
|
|
730
|
+
* @throws {OAuth2Error} when the endpoint rejects the request.
|
|
731
|
+
*/
|
|
732
|
+
async introspect(token, options = {}) {
|
|
733
|
+
if (!this.isConfidential) {
|
|
734
|
+
throw new Error("introspection requires a confidential client per RFC 7662; " +
|
|
735
|
+
"construct the DirectClient with a `clientSecret`.");
|
|
736
|
+
}
|
|
737
|
+
const endpoint = await this.#requireEndpoint("introspection");
|
|
738
|
+
const body = new URLSearchParams({ token });
|
|
739
|
+
if (options.tokenTypeHint) {
|
|
740
|
+
body.set("token_type_hint", options.tokenTypeHint);
|
|
741
|
+
}
|
|
742
|
+
const result = await this.#postForm(endpoint, {
|
|
743
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
744
|
+
Authorization: this.#basicAuth(),
|
|
745
|
+
}, body, "introspection endpoint");
|
|
746
|
+
return result;
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* Calls the revocation endpoint (RFC 7009) to kill a token server-side.
|
|
750
|
+
* Does not touch this client's storage — {@link logout} does both.
|
|
751
|
+
*
|
|
752
|
+
* @param token The token to revoke.
|
|
753
|
+
* @param options `tokenTypeHint` tells the server which kind to look up
|
|
754
|
+
* first.
|
|
755
|
+
* @throws {Error} when no revocation endpoint is configured or discoverable.
|
|
756
|
+
* @throws {OAuth2Error} when the endpoint rejects the request.
|
|
757
|
+
*/
|
|
758
|
+
async revoke(token, options = {}) {
|
|
759
|
+
const endpoint = await this.#requireEndpoint("revocation");
|
|
760
|
+
const body = new URLSearchParams({ token });
|
|
761
|
+
if (options.tokenTypeHint) {
|
|
762
|
+
body.set("token_type_hint", options.tokenTypeHint);
|
|
763
|
+
}
|
|
764
|
+
const headers = {
|
|
765
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
766
|
+
};
|
|
767
|
+
this.#applyClientAuth(headers, body);
|
|
768
|
+
const res = await sendGuarded((input, init) => this.fetchImpl(input, init), endpoint, { method: "POST", headers, body }, "revocation endpoint");
|
|
769
|
+
if (!res.ok)
|
|
770
|
+
throw await errorFromResponse(res, "revocation endpoint");
|
|
771
|
+
await res.body?.cancel();
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Fetches the OIDC userinfo endpoint with the current access token,
|
|
775
|
+
* refreshing it first if it is close to expiry.
|
|
776
|
+
*
|
|
777
|
+
* @returns The claims the server returns.
|
|
778
|
+
* @throws {Error} when no userinfo endpoint is configured or discoverable.
|
|
779
|
+
* @throws {AccessDeniedError} when no token is stored and none can be
|
|
780
|
+
* refreshed.
|
|
781
|
+
*/
|
|
782
|
+
async getUserInfo() {
|
|
783
|
+
return await this.#fetchUserInfo(false);
|
|
784
|
+
}
|
|
785
|
+
async #fetchUserInfo(quiet) {
|
|
786
|
+
const endpoint = await this.#requireEndpoint("userInfo");
|
|
787
|
+
const accessToken = await this.#accessTokenFor(quiet);
|
|
788
|
+
const res = await sendGuarded((input, init) => this.fetchImpl(input, init), endpoint, {
|
|
789
|
+
headers: {
|
|
790
|
+
Accept: "application/json",
|
|
791
|
+
Authorization: `Bearer ${accessToken}`,
|
|
792
|
+
},
|
|
793
|
+
}, "userinfo endpoint");
|
|
794
|
+
return await receiveJson(res, "userinfo endpoint");
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* Base64url-decodes an OIDC `id_token` payload and returns the claims.
|
|
798
|
+
*
|
|
799
|
+
* **Does not verify the signature** — the token arrives from the token
|
|
800
|
+
* endpoint over TLS, so verification would only defend against a compromised
|
|
801
|
+
* TLS chain and is not a normal client concern. If you need verification
|
|
802
|
+
* (e.g. the token was obtained out-of-band), use a dedicated JWT library
|
|
803
|
+
* with your server's JWKS.
|
|
804
|
+
*
|
|
805
|
+
* @param idToken A compact-serialized JWS.
|
|
806
|
+
* @returns The decoded payload claims.
|
|
807
|
+
* @throws {TypeError | RangeError | SyntaxError} on a token that isn't a
|
|
808
|
+
* decodable JWS with a JSON payload. Callers that surface an authorization
|
|
809
|
+
* server's response to a user should catch, since a broken or hostile
|
|
810
|
+
* server controls this input.
|
|
811
|
+
*/
|
|
812
|
+
decodeIdToken(idToken) {
|
|
813
|
+
const parts = idToken.split(".");
|
|
814
|
+
if (parts.length < 2)
|
|
815
|
+
throw new TypeError("malformed id_token");
|
|
816
|
+
const json = new TextDecoder().decode(base64urlDecode(parts[1]));
|
|
817
|
+
return JSON.parse(json);
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* Fetches with `Authorization: Bearer <access token>` attached, retrying
|
|
821
|
+
* once after a silent {@link refresh} if the response is a `401` carrying
|
|
822
|
+
* `WWW-Authenticate: … error="invalid_token"`.
|
|
823
|
+
*
|
|
824
|
+
* A `401` without that challenge is returned as-is — it means "not allowed",
|
|
825
|
+
* not "token stale", and re-minting would not help.
|
|
826
|
+
*
|
|
827
|
+
* @param input Request target, as for `fetch`. A `Request` keeps the headers
|
|
828
|
+
* and body it was built with; it is cloned up front so the retry has an
|
|
829
|
+
* unread body.
|
|
830
|
+
* @param init Request options. A caller-set `Authorization` header wins, and
|
|
831
|
+
* `init.headers` override same-named headers on a `Request` input.
|
|
832
|
+
* @returns The response; the original `401` when no new token could be
|
|
833
|
+
* minted. A transport failure on the retry itself rejects rather than
|
|
834
|
+
* masquerading as the original `401`.
|
|
835
|
+
*
|
|
836
|
+
* @example
|
|
837
|
+
* ```ts
|
|
838
|
+
* import { DirectClient } from "@udibo/oauth2/client";
|
|
839
|
+
*
|
|
840
|
+
* declare const client: DirectClient;
|
|
841
|
+
* const res = await client.fetch("https://api.example.com/items");
|
|
842
|
+
* ```
|
|
843
|
+
*/
|
|
844
|
+
async fetch(input, init = {}) {
|
|
845
|
+
const stored = await this.#tokenStorage.get();
|
|
846
|
+
const retryInput = input instanceof Request ? input.clone() : input;
|
|
847
|
+
const first = await this.#fetchWithBearer(input, init, stored?.accessToken);
|
|
848
|
+
if (first.status !== 401)
|
|
849
|
+
return first;
|
|
850
|
+
const wwwAuth = first.headers.get("WWW-Authenticate") ?? "";
|
|
851
|
+
if (!/error\s*=\s*"invalid_token"/i.test(wwwAuth))
|
|
852
|
+
return first;
|
|
853
|
+
let accessToken;
|
|
854
|
+
try {
|
|
855
|
+
accessToken = await this.refresh();
|
|
856
|
+
}
|
|
857
|
+
catch {
|
|
858
|
+
return first;
|
|
859
|
+
}
|
|
860
|
+
return await this.#fetchWithBearer(retryInput, init, accessToken);
|
|
861
|
+
}
|
|
862
|
+
async #fetchWithBearer(input, init, accessToken) {
|
|
863
|
+
const headers = mergedHeaders(input, init);
|
|
864
|
+
if (accessToken && !headers.has("Authorization")) {
|
|
865
|
+
headers.set("Authorization", `Bearer ${accessToken}`);
|
|
866
|
+
}
|
|
867
|
+
return await this.fetchImpl(input, { ...init, headers });
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* Revokes the stored refresh token (best effort), clears all local token
|
|
871
|
+
* state, emits `logged_out`, and returns the OIDC end-session URL when the
|
|
872
|
+
* server advertises one.
|
|
873
|
+
*
|
|
874
|
+
* Best effort covers the read too: a refresh-token store that cannot be read
|
|
875
|
+
* skips the revocation rather than failing the sign-out, because a client
|
|
876
|
+
* that stays signed in locally because its store broke is the worse outcome.
|
|
877
|
+
*
|
|
878
|
+
* @param options `returnTo` becomes `post_logout_redirect_uri` on the
|
|
879
|
+
* end-session URL.
|
|
880
|
+
* @returns `{ url }` when the browser should visit the OP's end-session
|
|
881
|
+
* endpoint to drop the upstream SSO session, `{}` when sign-out completed
|
|
882
|
+
* locally.
|
|
883
|
+
*
|
|
884
|
+
* @example
|
|
885
|
+
* ```ts
|
|
886
|
+
* import { DirectClient } from "@udibo/oauth2/client";
|
|
887
|
+
*
|
|
888
|
+
* declare const client: DirectClient;
|
|
889
|
+
* const { url } = await client.logout({ returnTo: "https://app.test/" });
|
|
890
|
+
* if (url) location.assign(url);
|
|
891
|
+
* ```
|
|
892
|
+
*/
|
|
893
|
+
async logout(options = {}) {
|
|
894
|
+
const version = ++this.#sessionVersion;
|
|
895
|
+
const refreshToken = await this.#readRefreshToken(false).catch(() => null);
|
|
896
|
+
const idToken = (await this.#tokenStorage.get())?.idToken;
|
|
897
|
+
if (version !== this.#sessionVersion)
|
|
898
|
+
return {};
|
|
899
|
+
await this.#clearSession();
|
|
900
|
+
this.emit({ type: "logged_out", reason: "user" });
|
|
901
|
+
if (refreshToken) {
|
|
902
|
+
try {
|
|
903
|
+
await this.revoke(refreshToken, { tokenTypeHint: "refresh_token" });
|
|
904
|
+
}
|
|
905
|
+
catch {
|
|
906
|
+
// Non-fatal; the local session clears regardless.
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
let endSession;
|
|
910
|
+
try {
|
|
911
|
+
endSession = await this.#endpoint("endSession");
|
|
912
|
+
}
|
|
913
|
+
catch {
|
|
914
|
+
return {};
|
|
915
|
+
}
|
|
916
|
+
if (!endSession)
|
|
917
|
+
return {};
|
|
918
|
+
const url = new URL(endSession);
|
|
919
|
+
if (idToken)
|
|
920
|
+
url.searchParams.set("id_token_hint", idToken);
|
|
921
|
+
if (options.returnTo) {
|
|
922
|
+
url.searchParams.set("post_logout_redirect_uri", options.returnTo);
|
|
923
|
+
}
|
|
924
|
+
return { url: url.toString() };
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* The signed-in user's claims: the decoded `id_token` when one was issued,
|
|
928
|
+
* otherwise the userinfo endpoint when one is configured, otherwise `null`.
|
|
929
|
+
*
|
|
930
|
+
* @returns The claims, or `null` when no token is stored or neither source
|
|
931
|
+
* is available.
|
|
932
|
+
*/
|
|
933
|
+
async getUser() {
|
|
934
|
+
return await this.#resolveUser(false);
|
|
935
|
+
}
|
|
936
|
+
async #resolveUser(quiet) {
|
|
937
|
+
const tokens = await this.#tokenStorage.get();
|
|
938
|
+
if (!tokens)
|
|
939
|
+
return null;
|
|
940
|
+
if (tokens.idToken)
|
|
941
|
+
return this.decodeIdToken(tokens.idToken);
|
|
942
|
+
if (this.#endpoints.userInfo)
|
|
943
|
+
return await this.#fetchUserInfo(quiet);
|
|
944
|
+
return null;
|
|
945
|
+
}
|
|
946
|
+
/**
|
|
947
|
+
* The session as this client sees it, derived from stored tokens — no
|
|
948
|
+
* network call unless the claims come from a userinfo endpoint.
|
|
949
|
+
*
|
|
950
|
+
* `isAuthenticated` requires a usable access token: a stored bundle whose
|
|
951
|
+
* `accessToken` is non-empty and not past its expiry. A holder of a valid
|
|
952
|
+
* token therefore still reads as signed in when the server issues neither an
|
|
953
|
+
* `id_token` nor a userinfo endpoint, while an expired one reads as signed
|
|
954
|
+
* out rather than leaving the UI authenticated over a dead token. An expired
|
|
955
|
+
* bundle that a stored refresh token could still revive also reads as signed
|
|
956
|
+
* in, with `sessionExpiresIn: 0` — call {@link renewSession} to revive it.
|
|
957
|
+
*
|
|
958
|
+
* `logoutUrl` is always `null`: sign-out runs through {@link logout}, which
|
|
959
|
+
* revokes and clears locally rather than sending the browser somewhere.
|
|
960
|
+
*
|
|
961
|
+
* Never rejects — a failure to resolve claims reports a signed-out session
|
|
962
|
+
* and emits `error`, per {@link OAuth2ClientBase.getSession}.
|
|
963
|
+
*
|
|
964
|
+
* @returns The current {@link SessionState}.
|
|
965
|
+
*/
|
|
966
|
+
async getSession() {
|
|
967
|
+
return await this.#session(false);
|
|
968
|
+
}
|
|
969
|
+
async #session(quiet) {
|
|
970
|
+
try {
|
|
971
|
+
const tokens = await this.#tokenStorage.get();
|
|
972
|
+
if (!tokens?.accessToken)
|
|
973
|
+
return SIGNED_OUT;
|
|
974
|
+
const expiresIn = tokens.accessTokenExpiresAt
|
|
975
|
+
? Math.round((tokens.accessTokenExpiresAt - Date.now()) / 1000)
|
|
976
|
+
: null;
|
|
977
|
+
if (expiresIn !== null && expiresIn <= 0) {
|
|
978
|
+
const revivable = await this.#refreshTokenStorage.get();
|
|
979
|
+
if (!revivable)
|
|
980
|
+
return SIGNED_OUT;
|
|
981
|
+
}
|
|
982
|
+
return {
|
|
983
|
+
isAuthenticated: true,
|
|
984
|
+
user: await this.#resolveUser(quiet),
|
|
985
|
+
sessionExpiresIn: expiresIn === null ? null : Math.max(0, expiresIn),
|
|
986
|
+
logoutUrl: null,
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
catch (error) {
|
|
990
|
+
if (!quiet)
|
|
991
|
+
this.emit({ type: "error", error });
|
|
992
|
+
return SIGNED_OUT;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* Refreshes the access token, then reports the resulting session.
|
|
997
|
+
*
|
|
998
|
+
* Never rejects: a dead grant has already cleared the session and emitted
|
|
999
|
+
* `logged_out`, so the reported state is signed out, while a transient
|
|
1000
|
+
* network failure leaves the stored tokens — and the reported state — as
|
|
1001
|
+
* they were. Unlike {@link refresh}, nothing on this path emits `error`:
|
|
1002
|
+
* it runs on a background timer, and a blip the client will simply retry
|
|
1003
|
+
* must not paint a user-visible error over a session that is still perfectly
|
|
1004
|
+
* good. A dead grant still emits `logged_out`, because the UI does have to
|
|
1005
|
+
* react to that.
|
|
1006
|
+
*
|
|
1007
|
+
* "Transient" here means only "not `invalid_grant`", so a durable failure —
|
|
1008
|
+
* a rotated client secret answering `invalid_client`, or a token endpoint
|
|
1009
|
+
* returning `500` indefinitely — is retried silently for as long as the
|
|
1010
|
+
* timer runs. The session ends when the access token finally expires and a
|
|
1011
|
+
* request 401s, not when renewal starts failing. Subscribe to
|
|
1012
|
+
* `token_refreshed` if you need to notice renewals that stopped landing.
|
|
1013
|
+
*
|
|
1014
|
+
* **A client with no refresh token cannot renew.** That includes the
|
|
1015
|
+
* `client_credentials` case, where re-running the grant would be possible in
|
|
1016
|
+
* principle: doing it automatically is refused because a confidential client
|
|
1017
|
+
* that had signed a *user* in would silently swap that user's session for a
|
|
1018
|
+
* machine identity. Re-run {@link getClientCredentialsToken} yourself.
|
|
1019
|
+
*
|
|
1020
|
+
* @returns The {@link SessionState} after the refresh attempt.
|
|
1021
|
+
*/
|
|
1022
|
+
async renewSession() {
|
|
1023
|
+
await this.#sharedRefresh(false).catch(() => { });
|
|
1024
|
+
return await this.#session(true);
|
|
1025
|
+
}
|
|
1026
|
+
/**
|
|
1027
|
+
* Returns a usable access token, refreshing first when the stored one is
|
|
1028
|
+
* within 30 seconds of expiry.
|
|
1029
|
+
*
|
|
1030
|
+
* Reach for this when you must attach `Authorization: Bearer <token>` to a
|
|
1031
|
+
* request {@link fetch} can't carry (a custom transport, a third-party SDK).
|
|
1032
|
+
* For ordinary HTTP calls prefer {@link fetch}, which attaches the token and
|
|
1033
|
+
* handles the 401/refresh dance for you.
|
|
1034
|
+
*
|
|
1035
|
+
* @returns A non-expired access token.
|
|
1036
|
+
* @throws {InvalidGrantError} when nothing is stored and no refresh token is
|
|
1037
|
+
* available.
|
|
1038
|
+
*/
|
|
1039
|
+
async getAccessToken() {
|
|
1040
|
+
return await this.#accessTokenFor(false);
|
|
1041
|
+
}
|
|
1042
|
+
async #accessTokenFor(quiet) {
|
|
1043
|
+
const tokens = await this.#tokenStorage.get();
|
|
1044
|
+
if (tokens &&
|
|
1045
|
+
(!tokens.accessTokenExpiresAt ||
|
|
1046
|
+
tokens.accessTokenExpiresAt - REFRESH_SKEW_MS > Date.now())) {
|
|
1047
|
+
return tokens.accessToken;
|
|
1048
|
+
}
|
|
1049
|
+
return await this.#sharedRefresh(!quiet);
|
|
1050
|
+
}
|
|
1051
|
+
#basicAuth() {
|
|
1052
|
+
return encodeBasicAuth(this.#clientId, this.#clientSecret);
|
|
1053
|
+
}
|
|
1054
|
+
/**
|
|
1055
|
+
* Applies exactly one client authentication method (RFC 6749 §2.3.1):
|
|
1056
|
+
* HTTP Basic for a confidential client — dropping any body `client_id` so
|
|
1057
|
+
* credentials aren't sent twice — or a body `client_id` for a public one.
|
|
1058
|
+
*/
|
|
1059
|
+
#applyClientAuth(headers, body) {
|
|
1060
|
+
if (this.isConfidential) {
|
|
1061
|
+
headers.Authorization = this.#basicAuth();
|
|
1062
|
+
body.delete("client_id");
|
|
1063
|
+
}
|
|
1064
|
+
else if (!body.has("client_id")) {
|
|
1065
|
+
body.set("client_id", this.#clientId);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
async #postToken(body) {
|
|
1069
|
+
const endpoint = await this.#requireEndpoint("token");
|
|
1070
|
+
const headers = {
|
|
1071
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
1072
|
+
};
|
|
1073
|
+
this.#applyClientAuth(headers, body);
|
|
1074
|
+
const response = await this.#postForm(endpoint, headers, body, "token endpoint");
|
|
1075
|
+
assertTokenResponse(response, "token endpoint");
|
|
1076
|
+
return response;
|
|
1077
|
+
}
|
|
1078
|
+
/**
|
|
1079
|
+
* POSTs a form to a credentialed endpoint and returns its JSON object.
|
|
1080
|
+
* Refuses a redirect rather than replaying the credentials in `body`.
|
|
1081
|
+
*/
|
|
1082
|
+
async #postForm(endpoint, headers, body, what) {
|
|
1083
|
+
const res = await sendGuarded((input, init) => this.fetchImpl(input, init), endpoint, {
|
|
1084
|
+
method: "POST",
|
|
1085
|
+
headers: { Accept: "application/json", ...headers },
|
|
1086
|
+
body,
|
|
1087
|
+
}, what);
|
|
1088
|
+
return await receiveJson(res, what);
|
|
1089
|
+
}
|
|
1090
|
+
#saveTokens(response, version, preserveRefreshToken = false) {
|
|
1091
|
+
return this.#mutateSession(async () => {
|
|
1092
|
+
if (version !== this.#sessionVersion) {
|
|
1093
|
+
throw new InvalidGrantError("session changed during token exchange");
|
|
1094
|
+
}
|
|
1095
|
+
const bundle = responseToBundle(response);
|
|
1096
|
+
await this.#tokenStorage.set(bundle);
|
|
1097
|
+
if (response.refresh_token) {
|
|
1098
|
+
await this.#refreshTokenStorage.set(response.refresh_token);
|
|
1099
|
+
}
|
|
1100
|
+
else if (!preserveRefreshToken) {
|
|
1101
|
+
await this.#refreshTokenStorage.clear();
|
|
1102
|
+
}
|
|
1103
|
+
if (version !== this.#sessionVersion) {
|
|
1104
|
+
throw new InvalidGrantError("session changed during token exchange");
|
|
1105
|
+
}
|
|
1106
|
+
if (!preserveRefreshToken) {
|
|
1107
|
+
this.#sessionVersion++;
|
|
1108
|
+
}
|
|
1109
|
+
return bundle;
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
#mutateSession(mutate) {
|
|
1113
|
+
const operation = this.#sessionWrites.then(mutate);
|
|
1114
|
+
this.#sessionWrites = operation.then(() => undefined, () => undefined);
|
|
1115
|
+
return operation;
|
|
1116
|
+
}
|
|
1117
|
+
#clearSession() {
|
|
1118
|
+
this.#sessionVersion++;
|
|
1119
|
+
return this.#mutateSession(async () => {
|
|
1120
|
+
await this.#tokenStorage.clear();
|
|
1121
|
+
await this.#refreshTokenStorage.clear();
|
|
1122
|
+
await this.#authRequestStorage.clear();
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
/**
|
|
1127
|
+
* Returns `urlStr` with its origin (scheme + host) replaced by `origin`'s,
|
|
1128
|
+
* preserving the path/query. Resolving against the new origin replaces
|
|
1129
|
+
* scheme/host/port cleanly; setting `.host` alone would keep a stale port.
|
|
1130
|
+
*/
|
|
1131
|
+
function withOrigin(urlStr, origin) {
|
|
1132
|
+
const url = new URL(urlStr);
|
|
1133
|
+
return new URL(`${url.pathname}${url.search}${url.hash}`, origin).toString();
|
|
1134
|
+
}
|
|
1135
|
+
function responseToBundle(response) {
|
|
1136
|
+
return {
|
|
1137
|
+
accessToken: response.access_token,
|
|
1138
|
+
tokenType: "Bearer",
|
|
1139
|
+
accessTokenExpiresAt: response.expires_in
|
|
1140
|
+
? Date.now() + response.expires_in * 1000
|
|
1141
|
+
: undefined,
|
|
1142
|
+
scope: response.scope,
|
|
1143
|
+
idToken: response.id_token,
|
|
1144
|
+
};
|
|
1145
|
+
}
|
|
1146
|
+
function metadataToEndpoints(meta) {
|
|
1147
|
+
const out = {};
|
|
1148
|
+
if (meta.authorization_endpoint) {
|
|
1149
|
+
out.authorization = meta.authorization_endpoint;
|
|
1150
|
+
}
|
|
1151
|
+
if (meta.token_endpoint)
|
|
1152
|
+
out.token = meta.token_endpoint;
|
|
1153
|
+
if (meta.revocation_endpoint)
|
|
1154
|
+
out.revocation = meta.revocation_endpoint;
|
|
1155
|
+
if (meta.introspection_endpoint) {
|
|
1156
|
+
out.introspection = meta.introspection_endpoint;
|
|
1157
|
+
}
|
|
1158
|
+
if (meta.device_authorization_endpoint) {
|
|
1159
|
+
out.deviceAuthorization = meta.device_authorization_endpoint;
|
|
1160
|
+
}
|
|
1161
|
+
if (meta.userinfo_endpoint)
|
|
1162
|
+
out.userInfo = meta.userinfo_endpoint;
|
|
1163
|
+
if (meta.end_session_endpoint)
|
|
1164
|
+
out.endSession = meta.end_session_endpoint;
|
|
1165
|
+
return out;
|
|
1166
|
+
}
|
|
1167
|
+
function toSearchParams(input) {
|
|
1168
|
+
if (input instanceof URLSearchParams)
|
|
1169
|
+
return input;
|
|
1170
|
+
if (input instanceof URL)
|
|
1171
|
+
return input.searchParams;
|
|
1172
|
+
try {
|
|
1173
|
+
return new URL(input).searchParams;
|
|
1174
|
+
}
|
|
1175
|
+
catch {
|
|
1176
|
+
return new URLSearchParams(input.startsWith("?") ? input.slice(1) : input);
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
function defaultAuthRequestStorage() {
|
|
1180
|
+
const inBrowserDocument = typeof document !== "undefined" &&
|
|
1181
|
+
typeof sessionStorage !== "undefined";
|
|
1182
|
+
return inBrowserDocument
|
|
1183
|
+
? new SessionStorageAuthRequestStorage()
|
|
1184
|
+
: new MemoryAuthRequestStorage();
|
|
1185
|
+
}
|
|
1186
|
+
function assertUsableSecret(secret) {
|
|
1187
|
+
if (secret === undefined)
|
|
1188
|
+
return;
|
|
1189
|
+
if (secret === "") {
|
|
1190
|
+
throw new TypeError("`clientSecret` is an empty string — a confidential client cannot " +
|
|
1191
|
+
"authenticate with a blank secret. Omit the property for a public " +
|
|
1192
|
+
"client, or fix the value (a variable that resolved to blank is the " +
|
|
1193
|
+
"usual cause).");
|
|
1194
|
+
}
|
|
1195
|
+
if (typeof document !== "undefined") {
|
|
1196
|
+
throw new Error("refusing to construct a confidential DirectClient in a document " +
|
|
1197
|
+
"context: a `clientSecret` here ships to every visitor. Use a public " +
|
|
1198
|
+
"client (omit `clientSecret`; PKCE protects the code), or move the " +
|
|
1199
|
+
"exchange behind a BffClient.");
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
/**
|
|
1203
|
+
* RFC 8414 §3.3 / OIDC Discovery §4.3: the `issuer` a metadata document
|
|
1204
|
+
* reports must be the issuer it was fetched from. Without this a document
|
|
1205
|
+
* served (or redirected to) from elsewhere could name another provider's
|
|
1206
|
+
* endpoints, which is the mix-up attack the requirement exists to stop.
|
|
1207
|
+
*/
|
|
1208
|
+
function assertIssuer(meta, issuer) {
|
|
1209
|
+
const reported = meta.issuer;
|
|
1210
|
+
if (typeof reported !== "string" || reported.length === 0) {
|
|
1211
|
+
throw new ServerError("the discovery document reported no `issuer` (RFC 8414 §3.3)");
|
|
1212
|
+
}
|
|
1213
|
+
if (trimSlash(reported) !== trimSlash(issuer)) {
|
|
1214
|
+
throw new ServerError(`the discovery document reported issuer ` +
|
|
1215
|
+
`"${sanitizeProviderText(reported)}" but was fetched ` +
|
|
1216
|
+
`from "${issuer}" (RFC 8414 §3.3); refusing metadata that names ` +
|
|
1217
|
+
`another provider's endpoints`);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
function trimSlash(url) {
|
|
1221
|
+
return url.endsWith("/") ? url.slice(0, -1) : url;
|
|
1222
|
+
}
|
|
1223
|
+
function isOAuth2ErrorCode(error, code) {
|
|
1224
|
+
return isOAuth2Error(error) && error.extensions.error === code;
|
|
1225
|
+
}
|
|
1226
|
+
/**
|
|
1227
|
+
* Sleeps `ms`, or rejects with {@link AccessDeniedError} if `signal` aborts —
|
|
1228
|
+
* so cancelling a device-flow poll mid-wait surfaces as `access_denied`, the
|
|
1229
|
+
* same as the loop-top abort guard in {@link DirectClient.pollDeviceToken}.
|
|
1230
|
+
* The timer/abort plumbing is `@std/async/delay`; only the OAuth2 error
|
|
1231
|
+
* mapping is ours (`delay` rejects solely on abort).
|
|
1232
|
+
*/
|
|
1233
|
+
function pollDelay(ms, signal) {
|
|
1234
|
+
return delay(ms, { signal }).catch(() => {
|
|
1235
|
+
throw new AccessDeniedError("aborted");
|
|
1236
|
+
});
|
|
1237
|
+
}
|