@udibo/oauth2 0.4.0 → 0.5.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.
Files changed (161) hide show
  1. package/README.md +3 -3
  2. package/SECURITY.md +0 -4
  3. package/esm/adapters/hono/_common.d.ts +10 -13
  4. package/esm/adapters/hono/_common.js +11 -20
  5. package/esm/adapters/hono/authorization-server.d.ts +16 -10
  6. package/esm/adapters/hono/authorization-server.js +13 -8
  7. package/esm/adapters/hono/bff/auth-request-store.d.ts +4 -0
  8. package/esm/adapters/hono/bff/auth-request-store.js +4 -0
  9. package/esm/adapters/hono/bff/bff.d.ts +29 -18
  10. package/esm/adapters/hono/bff/bff.js +15 -11
  11. package/esm/adapters/hono/bff/proxy.js +7 -55
  12. package/esm/adapters/hono/bff/session-store.d.ts +9 -9
  13. package/esm/adapters/hono/bff/session-store.js +3 -2
  14. package/esm/adapters/hono/bff/testing.d.ts +14 -3
  15. package/esm/adapters/hono/bff/testing.js +14 -3
  16. package/esm/adapters/hono/identity.d.ts +5 -3
  17. package/esm/adapters/hono/identity.js +1 -17
  18. package/esm/adapters/hono/resource-server.d.ts +3 -2
  19. package/esm/adapters/hono/resource-server.js +3 -2
  20. package/esm/client/_http.d.ts +4 -7
  21. package/esm/client/_http.js +4 -37
  22. package/esm/client/base.d.ts +3 -6
  23. package/esm/client/bff-client.d.ts +7 -6
  24. package/esm/client/bff-client.js +7 -6
  25. package/esm/client/browser-storage.d.ts +33 -34
  26. package/esm/client/browser-storage.js +47 -32
  27. package/esm/client/check.d.ts +10 -3
  28. package/esm/client/check.js +28 -8
  29. package/esm/client/direct-client.d.ts +49 -29
  30. package/esm/client/direct-client.js +59 -59
  31. package/esm/client/discovery-cache.d.ts +12 -5
  32. package/esm/client/discovery-cache.js +11 -4
  33. package/esm/client/events.d.ts +11 -3
  34. package/esm/client/events.js +1 -1
  35. package/esm/client/mod.d.ts +6 -4
  36. package/esm/client/mod.js +5 -3
  37. package/esm/client/storage.d.ts +53 -23
  38. package/esm/client/storage.js +43 -15
  39. package/esm/errors.d.ts +15 -12
  40. package/esm/errors.js +12 -8
  41. package/esm/identity/delivery.d.ts +7 -7
  42. package/esm/identity/delivery.js +1 -2
  43. package/esm/identity/errors.d.ts +10 -4
  44. package/esm/identity/errors.js +4 -1
  45. package/esm/identity/events.d.ts +21 -5
  46. package/esm/identity/events.js +4 -2
  47. package/esm/identity/external/_shared.d.ts +5 -7
  48. package/esm/identity/external/_shared.js +5 -7
  49. package/esm/identity/external/_token-exchange.d.ts +3 -6
  50. package/esm/identity/external/_token-exchange.js +2 -5
  51. package/esm/identity/external/apple-client-secret.d.ts +13 -8
  52. package/esm/identity/external/apple-client-secret.js +9 -6
  53. package/esm/identity/external/apple.d.ts +17 -10
  54. package/esm/identity/external/apple.js +12 -9
  55. package/esm/identity/external/discord.d.ts +5 -1
  56. package/esm/identity/external/errors.d.ts +6 -2
  57. package/esm/identity/external/errors.js +6 -2
  58. package/esm/identity/external/flow.d.ts +9 -6
  59. package/esm/identity/external/flow.js +6 -5
  60. package/esm/identity/external/github.d.ts +7 -3
  61. package/esm/identity/external/github.js +2 -2
  62. package/esm/identity/external/google.d.ts +5 -1
  63. package/esm/identity/external/oauth2.d.ts +13 -2
  64. package/esm/identity/external/oauth2.js +3 -3
  65. package/esm/identity/external/oidc.d.ts +21 -10
  66. package/esm/identity/external/oidc.js +11 -6
  67. package/esm/identity/external/provider.d.ts +4 -2
  68. package/esm/identity/identifier.d.ts +6 -5
  69. package/esm/identity/identifier.js +6 -5
  70. package/esm/identity/lockout.d.ts +4 -3
  71. package/esm/identity/lockout.js +4 -3
  72. package/esm/identity/mfa/mod.d.ts +2 -1
  73. package/esm/identity/mfa/mod.js +2 -1
  74. package/esm/identity/mfa/recovery-codes.d.ts +2 -1
  75. package/esm/identity/mfa/service.d.ts +29 -11
  76. package/esm/identity/mfa/service.js +35 -11
  77. package/esm/identity/mfa/totp.d.ts +4 -2
  78. package/esm/identity/mfa/totp.js +4 -2
  79. package/esm/identity/migration.d.ts +8 -7
  80. package/esm/identity/migration.js +9 -12
  81. package/esm/identity/mod.d.ts +2 -2
  82. package/esm/identity/mod.js +2 -2
  83. package/esm/identity/otp.d.ts +23 -16
  84. package/esm/identity/otp.js +13 -12
  85. package/esm/identity/password-policy.d.ts +3 -3
  86. package/esm/identity/password-policy.js +3 -3
  87. package/esm/identity/password.d.ts +23 -20
  88. package/esm/identity/password.js +15 -16
  89. package/esm/identity/rate-limit.d.ts +1 -7
  90. package/esm/identity/rate-limit.js +1 -2
  91. package/esm/identity/service.d.ts +76 -44
  92. package/esm/identity/service.js +86 -60
  93. package/esm/identity/session.d.ts +5 -4
  94. package/esm/identity/session.js +5 -4
  95. package/esm/identity/token-flow.d.ts +8 -8
  96. package/esm/identity/token-flow.js +5 -6
  97. package/esm/models/client.d.ts +7 -4
  98. package/esm/models/token.d.ts +2 -3
  99. package/esm/react/callback.d.ts +3 -3
  100. package/esm/react/callback.js +3 -3
  101. package/esm/react/components/class-names.d.ts +3 -6
  102. package/esm/react/components/class-names.js +3 -6
  103. package/esm/react/components/internal.d.ts +7 -17
  104. package/esm/react/components/internal.js +7 -17
  105. package/esm/react/components/mod.d.ts +7 -5
  106. package/esm/react/components/mod.js +7 -5
  107. package/esm/react/components/types.d.ts +8 -6
  108. package/esm/react/context.d.ts +3 -3
  109. package/esm/react/provider.d.ts +3 -2
  110. package/esm/react/provider.js +0 -13
  111. package/esm/react/require-auth.d.ts +0 -3
  112. package/esm/react/require-auth.js +0 -3
  113. package/esm/react/testing.d.ts +3 -2
  114. package/esm/react/testing.js +3 -3
  115. package/esm/react/use-oauth2.d.ts +1 -1
  116. package/esm/server/authorization-server.d.ts +81 -54
  117. package/esm/server/authorization-server.js +76 -100
  118. package/esm/server/grants/authorization-code.d.ts +7 -3
  119. package/esm/server/grants/authorization-code.js +6 -2
  120. package/esm/server/grants/device-authorization.d.ts +1 -1
  121. package/esm/server/grants/grant.d.ts +15 -8
  122. package/esm/server/grants/grant.js +26 -18
  123. package/esm/server/grants/password.d.ts +1 -1
  124. package/esm/server/grants/refresh-token.d.ts +6 -3
  125. package/esm/server/grants/refresh-token.js +4 -3
  126. package/esm/server/introspection-token-reader.d.ts +16 -7
  127. package/esm/server/introspection-token-reader.js +9 -3
  128. package/esm/server/jwks-token-reader.d.ts +3 -3
  129. package/esm/server/jwks-token-reader.js +3 -3
  130. package/esm/server/public-suffix/rules.d.ts +4 -4
  131. package/esm/server/public-suffix/rules.js +4 -4
  132. package/esm/server/redirect-uri.d.ts +6 -9
  133. package/esm/server/redirect-uri.js +7 -34
  134. package/esm/server/resource-server.d.ts +5 -3
  135. package/esm/server/resource-server.js +5 -3
  136. package/esm/server/services/client.d.ts +5 -1
  137. package/esm/server/services/device-authorization.d.ts +6 -6
  138. package/esm/server/services/token.d.ts +15 -8
  139. package/esm/server/services/token.js +1 -1
  140. package/esm/server/signing-keys.d.ts +24 -9
  141. package/esm/server/signing-keys.js +20 -9
  142. package/esm/testing/contract/mod.d.ts +3 -3
  143. package/esm/testing/contract/mod.js +3 -3
  144. package/esm/testing/mod.d.ts +4 -2
  145. package/esm/testing/mod.js +4 -2
  146. package/esm/testing/services.d.ts +1 -1
  147. package/esm/testing/services.js +1 -1
  148. package/esm/testing/tenant.d.ts +13 -4
  149. package/esm/utils/_buffer.d.ts +3 -2
  150. package/esm/utils/_buffer.js +3 -2
  151. package/esm/utils/_timing-safe.d.ts +8 -6
  152. package/esm/utils/_timing-safe.js +8 -6
  153. package/esm/utils/basic-auth.d.ts +5 -12
  154. package/esm/utils/basic-auth.js +5 -12
  155. package/esm/utils/crypto.d.ts +2 -1
  156. package/esm/utils/crypto.js +2 -1
  157. package/esm/utils/text.d.ts +2 -9
  158. package/esm/utils/text.js +2 -9
  159. package/esm/utils/url.d.ts +2 -2
  160. package/esm/utils/url.js +0 -2
  161. package/package.json +1 -1
package/README.md CHANGED
@@ -27,7 +27,7 @@ without a hosted account.
27
27
  Requires Deno 2 for the development workflow:
28
28
 
29
29
  ```sh
30
- deno add jsr:@udibo/oauth2@0.1.0
30
+ deno add jsr:@udibo/oauth2
31
31
  ```
32
32
 
33
33
  Import the part you use; there is no root barrel or default export:
@@ -38,8 +38,8 @@ import { HonoBff } from "@udibo/oauth2/hono/bff";
38
38
  import { HonoResourceServer } from "@udibo/oauth2/hono/resource-server";
39
39
  ```
40
40
 
41
- Version `0.1.0` is a pre-1.0 API. Minor releases may contain breaking changes;
42
- review the [stability policy](docs/stability.md) before upgrading.
41
+ The package is pre-1.0. Minor releases may contain breaking changes; review the
42
+ [stability policy](docs/stability.md) before upgrading.
43
43
 
44
44
  ## How the pieces fit
45
45
 
package/SECURITY.md CHANGED
@@ -4,10 +4,6 @@
4
4
  OpenID Connect protocol surface and password-based identity flows. We take
5
5
  reports seriously and value the time of security researchers.
6
6
 
7
- > **Status note:** the package is not yet published to a public registry. Until
8
- > it is, the disclosure channel below is drafted and monitored, but the package
9
- > has no external users; the process activates fully at publish.
10
-
11
7
  ## Supported versions
12
8
 
13
9
  | Version | Supported |
@@ -2,11 +2,8 @@
2
2
  * Internal helpers shared by the Hono adapters.
3
3
  *
4
4
  * Not part of the public API — use {@link HonoResourceServer} or
5
- * {@link HonoAuthorizationServer} instead. Keeping the `protect` /
6
- * `authenticate` / `getContext` implementations in one place lets both
7
- * adapter classes stay as direct subclasses of the server type they
8
- * specialize (no composition / companion objects) without duplicating the
9
- * Hono-specific plumbing.
5
+ * {@link HonoAuthorizationServer} instead. Only {@link OAUTH2_CONTEXT_KEY}
6
+ * and {@link HonoResourceServerVariables} are re-exported publicly.
10
7
  *
11
8
  * @module
12
9
  */
@@ -37,7 +34,7 @@ export interface HonoResourceServerVariables<Client extends ClientInterface, Use
37
34
  export declare function createProtectMiddleware<Client extends ClientInterface, User, S extends AbstractScope>(server: ResourceServer<Client, User, S>, requiredScope?: S | string): MiddlewareHandler;
38
35
  /**
39
36
  * Builds a Hono middleware that asserts the **already-authenticated** request
40
- * (whose context a prior `protect` / `attachToken` set) carries
37
+ * (whose context a prior adapter or BFF `protect` set) carries
41
38
  * `requiredScope`, without re-validating the token. Shared by both adapter
42
39
  * classes' `requireScope` so the check and error stay identical to `protect`'s
43
40
  * scope enforcement. `adapterName` names the class in the "context missing"
@@ -52,15 +49,15 @@ export declare function createRequireScopeMiddleware<Client extends ClientInterf
52
49
  */
53
50
  export declare function createRequireMiddleware<Client extends ClientInterface, User, S extends AbstractScope>(server: ResourceServer<Client, User, S>, adapterName: string, conditions: RequireConditions): MiddlewareHandler;
54
51
  /**
55
- * Resolves a Hono {@link Context} or raw {@link Request} to a Request and
56
- * calls the base {@link ResourceServer.authenticate} (bypassing any
57
- * Context-accepting override). This is what both adapter classes'
58
- * `authenticate` overrides end up calling.
52
+ * Authenticates a Hono {@link Context} or raw {@link Request} with the base
53
+ * {@link ResourceServer.authenticate}, bypassing the adapters'
54
+ * Context-accepting override.
59
55
  */
60
56
  export declare function authenticateHonoRequest<Client extends ClientInterface, User, S extends AbstractScope>(server: ResourceServer<Client, User, S>, c: Context | Request, requiredScope?: S | string): Promise<AuthenticatedContext<Client, User, S>>;
61
57
  /**
62
- * Reads the authenticated OAuth2 context off the Hono context. Throws if
63
- * `createProtectMiddleware` has not run first — the error message names the
64
- * class the user invoked to make the fix obvious.
58
+ * Reads the authenticated OAuth2 context off the Hono context.
59
+ *
60
+ * @throws {Error} If no `protect` (the adapter's or the BFF's) middleware set
61
+ * it first; the message names `adapterName`.
65
62
  */
66
63
  export declare function readContext<Client extends ClientInterface, User, S extends AbstractScope>(c: Context, adapterName: string): AuthenticatedContext<Client, User, S>;
@@ -2,11 +2,8 @@
2
2
  * Internal helpers shared by the Hono adapters.
3
3
  *
4
4
  * Not part of the public API — use {@link HonoResourceServer} or
5
- * {@link HonoAuthorizationServer} instead. Keeping the `protect` /
6
- * `authenticate` / `getContext` implementations in one place lets both
7
- * adapter classes stay as direct subclasses of the server type they
8
- * specialize (no composition / companion objects) without duplicating the
9
- * Hono-specific plumbing.
5
+ * {@link HonoAuthorizationServer} instead. Only {@link OAUTH2_CONTEXT_KEY}
6
+ * and {@link HonoResourceServerVariables} are re-exported publicly.
10
7
  *
11
8
  * @module
12
9
  */
@@ -35,7 +32,7 @@ export function createProtectMiddleware(server, requiredScope) {
35
32
  }
36
33
  /**
37
34
  * Builds a Hono middleware that asserts the **already-authenticated** request
38
- * (whose context a prior `protect` / `attachToken` set) carries
35
+ * (whose context a prior adapter or BFF `protect` set) carries
39
36
  * `requiredScope`, without re-validating the token. Shared by both adapter
40
37
  * classes' `requireScope` so the check and error stay identical to `protect`'s
41
38
  * scope enforcement. `adapterName` names the class in the "context missing"
@@ -72,31 +69,25 @@ export function createRequireMiddleware(server, adapterName, conditions) {
72
69
  };
73
70
  }
74
71
  /**
75
- * Resolves a Hono {@link Context} or raw {@link Request} to a Request and
76
- * calls the base {@link ResourceServer.authenticate} (bypassing any
77
- * Context-accepting override). This is what both adapter classes'
78
- * `authenticate` overrides end up calling.
72
+ * Authenticates a Hono {@link Context} or raw {@link Request} with the base
73
+ * {@link ResourceServer.authenticate}, bypassing the adapters'
74
+ * Context-accepting override.
79
75
  */
80
76
  export function authenticateHonoRequest(server, c, requiredScope) {
81
77
  const request = c instanceof Request ? c : c.req.raw;
82
78
  return baseAuthenticate(server, request, requiredScope);
83
79
  }
84
- /**
85
- * Invokes the base {@link ResourceServer.authenticate} directly, bypassing
86
- * any subclass override. Necessary because {@link HonoResourceServer}
87
- * overrides `authenticate` to accept `Context | Request`; if this module
88
- * called `server.authenticate(...)` the dispatch would land back on that
89
- * override and recurse forever.
90
- */
80
+ // Base method on purpose: the Hono subclasses' override would recurse.
91
81
  function baseAuthenticate(server, request, requiredScope) {
92
82
  // deno-lint-ignore no-explicit-any
93
83
  const base = ResourceServer.prototype;
94
84
  return base.authenticate.call(server, request, requiredScope);
95
85
  }
96
86
  /**
97
- * Reads the authenticated OAuth2 context off the Hono context. Throws if
98
- * `createProtectMiddleware` has not run first — the error message names the
99
- * class the user invoked to make the fix obvious.
87
+ * Reads the authenticated OAuth2 context off the Hono context.
88
+ *
89
+ * @throws {Error} If no `protect` (the adapter's or the BFF's) middleware set
90
+ * it first; the message names `adapterName`.
100
91
  */
101
92
  export function readContext(c, adapterName) {
102
93
  const ctx = c.get(OAUTH2_CONTEXT_KEY);
@@ -69,8 +69,9 @@ export type HonoAuthenticateUserFn<User> = (c: Context) => Promise<{
69
69
  * Receives the Hono {@link Context} so the callback can render a consent
70
70
  * page, read form input, etc. Return one of:
71
71
  *
72
- * - `{ approved: true, scope? }` — consent granted (optionally narrowing the
73
- * scope per RFC 6749 §3.3).
72
+ * - `{ approved: true, scope? }` — consent granted. `scope`, when set,
73
+ * replaces the granted scope (RFC 6749 §3.3) and is not checked against the
74
+ * request, so return a subset.
74
75
  * - `{ approved: false }` — consent denied; the framework redirects to
75
76
  * `redirect_uri` with `error=access_denied`.
76
77
  * - A `Response` (typically `c.html(...)` rendering a consent page) —
@@ -93,8 +94,9 @@ export interface HonoRoutesOptions<Client extends ClientInterface, User, S exten
93
94
  export type HonoAuthorizeHandlerOptions<Client extends ClientInterface, User, S extends AbstractScope> = HonoRoutesOptions<Client, User, S>;
94
95
  /**
95
96
  * Hono-specific wrapper around {@link AuthorizationServer}. Adds endpoint
96
- * handler factories and the three resource-server-shaped helpers
97
- * (`protect`, `authenticate`, `getContext`) against this same instance.
97
+ * handler factories and the resource-server-shaped helpers (`protect`,
98
+ * `requireScope`, `require`, `authenticate`, `getContext`) against this same
99
+ * instance.
98
100
  *
99
101
  * Resource-server behavior (realm, errorFormat, throwOnError, token lookup)
100
102
  * reads directly off the auth-server instance, so mutating those fields
@@ -107,8 +109,9 @@ export declare class HonoAuthorizationServer<Client extends ClientInterface, Use
107
109
  /**
108
110
  * Returns a Hono middleware that validates the bearer token against this
109
111
  * server's token service. On success, sets the authenticated context at
110
- * `c.get("@udibo/oauth2")`; on failure returns an RFC 6749 error response
111
- * with a `WWW-Authenticate` header per RFC 6750 Section 3.
112
+ * `c.get("@udibo/oauth2")`; on failure returns an error response, with a
113
+ * `WWW-Authenticate` challenge (RFC 6750 Section 3) on a 401 or 403, or
114
+ * rethrows when `throwOnError` is set.
112
115
  */
113
116
  protect(requiredScope?: S | string): MiddlewareHandler;
114
117
  /**
@@ -198,14 +201,17 @@ export declare class HonoAuthorizationServer<Client extends ClientInterface, Use
198
201
  * - `GET /.well-known/openid-configuration`
199
202
  * - `GET /jwks` and `GET`/`POST /userinfo` (live when OIDC issuance is
200
203
  * configured via `signingKeys`)
204
+ * - `GET`/`POST /end_session` (live when the server has an `endSession`
205
+ * callback)
201
206
  *
202
207
  * Mount with `app.route("/oauth2", server.routes({...}))`.
203
208
  *
204
209
  * Every path and verb above comes from
205
- * {@link ENDPOINT_PATHS}/{@link ENDPOINT_METHODS}, so the mounted surface and
206
- * the URLs the server advertises can never drift apart. Users who want custom
207
- * paths or only a subset of endpoints should mount the individual handler
208
- * factories instead.
210
+ * {@link ENDPOINT_PATHS}/{@link ENDPOINT_METHODS}, the same paths the server
211
+ * derives from an `issuer` alone. The advertised URLs match the mount only
212
+ * when the issuer (or each explicit `*Endpoint` in the resolved context)
213
+ * points at where you mounted it. Users who want custom paths or only a
214
+ * subset of endpoints should mount the individual handler factories instead.
209
215
  */
210
216
  routes(options: HonoRoutesOptions<Client, User, S>): Hono;
211
217
  }
@@ -38,8 +38,9 @@ import { AuthorizationServer, ENDPOINT_METHODS, ENDPOINT_PATHS, } from "../../se
38
38
  import { authenticateHonoRequest, createProtectMiddleware, createRequireMiddleware, createRequireScopeMiddleware, readContext, } from "./_common.js";
39
39
  /**
40
40
  * Hono-specific wrapper around {@link AuthorizationServer}. Adds endpoint
41
- * handler factories and the three resource-server-shaped helpers
42
- * (`protect`, `authenticate`, `getContext`) against this same instance.
41
+ * handler factories and the resource-server-shaped helpers (`protect`,
42
+ * `requireScope`, `require`, `authenticate`, `getContext`) against this same
43
+ * instance.
43
44
  *
44
45
  * Resource-server behavior (realm, errorFormat, throwOnError, token lookup)
45
46
  * reads directly off the auth-server instance, so mutating those fields
@@ -54,8 +55,9 @@ export class HonoAuthorizationServer extends AuthorizationServer {
54
55
  /**
55
56
  * Returns a Hono middleware that validates the bearer token against this
56
57
  * server's token service. On success, sets the authenticated context at
57
- * `c.get("@udibo/oauth2")`; on failure returns an RFC 6749 error response
58
- * with a `WWW-Authenticate` header per RFC 6750 Section 3.
58
+ * `c.get("@udibo/oauth2")`; on failure returns an error response, with a
59
+ * `WWW-Authenticate` challenge (RFC 6750 Section 3) on a 401 or 403, or
60
+ * rethrows when `throwOnError` is set.
59
61
  */
60
62
  protect(requiredScope) {
61
63
  return createProtectMiddleware(this, requiredScope);
@@ -182,14 +184,17 @@ export class HonoAuthorizationServer extends AuthorizationServer {
182
184
  * - `GET /.well-known/openid-configuration`
183
185
  * - `GET /jwks` and `GET`/`POST /userinfo` (live when OIDC issuance is
184
186
  * configured via `signingKeys`)
187
+ * - `GET`/`POST /end_session` (live when the server has an `endSession`
188
+ * callback)
185
189
  *
186
190
  * Mount with `app.route("/oauth2", server.routes({...}))`.
187
191
  *
188
192
  * Every path and verb above comes from
189
- * {@link ENDPOINT_PATHS}/{@link ENDPOINT_METHODS}, so the mounted surface and
190
- * the URLs the server advertises can never drift apart. Users who want custom
191
- * paths or only a subset of endpoints should mount the individual handler
192
- * factories instead.
193
+ * {@link ENDPOINT_PATHS}/{@link ENDPOINT_METHODS}, the same paths the server
194
+ * derives from an `issuer` alone. The advertised URLs match the mount only
195
+ * when the issuer (or each explicit `*Endpoint` in the resolved context)
196
+ * points at where you mounted it. Users who want custom paths or only a
197
+ * subset of endpoints should mount the individual handler factories instead.
193
198
  */
194
199
  routes(options) {
195
200
  const handlers = {
@@ -123,6 +123,10 @@ export interface AuthRequestStorageFactory {
123
123
  * `unknown state parameter`. The AES key is derived on first use and shared by
124
124
  * every later request.
125
125
  *
126
+ * It has no `take`: the record travels with the browser and cannot be claimed
127
+ * atomically, so a replayed callback is bounded by the authorization server's
128
+ * single-use code and PKCE.
129
+ *
126
130
  * @example
127
131
  * ```ts
128
132
  * const bff = new HonoBff({
@@ -53,6 +53,10 @@ function cookieAttributes(options, maxAgeSeconds) {
53
53
  * `unknown state parameter`. The AES key is derived on first use and shared by
54
54
  * every later request.
55
55
  *
56
+ * It has no `take`: the record travels with the browser and cannot be claimed
57
+ * atomically, so a replayed callback is bounded by the authorization server's
58
+ * single-use code and PKCE.
59
+ *
56
60
  * @example
57
61
  * ```ts
58
62
  * const bff = new HonoBff({
@@ -4,7 +4,7 @@
4
4
  * Wraps a {@link DirectClient} configured with a client secret
5
5
  * and exposes the session endpoints a browser SPA talks to:
6
6
  *
7
- * - `POST /auth/login` — redirects the browser to the authorize endpoint,
7
+ * - `GET|POST /auth/login` — redirects the browser to the authorize endpoint,
8
8
  * persisting a per-request state/verifier record and binding its `state` to
9
9
  * this browser with a short-lived cookie.
10
10
  * - `GET /auth/callback` — requires the browser to present that binding
@@ -15,7 +15,8 @@
15
15
  * the refresh token upstream. Registered for both methods because the
16
16
  * shipped React client navigates the browser here (a GET).
17
17
  * - `GET /auth/session` — returns `{ isAuthenticated, user }` from the
18
- * session. **Never** exposes tokens to the browser.
18
+ * session, plus `sessionExpiresIn` and `logoutUrl` when signed in.
19
+ * **Never** exposes tokens to the browser.
19
20
  * - `POST /auth/backchannel` — OIDC Back-Channel Logout receiver, mounted only
20
21
  * when `backchannelLogout` is configured. A server-to-server call from the OP
21
22
  * that ends the matching server-side session(s).
@@ -62,12 +63,12 @@ import { type HonoBffProxyOptions } from "./proxy.js";
62
63
  *
63
64
  * Accepted in two shapes so both deployment topologies stay symmetric:
64
65
  *
65
- * - **Own-issuer** (the Udibo case): pass a {@link HonoAuthorizationServer}.
66
+ * - **Own-issuer**: pass a {@link HonoAuthorizationServer}.
66
67
  * `protect()` validates against the same in-process token service the
67
68
  * issuer uses — no introspection round-trip.
68
69
  * - **Third-party-issuer**: pass a {@link HonoResourceServer} configured
69
- * with `IntrospectionTokenReader`. `protect()` validates via RFC 7662
70
- * against the upstream.
70
+ * with `IntrospectionTokenReader` (RFC 7662 against the upstream) or
71
+ * `JwksTokenReader` (signed JWT access tokens, verified locally).
71
72
  */
72
73
  export type HonoBffResourceServer<Client extends ClientInterface = any, User = any, S extends AbstractScope = BasicScope> = HonoResourceServer<Client, User, S> | HonoAuthorizationServer<Client, User, S>;
73
74
  /**
@@ -122,9 +123,11 @@ export interface HonoBffCookieOptions {
122
123
  */
123
124
  domain?: string;
124
125
  /**
125
- * `Max-Age` in seconds, for the **cookie only**. Defaults to
126
+ * `Max-Age` in seconds, for the **cookie only**. In the default
127
+ * `sessionMode: "own"` it defaults to
126
128
  * {@linkcode HonoBffOptions.sessionMaxAgeMs}, so the browser keeps the
127
- * cookie exactly as long as the session behind it is honored.
129
+ * cookie exactly as long as the session behind it is honored; in
130
+ * `sessionMode: "shared"` it has no default and is not cross-checked.
128
131
  *
129
132
  * Set it to lengthen the cookie past the session (harmless: the session ends
130
133
  * first and the stale cookie is cleared). Setting it **shorter** than the
@@ -143,8 +146,7 @@ export interface HonoBffCookieOptions {
143
146
  *
144
147
  * The BFF's session cookie is an ambient credential the browser attaches
145
148
  * automatically, so a cross-site page can trigger credentialed requests
146
- * (classic CSRF). The defense (as used by Duende.BFF's `X-CSRF` and Curity's
147
- * `token-handler-version` headers) requires a **custom request header** on the
149
+ * (classic CSRF). The defense requires a **custom request header** on the
148
150
  * fetch-driven surface: a custom header makes the request "non-simple", forcing
149
151
  * a CORS preflight that an untrusted origin cannot satisfy, so the browser never
150
152
  * attaches the cookie. The shipped `BffClient` (and therefore the React
@@ -269,12 +271,13 @@ export interface BffCallbackError {
269
271
  * OAuth2-style error code: the IDP's `error` (e.g. `access_denied`),
270
272
  * `invalid_request` (missing `code`/`state`, or a `state` this browser never
271
273
  * started — see {@link HonoBffOptions.loginStateTtlMs}), or `invalid_grant`
272
- * (the code exchange failed — expired/replayed code, unknown `state`).
274
+ * (the code exchange failed — expired/replayed code, unknown `state` — or
275
+ * a later step threw: `resolveUser` or the session store).
273
276
  */
274
277
  error: string;
275
278
  /** Human-readable description; never contains secrets. */
276
279
  error_description: string;
277
- /** The thrown cause for the `invalid_grant` (exchange) case; otherwise `undefined`. */
280
+ /** The thrown cause for the `invalid_grant` case; otherwise `undefined`. */
278
281
  cause?: unknown;
279
282
  }
280
283
  /** Options accepted by the {@link HonoBff} constructor. */
@@ -394,7 +397,7 @@ export interface HonoBffOptions {
394
397
  * an end-session endpoint (`endpoints.endSession`, or via discovery),
395
398
  * `/auth/logout` — after destroying the local session — additionally
396
399
  * redirects the browser to the OP's `end_session_endpoint` with
397
- * `id_token_hint` (the session's id_token, if any) and a
400
+ * `id_token_hint` (the session's id_token, if any), `client_id`, and a
398
401
  * `post_logout_redirect_uri` built from the resolved origin + the
399
402
  * open-redirect-guarded `return_to`. This ends the upstream IDP SSO session,
400
403
  * not just the local BFF session.
@@ -557,7 +560,8 @@ export interface HonoBffOptions {
557
560
  * Pass a {@link HonoAuthorizationServer} for own-issuer co-located
558
561
  * deployments (validation happens against the in-process token service)
559
562
  * or a {@link HonoResourceServer} for third-party-issuer deployments
560
- * (validation goes through `IntrospectionTokenReader`).
563
+ * (validation goes through its token reader, such as
564
+ * `IntrospectionTokenReader` or `JwksTokenReader`).
561
565
  */
562
566
  resourceServer?: HonoBffResourceServer;
563
567
  /**
@@ -604,7 +608,8 @@ export declare class HonoBff {
604
608
  * {@linkcode HonoBffPaths} entry falls outside a non-empty
605
609
  * {@linkcode HonoBffPaths.basePath}, when the session cookie would die
606
610
  * before the session behind it (see
607
- * {@linkcode HonoBffOptions.sessionMaxAgeMs}), or when
611
+ * {@linkcode HonoBffOptions.sessionMaxAgeMs}), when `sessionMaxAgeMs` or a
612
+ * numeric `cookie.maxAge` is not positive or exceeds 400 days, or when
608
613
  * {@linkcode HonoBffOptions.extraParams} or
609
614
  * {@linkcode HonoBffOptions.forwardedParams} names a reserved authorize
610
615
  * parameter, gives one a name that is empty or not equal to its trimmed
@@ -701,7 +706,11 @@ export declare class HonoBff {
701
706
  callbackHandler(): Handler;
702
707
  /** Logout handler — revokes the refresh token and clears the session cookie. */
703
708
  logoutHandler(): Handler;
704
- /** Session probe — returns `{ isAuthenticated, user }` without tokens. */
709
+ /**
710
+ * Session probe — returns `{ isAuthenticated, user }`, plus
711
+ * `sessionExpiresIn` (seconds until the access token expires, or `null`) and
712
+ * `logoutUrl` when signed in. Never returns tokens.
713
+ */
705
714
  sessionHandler(): Handler;
706
715
  /**
707
716
  * OIDC Back-Channel Logout receiver. A server-to-server `POST` from the OP
@@ -825,9 +834,11 @@ export declare class HonoBff {
825
834
  * - **Headers.** Only {@link DEFAULT_PROXY_FORWARD_HEADERS} (or your
826
835
  * `forwardHeaders`) travel upstream; the session cookie, inbound
827
836
  * `Authorization`, and hop-by-hop headers never do. Coming back, upstream
828
- * `Set-Cookie` and hop-by-hop headers are dropped and everything else —
829
- * including problem-details error bodies and their status — is passed
830
- * through verbatim.
837
+ * `Set-Cookie` and hop-by-hop headers are dropped; the status, body
838
+ * (problem-details errors included), and remaining headers pass through,
839
+ * with `Location`/`Content-Location` rewritten into the mount, and the
840
+ * response marked `Cache-Control: private`, `Vary: Cookie`, and
841
+ * `X-Content-Type-Options: nosniff`.
831
842
  * - **Bodies stream** in both directions; nothing is buffered. Upstream
832
843
  * redirects are returned to the browser rather than followed, so the token
833
844
  * is never replayed to a `Location` the BFF did not vet.
@@ -4,7 +4,7 @@
4
4
  * Wraps a {@link DirectClient} configured with a client secret
5
5
  * and exposes the session endpoints a browser SPA talks to:
6
6
  *
7
- * - `POST /auth/login` — redirects the browser to the authorize endpoint,
7
+ * - `GET|POST /auth/login` — redirects the browser to the authorize endpoint,
8
8
  * persisting a per-request state/verifier record and binding its `state` to
9
9
  * this browser with a short-lived cookie.
10
10
  * - `GET /auth/callback` — requires the browser to present that binding
@@ -15,7 +15,8 @@
15
15
  * the refresh token upstream. Registered for both methods because the
16
16
  * shipped React client navigates the browser here (a GET).
17
17
  * - `GET /auth/session` — returns `{ isAuthenticated, user }` from the
18
- * session. **Never** exposes tokens to the browser.
18
+ * session, plus `sessionExpiresIn` and `logoutUrl` when signed in.
19
+ * **Never** exposes tokens to the browser.
19
20
  * - `POST /auth/backchannel` — OIDC Back-Channel Logout receiver, mounted only
20
21
  * when `backchannelLogout` is configured. A server-to-server call from the OP
21
22
  * that ends the matching server-side session(s).
@@ -56,10 +57,6 @@ import { loginContinuation as computeLoginContinuation, safeReturnTo, } from "..
56
57
  import { resolveCookieName, resolvePrefixConstrainedAttributes, } from "./cookie-prefix.js";
57
58
  import { DEFAULT_SESSION_MAX_AGE_MS, MemorySessionStore, sessionStoreMaxAgeMs, supportsBackchannelLogout, } from "./session-store.js";
58
59
  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
60
  function isRevokedGrant(error) {
64
61
  if (!isOAuth2Error(error))
65
62
  return false;
@@ -305,7 +302,8 @@ export class HonoBff {
305
302
  * {@linkcode HonoBffPaths} entry falls outside a non-empty
306
303
  * {@linkcode HonoBffPaths.basePath}, when the session cookie would die
307
304
  * before the session behind it (see
308
- * {@linkcode HonoBffOptions.sessionMaxAgeMs}), or when
305
+ * {@linkcode HonoBffOptions.sessionMaxAgeMs}), when `sessionMaxAgeMs` or a
306
+ * numeric `cookie.maxAge` is not positive or exceeds 400 days, or when
309
307
  * {@linkcode HonoBffOptions.extraParams} or
310
308
  * {@linkcode HonoBffOptions.forwardedParams} names a reserved authorize
311
309
  * parameter, gives one a name that is empty or not equal to its trimmed
@@ -742,7 +740,11 @@ export class HonoBff {
742
740
  "silent. Give the client an `issuer` so discovery can find the " +
743
741
  "endpoint, or add `endpoints.endSession` explicitly.");
744
742
  }
745
- /** Session probe — returns `{ isAuthenticated, user }` without tokens. */
743
+ /**
744
+ * Session probe — returns `{ isAuthenticated, user }`, plus
745
+ * `sessionExpiresIn` (seconds until the access token expires, or `null`) and
746
+ * `logoutUrl` when signed in. Never returns tokens.
747
+ */
746
748
  sessionHandler() {
747
749
  return async (c) => {
748
750
  this.#noStore(c);
@@ -1084,9 +1086,11 @@ export class HonoBff {
1084
1086
  * - **Headers.** Only {@link DEFAULT_PROXY_FORWARD_HEADERS} (or your
1085
1087
  * `forwardHeaders`) travel upstream; the session cookie, inbound
1086
1088
  * `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.
1089
+ * `Set-Cookie` and hop-by-hop headers are dropped; the status, body
1090
+ * (problem-details errors included), and remaining headers pass through,
1091
+ * with `Location`/`Content-Location` rewritten into the mount, and the
1092
+ * response marked `Cache-Control: private`, `Vary: Cookie`, and
1093
+ * `X-Content-Type-Options: nosniff`.
1090
1094
  * - **Bodies stream** in both directions; nothing is buffered. Upstream
1091
1095
  * redirects are returned to the browser rather than followed, so the token
1092
1096
  * is never replayed to a `Location` the BFF did not vet.
@@ -10,10 +10,7 @@
10
10
  *
11
11
  * @module
12
12
  */
13
- /**
14
- * Hop-by-hop headers (RFC 9110 §7.6.1). They describe a single transport
15
- * connection, so a proxy must not pass them through in either direction.
16
- */
13
+ // RFC 9110 §7.6.1: connection-specific, never proxied in either direction.
17
14
  const HOP_BY_HOP_HEADERS = [
18
15
  "connection",
19
16
  "keep-alive",
@@ -46,14 +43,7 @@ export const DEFAULT_PROXY_FORWARD_HEADERS = [
46
43
  "tracestate",
47
44
  "x-request-id",
48
45
  ];
49
- /**
50
- * Never forwarded upstream, even when named in
51
- * {@link HonoBffProxyOptions.forwardHeaders}. `cookie` and `authorization` are
52
- * the security-critical entries: the browser's session cookie is the BFF's
53
- * credential (not the API's), and an inbound `Authorization` must never be able
54
- * to override the token the BFF attaches. `accept-encoding` and `content-length`
55
- * are dropped because `fetch` owns content coding and body framing.
56
- */
46
+ // The session cookie is the BFF's credential; the BFF's bearer token must win.
57
47
  const NEVER_FORWARDED_REQUEST_HEADERS = [
58
48
  ...HOP_BY_HOP_HEADERS,
59
49
  "accept-encoding",
@@ -63,26 +53,16 @@ const NEVER_FORWARDED_REQUEST_HEADERS = [
63
53
  "cookie2",
64
54
  "host",
65
55
  ];
66
- /**
67
- * Never returned to the browser. `set-cookie` is dropped so an upstream API
68
- * cannot plant cookies on the BFF's origin, where they would ride along with
69
- * the session cookie.
70
- */
56
+ // An upstream Set-Cookie would plant cookies on the BFF's origin.
71
57
  const NEVER_RETURNED_RESPONSE_HEADERS = [
72
58
  ...HOP_BY_HOP_HEADERS,
73
59
  "set-cookie",
74
60
  ];
75
- /**
76
- * Dropped only when a body is actually streamed back: `fetch` has already
77
- * decoded it, so the upstream's content coding and length no longer describe
78
- * what the browser receives. A body-less response (`HEAD`, `204`, `304`) was
79
- * never decoded, and there `Content-Length` *is* the answer.
80
- */
61
+ // fetch decodes a streamed body; a body-less response keeps its Content-Length.
81
62
  const DECODED_BODY_RESPONSE_HEADERS = [
82
63
  "content-encoding",
83
64
  "content-length",
84
65
  ];
85
- /** Case-insensitive header name set. */
86
66
  function headerSet(names) {
87
67
  return new Set(names.map((name) => name.toLowerCase()));
88
68
  }
@@ -115,29 +95,17 @@ export function buildUpstreamHeaders(inbound, allowed, accessToken) {
115
95
  headers.set("Authorization", `Bearer ${accessToken}`);
116
96
  return headers;
117
97
  }
118
- /**
119
- * The header names an upstream `Connection` field lists. RFC 9110 §7.6.1 makes
120
- * those connection-specific too, so a proxy must drop them alongside the
121
- * well-known hop-by-hop set.
122
- */
98
+ // RFC 9110 §7.6.1: headers named in `Connection` are hop-by-hop too.
123
99
  function connectionNamedHeaders(headers) {
124
100
  return (headers.get("connection") ?? "")
125
101
  .split(",")
126
102
  .map((name) => name.trim().toLowerCase())
127
103
  .filter((name) => name.length > 0);
128
104
  }
129
- /** Headers whose value is a URL into the upstream's namespace. */
130
105
  const URL_VALUED_RESPONSE_HEADERS = [
131
106
  "location",
132
107
  "content-location",
133
108
  ];
134
- /**
135
- * Rewrites an upstream URL into the mount's namespace, so a `Location` naming
136
- * the resource server neither leaks internal topology to the browser nor points
137
- * it at a host it cannot reach. A relative value resolves against the upstream
138
- * request first. Anything outside the configured base is left alone — it is a
139
- * genuinely external destination, not part of what this mount proxies.
140
- */
141
109
  function rewriteUpstreamUrl(value, upstreamUrl, base, mountPrefix) {
142
110
  let resolved;
143
111
  try {
@@ -159,7 +127,6 @@ function rewriteUpstreamUrl(value, upstreamUrl, base, mountPrefix) {
159
127
  const suffix = resolved.pathname.slice(basePath.length) || "/";
160
128
  return `${mountPrefix}${suffix}${resolved.search}${resolved.hash}`;
161
129
  }
162
- /** Adds `name` to `Vary` without disturbing what the upstream already varies on. */
163
130
  function appendVary(headers, name) {
164
131
  const existing = headers.get("vary");
165
132
  if (!existing) {
@@ -174,11 +141,7 @@ function appendVary(headers, name) {
174
141
  if (!present)
175
142
  headers.set("vary", `${existing}, ${name}`);
176
143
  }
177
- /**
178
- * Forces the response private. Upstream sees an `Authorization`-protected
179
- * request, which RFC 9111 §3.5 already keeps out of shared caches; downstream
180
- * the same response is authenticated by a cookie, which carries no such rule.
181
- */
144
+ // RFC 9111 §3.5 covers the upstream Authorization, not the downstream cookie.
182
145
  function forcePrivateCache(headers) {
183
146
  const existing = headers.get("cache-control");
184
147
  if (!existing) {
@@ -227,18 +190,7 @@ export function buildDownstreamHeaders(upstream, context) {
227
190
  headers.set("X-Content-Type-Options", "nosniff");
228
191
  return headers;
229
192
  }
230
- /**
231
- * Rejects a path segment that would become a dot segment once decoded.
232
- *
233
- * Fails closed on a malformed escape: `decodeURIComponent` throws on overlong
234
- * or invalid UTF-8 (`%C0%AF`, the classic `/` smuggle), and a lenient upstream
235
- * decoder may still reconstruct the separator, so the segment cannot be
236
- * forwarded unexamined. Encoded separators themselves are allowed through —
237
- * `a%2Fb` is a legitimate resource id — it is only `..` between them that is
238
- * refused.
239
- *
240
- * @throws {TypeError} If the segment has a malformed escape or hides `..`.
241
- */
193
+ // A lenient upstream decoder can rebuild `/` from `%C0%AF`: fail closed.
242
194
  function assertSafeSegment(segment) {
243
195
  let decoded;
244
196
  try {
@@ -17,8 +17,9 @@
17
17
  *
18
18
  * A raw "token in cookie" store is deliberately **not** shipped: a leaked
19
19
  * log line containing the cookie would yield a working bearer token. The
20
- * AES-GCM encryption in the stateless store is what makes that pattern
21
- * safe.
20
+ * AES-GCM encryption in the stateless store keeps the tokens unreadable, but
21
+ * a leaked encrypted cookie still works as a session cookie against the BFF
22
+ * until it expires.
22
23
  *
23
24
  * @module
24
25
  */
@@ -31,8 +32,8 @@ export interface SessionData {
31
32
  refreshToken?: string;
32
33
  /**
33
34
  * Cached user claims surfaced by `GET /auth/session` without exposing
34
- * tokens to the browser. Populated from the id_token or the userinfo
35
- * endpoint at callback time.
35
+ * tokens to the browser. Populated at callback time from the id_token's
36
+ * claims, as replaced by `HonoBffOptions.resolveUser` when configured.
36
37
  */
37
38
  user?: UserInfoClaims | null;
38
39
  /**
@@ -160,8 +161,8 @@ export interface EncryptedCookieSessionStoreOptions {
160
161
  * older secret keeps unsealing. Rotate by deploying `[newSecret, oldSecret]`,
161
162
  * then drop `oldSecret` once the grace window (at least {@linkcode maxAgeMs},
162
163
  * or your longest session lifetime) has elapsed. This is the session-store
163
- * analog of a multi-key JWKS grace window. A single secret behaves exactly as
164
- * before — a fleet-wide forced sign-out on rotation.
164
+ * analog of a multi-key JWKS grace window. With a single secret, rotating it
165
+ * signs everyone out.
165
166
  */
166
167
  secret: string | Uint8Array | Array<string | Uint8Array>;
167
168
  /**
@@ -181,9 +182,8 @@ export interface EncryptedCookieSessionStoreOptions {
181
182
  * milliseconds.
182
183
  *
183
184
  * The store stamps each cookie with a seal time at `create`/`update`; a
184
- * pre-rotation cookie sealed before this field existed has no stamp and falls
185
- * back to its own `updatedAt`, so old cookies are bounded too rather than
186
- * grandfathered as unbounded.
185
+ * cookie without that stamp is aged from its `updatedAt` instead, so it is
186
+ * bounded too.
187
187
  */
188
188
  maxAgeMs?: number;
189
189
  }