@udibo/oauth2 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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/redirect-uri.d.ts +6 -9
  131. package/esm/server/redirect-uri.js +7 -34
  132. package/esm/server/resource-server.d.ts +5 -3
  133. package/esm/server/resource-server.js +5 -3
  134. package/esm/server/services/client.d.ts +5 -1
  135. package/esm/server/services/device-authorization.d.ts +6 -6
  136. package/esm/server/services/token.d.ts +15 -8
  137. package/esm/server/services/token.js +1 -1
  138. package/esm/server/signing-keys.d.ts +24 -9
  139. package/esm/server/signing-keys.js +20 -9
  140. package/esm/testing/contract/mod.d.ts +3 -3
  141. package/esm/testing/contract/mod.js +3 -3
  142. package/esm/testing/contract/tenant.d.ts +1 -1
  143. package/esm/testing/contract/tenant.js +2 -2
  144. package/esm/testing/mod.d.ts +6 -4
  145. package/esm/testing/mod.js +6 -4
  146. package/esm/testing/services.d.ts +1 -1
  147. package/esm/testing/services.js +1 -1
  148. package/esm/testing/tenant.d.ts +14 -5
  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
@@ -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
  */
@@ -60,7 +60,10 @@ export interface CreateTestSessionOptions {
60
60
  * const cookie = await createTestSession(bff, {
61
61
  * user: { sub: "u1", name: "Alice" },
62
62
  * });
63
- * const res = await app.request("/auth/session", { headers: { cookie } });
63
+ * // With CSRF on (the default), a request carrying the cookie needs the header.
64
+ * const res = await app.request("/auth/session", {
65
+ * headers: { cookie, "x-csrf": "1" },
66
+ * });
64
67
  * ```
65
68
  */
66
69
  export declare function createTestSession(bff: HonoBff, options?: CreateTestSessionOptions): Promise<string>;
@@ -105,7 +108,9 @@ export interface CreateAuthenticatedTestSessionOptions<Client extends ClientInte
105
108
  * scope: new BasicScope("read write"),
106
109
  * claims: { sub: "u1", name: "Alice" },
107
110
  * });
108
- * const res = await app.request("/api/me", { headers: { cookie } });
111
+ * const res = await app.request("/api/me", {
112
+ * headers: { cookie, "x-csrf": "1" },
113
+ * });
109
114
  * ```
110
115
  */
111
116
  export declare function createAuthenticatedTestSession<Client extends ClientInterface, User, Scope extends AbstractScope>(bff: HonoBff, options: CreateAuthenticatedTestSessionOptions<Client, User, Scope>): Promise<string>;
@@ -161,5 +166,11 @@ export interface SessionStoreContractOptions {
161
166
  */
162
167
  makeBoundedStore?(maxAgeMs: number): Promise<SessionStore> | SessionStore;
163
168
  }
164
- /** Contract test suite for {@link SessionStore} implementations. */
169
+ /**
170
+ * Contract test suite for {@link SessionStore} implementations. Registers
171
+ * `describe`/`it` blocks from `@std/testing/bdd`, so call it at a test
172
+ * module's top level. It does not check that a stateful store's `update`
173
+ * rejects a missing or destroyed session, which {@link SessionStore.update}
174
+ * requires — test that yourself.
175
+ */
165
176
  export declare function runSessionStoreContractTests(options: SessionStoreContractOptions): void;
@@ -42,7 +42,10 @@ import { sessionStoreMaxAgeMs, } from "./session-store.js";
42
42
  * const cookie = await createTestSession(bff, {
43
43
  * user: { sub: "u1", name: "Alice" },
44
44
  * });
45
- * const res = await app.request("/auth/session", { headers: { cookie } });
45
+ * // With CSRF on (the default), a request carrying the cookie needs the header.
46
+ * const res = await app.request("/auth/session", {
47
+ * headers: { cookie, "x-csrf": "1" },
48
+ * });
46
49
  * ```
47
50
  */
48
51
  export async function createTestSession(bff, options = {}) {
@@ -86,7 +89,9 @@ export async function createTestSession(bff, options = {}) {
86
89
  * scope: new BasicScope("read write"),
87
90
  * claims: { sub: "u1", name: "Alice" },
88
91
  * });
89
- * const res = await app.request("/api/me", { headers: { cookie } });
92
+ * const res = await app.request("/api/me", {
93
+ * headers: { cookie, "x-csrf": "1" },
94
+ * });
90
95
  * ```
91
96
  */
92
97
  export async function createAuthenticatedTestSession(bff, options) {
@@ -163,7 +168,13 @@ function parseCookieHeader(header, name) {
163
168
  throw new Error(`parseCookieHeader: cookie "${name}" not found in header "${header}"`);
164
169
  }
165
170
  const BOUND_MS = 1000;
166
- /** Contract test suite for {@link SessionStore} implementations. */
171
+ /**
172
+ * Contract test suite for {@link SessionStore} implementations. Registers
173
+ * `describe`/`it` blocks from `@std/testing/bdd`, so call it at a test
174
+ * module's top level. It does not check that a stateful store's `update`
175
+ * rejects a missing or destroyed session, which {@link SessionStore.update}
176
+ * requires — test that yourself.
177
+ */
167
178
  export function runSessionStoreContractTests(options) {
168
179
  const destroyClearsRead = options.destroyClearsRead ?? true;
169
180
  const makeBoundedStore = options.makeBoundedStore;
@@ -10,7 +10,7 @@
10
10
  * (the `IdentityService` stays usable directly from your own
11
11
  * routes/loaders/actions).
12
12
  *
13
- * **Scope — password path only, by design.** The newer flows are intentionally
13
+ * **Scope — password path only, by design.** The other flows are intentionally
14
14
  * *not* mounted here, because their safe wiring carries app-owned policy the
15
15
  * factory can't guess. Drive them by calling the service methods from your own
16
16
  * handlers, as each guide shows:
@@ -76,8 +76,10 @@ export interface HonoIdentityOptions<User extends IdentityUser> {
76
76
  * Cross-site request protection, **on by default**. These routes carry no
77
77
  * token of their own, so the guard reads what the browser states about the
78
78
  * request: a `Sec-Fetch-Site` of `same-origin`/`none` passes, any other value
79
- * is refused, and browsers too old to send it fall back to matching the
80
- * `Origin` header's host against the request's. A request with neither header
79
+ * is refused unless `Origin` is in
80
+ * {@link HonoIdentityCsrfOptions.allowedOrigins}, and browsers too old to
81
+ * send it fall back to matching `Origin` against that list or the request's
82
+ * own host. A request with neither header
81
83
  * — curl, a server-side call, a native app — passes, since cross-site request
82
84
  * forgery needs a browser.
83
85
  *
@@ -10,7 +10,7 @@
10
10
  * (the `IdentityService` stays usable directly from your own
11
11
  * routes/loaders/actions).
12
12
  *
13
- * **Scope — password path only, by design.** The newer flows are intentionally
13
+ * **Scope — password path only, by design.** The other flows are intentionally
14
14
  * *not* mounted here, because their safe wiring carries app-owned policy the
15
15
  * factory can't guess. Drive them by calling the service methods from your own
16
16
  * handlers, as each guide shows:
@@ -42,12 +42,6 @@
42
42
  import "../../_dnt.polyfills.js";
43
43
  import { Hono } from "hono";
44
44
  import { identityErrorStatus, isIdentityError, } from "../../identity/errors.js";
45
- /**
46
- * Wrap a handler so a thrown {@link IdentityError} (e.g. `identifier_taken` from
47
- * your user store, or `weak_password` from a policy check) becomes a consistent
48
- * `{ error: code }` response with the conventional status; other errors
49
- * propagate untouched.
50
- */
51
45
  function handle(fn) {
52
46
  return async (c) => {
53
47
  try {
@@ -64,12 +58,6 @@ function handle(fn) {
64
58
  };
65
59
  }
66
60
  const STRING_FIELDS = new Set(["password", "identifier", "email", "token"]);
67
- /**
68
- * Reads a JSON or form-encoded request body. A JSON body keeps each value's
69
- * type except for {@link STRING_FIELDS}; a body that is not a JSON object
70
- * (array, string, `null`) reads as empty. A form-encoded body is all strings by
71
- * construction.
72
- */
73
61
  async function readBody(c) {
74
62
  const contentType = c.req.header("content-type") ?? "";
75
63
  if (contentType.includes("application/json")) {
@@ -124,10 +112,6 @@ function isTrustedOrigin(c, allowedOrigins) {
124
112
  return allowedOrigins.has(origin) ||
125
113
  hostOf(origin) === new URL(c.req.url).host;
126
114
  }
127
- /**
128
- * Refuses an unsafe-method request a browser reports as coming from another
129
- * site, per {@link HonoIdentityOptions.csrf}.
130
- */
131
115
  function sameOriginGuard(options) {
132
116
  const allowedOrigins = new Set(options.allowedOrigins ?? []);
133
117
  return async (c, next) => {
@@ -45,8 +45,9 @@ export declare class HonoResourceServer<Client extends ClientInterface, User, S
45
45
  *
46
46
  * On success, sets the authenticated context at `c.get("@udibo/oauth2")`
47
47
  * (typed via {@link HonoResourceServerVariables}) and calls `next()`. On
48
- * failure, returns an RFC 6749 error response with a `WWW-Authenticate`
49
- * header per RFC 6750 Section 3.
48
+ * failure, returns an error response, with a `WWW-Authenticate` challenge
49
+ * (RFC 6750 Section 3) on a 401 or 403, or rethrows when `throwOnError` is
50
+ * set.
50
51
  */
51
52
  protect(requiredScope?: S | string): MiddlewareHandler;
52
53
  /**
@@ -42,8 +42,9 @@ export class HonoResourceServer extends ResourceServer {
42
42
  *
43
43
  * On success, sets the authenticated context at `c.get("@udibo/oauth2")`
44
44
  * (typed via {@link HonoResourceServerVariables}) and calls `next()`. On
45
- * failure, returns an RFC 6749 error response with a `WWW-Authenticate`
46
- * header per RFC 6750 Section 3.
45
+ * failure, returns an error response, with a `WWW-Authenticate` challenge
46
+ * (RFC 6750 Section 3) on a 401 or 403, or rethrows when `throwOnError` is
47
+ * set.
47
48
  */
48
49
  protect(requiredScope) {
49
50
  return createProtectMiddleware(this, requiredScope);
@@ -1,11 +1,8 @@
1
1
  /**
2
- * The response ladder every {@link DirectClient} network call climbs: bounded
3
- * read, deadline, redirect refusal, JSON validation, RFC 6749 §5.2 error
4
- * mapping.
5
- *
6
- * Not part of the public API. The authorization server's bytes become either a
7
- * typed {@link OAuth2Error} or a validated JSON object here and nowhere else,
8
- * so the guards sit next to the tests that pin them.
2
+ * Not part of the public API. The guarded request/response path the clients'
3
+ * calls to the authorization server (and `BffClient`'s session probe) share:
4
+ * deadline, redirect refusal, bounded read, JSON validation, and RFC 6749 §5.2
5
+ * error mapping to a typed {@link OAuth2Error}.
9
6
  *
10
7
  * @module
11
8
  */
@@ -1,11 +1,8 @@
1
1
  /**
2
- * The response ladder every {@link DirectClient} network call climbs: bounded
3
- * read, deadline, redirect refusal, JSON validation, RFC 6749 §5.2 error
4
- * mapping.
5
- *
6
- * Not part of the public API. The authorization server's bytes become either a
7
- * typed {@link OAuth2Error} or a validated JSON object here and nowhere else,
8
- * so the guards sit next to the tests that pin them.
2
+ * Not part of the public API. The guarded request/response path the clients'
3
+ * calls to the authorization server (and `BffClient`'s session probe) share:
4
+ * deadline, redirect refusal, bounded read, JSON validation, and RFC 6749 §5.2
5
+ * error mapping to a typed {@link OAuth2Error}.
9
6
  *
10
7
  * @module
11
8
  */
@@ -15,11 +12,6 @@ import { sanitizeProviderText } from "../utils/text.js";
15
12
  export const MAX_RESPONSE_BYTES = 64 * 1024;
16
13
  /** Wall-clock budget for one call, covering the connect and the body read. */
17
14
  export const REQUEST_TIMEOUT_MS = 10_000;
18
- /**
19
- * Maps an OAuth2 error code to the specific error class so callers can
20
- * `catch (e) { if (e instanceof InvalidGrantError) … }` instead of
21
- * inspecting `e.extensions.error` at every call site.
22
- */
23
15
  const ERROR_CLASS_BY_CODE = {
24
16
  access_denied: AccessDeniedError,
25
17
  authorization_pending: AuthorizationPendingError,
@@ -167,16 +159,6 @@ export function assertField(body, field, what) {
167
159
  throw new ServerError(`the ${what} returned a success response with no "${field}"`);
168
160
  }
169
161
  }
170
- /**
171
- * Reads up to {@link MAX_RESPONSE_BYTES} and reports whether more was coming.
172
- *
173
- * Never throws for being too large: on a **failed** response the bytes are the
174
- * only place the OAuth2 error code lives, and refusing to look would turn a
175
- * `invalid_grant` that happens to arrive oversized into a generic transport
176
- * error — which reads as "retry later" instead of "this grant is dead", so the
177
- * client would keep presenting a revoked refresh token forever. Truncation is
178
- * reported instead, and the success path refuses it.
179
- */
180
162
  async function readBounded(res) {
181
163
  if (!res.body)
182
164
  return { text: "", truncated: false };
@@ -235,11 +217,6 @@ function reportedError(value) {
235
217
  : undefined,
236
218
  };
237
219
  }
238
- /**
239
- * `status` is passed through only when it is a real error status: a server
240
- * that reports `error` inside a `200` still needs a throwable, and `HttpError`
241
- * refuses to carry a 2xx.
242
- */
243
220
  function buildError(reported, status) {
244
221
  const ErrorClass = ERROR_CLASS_BY_CODE[reported.error] ?? OAuth2ErrorClass;
245
222
  return new ErrorClass(reported.description ?? reported.error, {
@@ -251,16 +228,6 @@ function buildError(reported, status) {
251
228
  },
252
229
  });
253
230
  }
254
- /**
255
- * Recovers the `error` code from a body that did not parse.
256
- *
257
- * The realistic reason a failed token response is unparseable here is that the
258
- * cap cut it mid-string — a server padding `error_description`, or a proxy
259
- * appending a page of HTML. The code itself is short and near the front, and
260
- * losing it costs the caller the difference between "this grant is dead" and
261
- * "something went wrong", so it is worth one bounded scan. Restricted to the
262
- * lowercase-underscore shape RFC 6749 §5.2 defines, so nothing else matches.
263
- */
264
231
  function scanErrorCode(text) {
265
232
  const match = /"error"\s*:\s*"([a-z_]{1,64})"/.exec(text);
266
233
  return match ? { error: match[1] } : null;
@@ -42,12 +42,9 @@ export interface BaseOptions {
42
42
  fetch?: typeof fetch;
43
43
  }
44
44
  /**
45
- * The `login` options every client understands.
46
- *
47
- * Named apart from {@link LoginOptions} — which keeps the richer
48
- * `DirectClient` shape it has always had — so that a consumer importing
49
- * `LoginOptions` and passing `scope` still compiles. Reach for this one only
50
- * when writing against {@link OAuth2ClientBase}.
45
+ * The `login` options every client understands. Use it when writing against
46
+ * {@link OAuth2ClientBase}; `DirectClient.login` accepts the richer
47
+ * {@link LoginOptions}.
51
48
  */
52
49
  export interface BaseLoginOptions {
53
50
  /** Path or URL to return to after a successful login. */
@@ -57,10 +57,11 @@ export interface BffClientOptions extends BaseOptions {
57
57
  * OAuth2 client for a browser app whose sessions live in a
58
58
  * Backend-for-Frontend.
59
59
  *
60
- * Every method here is a same-origin call to the BFF with `credentials:
61
- * "include"`, never a call to the authorization server. There is no token
62
- * storage, no PKCE, and no access token to read — if you need one, the app
63
- * wants a {@link DirectClient} instead.
60
+ * Nothing here calls the authorization server: the session probe and
61
+ * {@link BffClient.fetch} send `credentials: "include"` requests, and the login
62
+ * and logout methods only build BFF URLs. There is no token storage, no PKCE,
63
+ * and no access token to read — if you need one, the app wants a
64
+ * {@link DirectClient} instead.
64
65
  *
65
66
  * @example Same-origin BFF
66
67
  * ```ts
@@ -146,8 +147,8 @@ export declare class BffClient extends OAuth2ClientBase {
146
147
  * (an HTML error page from a misrouted path is the common one) all read as
147
148
  * signed out. A failure that was not simply "nobody is signed in" — anything
148
149
  * other than a `401` — also emits an `error` event so a UI can tell the two
149
- * apart. The read is bounded and deadlined like every other call this
150
- * package makes.
150
+ * apart. The probe is refused if the BFF answers with a redirect, and is
151
+ * bounded in size and time.
151
152
  *
152
153
  * Only `isAuthenticated: true` carries `user` / `sessionExpiresIn` /
153
154
  * `logoutUrl` through; an anonymous payload always reports them as `null`,
@@ -15,10 +15,11 @@ import { mergedHeaders, OAuth2ClientBase, SIGNED_OUT, } from "./base.js";
15
15
  * OAuth2 client for a browser app whose sessions live in a
16
16
  * Backend-for-Frontend.
17
17
  *
18
- * Every method here is a same-origin call to the BFF with `credentials:
19
- * "include"`, never a call to the authorization server. There is no token
20
- * storage, no PKCE, and no access token to read — if you need one, the app
21
- * wants a {@link DirectClient} instead.
18
+ * Nothing here calls the authorization server: the session probe and
19
+ * {@link BffClient.fetch} send `credentials: "include"` requests, and the login
20
+ * and logout methods only build BFF URLs. There is no token storage, no PKCE,
21
+ * and no access token to read — if you need one, the app wants a
22
+ * {@link DirectClient} instead.
22
23
  *
23
24
  * @example Same-origin BFF
24
25
  * ```ts
@@ -136,8 +137,8 @@ export class BffClient extends OAuth2ClientBase {
136
137
  * (an HTML error page from a misrouted path is the common one) all read as
137
138
  * signed out. A failure that was not simply "nobody is signed in" — anything
138
139
  * other than a `401` — also emits an `error` event so a UI can tell the two
139
- * apart. The read is bounded and deadlined like every other call this
140
- * package makes.
140
+ * apart. The probe is refused if the BFF answers with a redirect, and is
141
+ * bounded in size and time.
141
142
  *
142
143
  * Only `isAuthenticated: true` carries `user` / `sessionExpiresIn` /
143
144
  * `logoutUrl` through; an anonymous payload always reports them as `null`,
@@ -2,14 +2,10 @@
2
2
  * Browser-storage implementations of {@link RefreshTokenStorage} and
3
3
  * {@link AuthRequestStorage}.
4
4
  *
5
- * Both ship from the `@udibo/oauth2/client` subpath alongside the
6
- * `Memory*` defaults so non-React browser consumers (vanilla JS,
7
- * Vue, Svelte, …) can use them too.
8
- *
9
- * Both implementations are **SSR-safe**: if `window` / `indexedDB` /
10
- * `sessionStorage` is undefined at construction, the storage degrades
11
- * to a no-op (`get` returns `null`, `set`/`clear` do nothing). The
12
- * client constructs the real backing on first use in the browser.
5
+ * Both are **SSR-safe**: construction touches no browser API, and each method
6
+ * checks for `indexedDB` / `sessionStorage` when it runs. Where the API is
7
+ * missing the call is a no-op (`get` returns `null`, `set`/`clear` do
8
+ * nothing), so the same instance works once the code runs in a browser.
13
9
  *
14
10
  * @module
15
11
  */
@@ -32,17 +28,12 @@ export interface IndexedDBRefreshTokenStorageOptions {
32
28
  /**
33
29
  * Refresh token storage backed by IndexedDB.
34
30
  *
35
- * IndexedDB is preferred over `localStorage` because the latter is
36
- * synchronously readable from any script that runs in the document
37
- * (including injected XSS payloads). IndexedDB's same-origin guarantees
38
- * are similar but its async API and per-database isolation make
39
- * exfiltration somewhat harder. Combined with refresh-token rotation +
40
- * reuse detection on the authorization server, it's the recommended
41
- * default for a browser `DirectClient`.
42
- *
43
- * No `localStorage`-backed variant is shipped: it is synchronously
44
- * readable by any in-document script (including injected XSS), which
45
- * defeats the protection above.
31
+ * Keeps the refresh token across reloads without putting it in a cookie
32
+ * (sent with every request) or `localStorage`. It is stored in plain text and
33
+ * **is not a defense against XSS**: any script running on the origin can open
34
+ * the same database and read it. Pair it with refresh-token rotation and
35
+ * reuse detection on the authorization server, which limit how long a stolen
36
+ * token stays useful.
46
37
  *
47
38
  * Every instance needs a
48
39
  * {@link IndexedDBRefreshTokenStorageOptions.clientId} — the token is stored
@@ -77,25 +68,28 @@ export declare class IndexedDBRefreshTokenStorage implements RefreshTokenStorage
77
68
  */
78
69
  constructor(options: IndexedDBRefreshTokenStorageOptions);
79
70
  /**
80
- * Returns the stored refresh token, or `null` when none is stored, when
81
- * `indexedDB` is unavailable (SSR / non-browser), or when another tab is
82
- * blocking the store's creation.
71
+ * Returns the stored refresh token, or `null` when none is stored or
72
+ * `indexedDB` is unavailable (SSR / non-browser).
83
73
  *
84
- * @throws {DOMException} when IndexedDB rejects the read.
74
+ * @throws {DOMException} when IndexedDB rejects the read, or when another
75
+ * open connection (another tab) blocks the store's creation.
85
76
  */
86
77
  get(): Promise<string | null>;
87
78
  /**
88
- * Persists {@linkcode value} as the refresh token. A no-op when `indexedDB`
89
- * is unavailable (SSR / non-browser).
79
+ * Persists {@linkcode value} as the refresh token. A silent no-op when
80
+ * `indexedDB` is unavailable (SSR / non-browser).
90
81
  *
91
- * @throws {DOMException} when IndexedDB rejects the write.
82
+ * @throws {DOMException} when IndexedDB rejects the write, or when another
83
+ * open connection (another tab) blocks the store's creation — the token was
84
+ * not persisted.
92
85
  */
93
86
  set(value: string): Promise<void>;
94
87
  /**
95
- * Discards the stored refresh token. A no-op when `indexedDB` is unavailable
96
- * (SSR / non-browser).
88
+ * Discards the stored refresh token. A silent no-op when `indexedDB` is
89
+ * unavailable (SSR / non-browser).
97
90
  *
98
- * @throws {DOMException} when IndexedDB rejects the delete.
91
+ * @throws {DOMException} when IndexedDB rejects the delete, or when another
92
+ * open connection (another tab) blocks the store's creation.
99
93
  */
100
94
  clear(): Promise<void>;
101
95
  }
@@ -104,11 +98,10 @@ export interface SessionStorageAuthRequestStorageOptions {
104
98
  /** sessionStorage key prefix. Defaults to `"oauth2:auth-req:"`. */
105
99
  keyPrefix?: string;
106
100
  /**
107
- * Maximum age of an auth request before it's treated as expired
108
- * (epoch-ms). Reads of older entries return `null` and the entry is
109
- * removed. Defaults to 10 minutes — long enough for a slow
110
- * authorize-redirect round-trip, short enough that stale state from a
111
- * previous abandoned login doesn't accumulate.
101
+ * Maximum age of an auth request before it's treated as expired, in ms.
102
+ * Reading an older entry returns `null` and removes it; an entry that is
103
+ * never read again stays until {@link SessionStorageAuthRequestStorage.clear}
104
+ * or the tab closes. Defaults to 10 minutes.
112
105
  */
113
106
  ttlMs?: number;
114
107
  }
@@ -133,6 +126,12 @@ export declare class SessionStorageAuthRequestStorage implements AuthRequestStor
133
126
  * unavailable (SSR / non-browser). Expired or unparseable entries are pruned.
134
127
  */
135
128
  get(state: string): AuthRequestRecord | null;
129
+ /**
130
+ * Returns the record for {@linkcode state} and removes it, under the same
131
+ * rules as {@link get}. `sessionStorage` is synchronous and confined to one
132
+ * tab, so the read and the removal cannot interleave with another caller.
133
+ */
134
+ take(state: string): AuthRequestRecord | null;
136
135
  /**
137
136
  * Stores {@linkcode value} under {@linkcode state}. A no-op when
138
137
  * `sessionStorage` is unavailable (SSR / non-browser).
@@ -2,31 +2,22 @@
2
2
  * Browser-storage implementations of {@link RefreshTokenStorage} and
3
3
  * {@link AuthRequestStorage}.
4
4
  *
5
- * Both ship from the `@udibo/oauth2/client` subpath alongside the
6
- * `Memory*` defaults so non-React browser consumers (vanilla JS,
7
- * Vue, Svelte, …) can use them too.
8
- *
9
- * Both implementations are **SSR-safe**: if `window` / `indexedDB` /
10
- * `sessionStorage` is undefined at construction, the storage degrades
11
- * to a no-op (`get` returns `null`, `set`/`clear` do nothing). The
12
- * client constructs the real backing on first use in the browser.
5
+ * Both are **SSR-safe**: construction touches no browser API, and each method
6
+ * checks for `indexedDB` / `sessionStorage` when it runs. Where the API is
7
+ * missing the call is a no-op (`get` returns `null`, `set`/`clear` do
8
+ * nothing), so the same instance works once the code runs in a browser.
13
9
  *
14
10
  * @module
15
11
  */
16
12
  /**
17
13
  * Refresh token storage backed by IndexedDB.
18
14
  *
19
- * IndexedDB is preferred over `localStorage` because the latter is
20
- * synchronously readable from any script that runs in the document
21
- * (including injected XSS payloads). IndexedDB's same-origin guarantees
22
- * are similar but its async API and per-database isolation make
23
- * exfiltration somewhat harder. Combined with refresh-token rotation +
24
- * reuse detection on the authorization server, it's the recommended
25
- * default for a browser `DirectClient`.
26
- *
27
- * No `localStorage`-backed variant is shipped: it is synchronously
28
- * readable by any in-document script (including injected XSS), which
29
- * defeats the protection above.
15
+ * Keeps the refresh token across reloads without putting it in a cookie
16
+ * (sent with every request) or `localStorage`. It is stored in plain text and
17
+ * **is not a defense against XSS**: any script running on the origin can open
18
+ * the same database and read it. Pair it with refresh-token rotation and
19
+ * reuse detection on the authorization server, which limit how long a stolen
20
+ * token stays useful.
30
21
  *
31
22
  * Every instance needs a
32
23
  * {@link IndexedDBRefreshTokenStorageOptions.clientId} — the token is stored
@@ -71,11 +62,11 @@ export class IndexedDBRefreshTokenStorage {
71
62
  this.#key = options.clientId;
72
63
  }
73
64
  /**
74
- * Returns the stored refresh token, or `null` when none is stored, when
75
- * `indexedDB` is unavailable (SSR / non-browser), or when another tab is
76
- * blocking the store's creation.
65
+ * Returns the stored refresh token, or `null` when none is stored or
66
+ * `indexedDB` is unavailable (SSR / non-browser).
77
67
  *
78
- * @throws {DOMException} when IndexedDB rejects the read.
68
+ * @throws {DOMException} when IndexedDB rejects the read, or when another
69
+ * open connection (another tab) blocks the store's creation.
79
70
  */
80
71
  async get() {
81
72
  const db = await this.#openDb();
@@ -90,10 +81,12 @@ export class IndexedDBRefreshTokenStorage {
90
81
  });
91
82
  }
92
83
  /**
93
- * Persists {@linkcode value} as the refresh token. A no-op when `indexedDB`
94
- * is unavailable (SSR / non-browser).
84
+ * Persists {@linkcode value} as the refresh token. A silent no-op when
85
+ * `indexedDB` is unavailable (SSR / non-browser).
95
86
  *
96
- * @throws {DOMException} when IndexedDB rejects the write.
87
+ * @throws {DOMException} when IndexedDB rejects the write, or when another
88
+ * open connection (another tab) blocks the store's creation — the token was
89
+ * not persisted.
97
90
  */
98
91
  async set(value) {
99
92
  const db = await this.#openDb();
@@ -110,10 +103,11 @@ export class IndexedDBRefreshTokenStorage {
110
103
  });
111
104
  }
112
105
  /**
113
- * Discards the stored refresh token. A no-op when `indexedDB` is unavailable
114
- * (SSR / non-browser).
106
+ * Discards the stored refresh token. A silent no-op when `indexedDB` is
107
+ * unavailable (SSR / non-browser).
115
108
  *
116
- * @throws {DOMException} when IndexedDB rejects the delete.
109
+ * @throws {DOMException} when IndexedDB rejects the delete, or when another
110
+ * open connection (another tab) blocks the store's creation.
117
111
  */
118
112
  async clear() {
119
113
  const db = await this.#openDb();
@@ -133,7 +127,7 @@ export class IndexedDBRefreshTokenStorage {
133
127
  if (typeof indexedDB === "undefined")
134
128
  return null;
135
129
  const db = await this.#open();
136
- if (!db || db.objectStoreNames.contains(this.#storeName))
130
+ if (db.objectStoreNames.contains(this.#storeName))
137
131
  return db;
138
132
  const nextVersion = db.version + 1;
139
133
  db.close();
@@ -150,9 +144,20 @@ export class IndexedDBRefreshTokenStorage {
150
144
  db.createObjectStore(this.#storeName);
151
145
  }
152
146
  };
153
- req.onsuccess = () => resolve(req.result);
147
+ let blocked = false;
148
+ req.onsuccess = () => {
149
+ if (blocked)
150
+ req.result.close();
151
+ else
152
+ resolve(req.result);
153
+ };
154
154
  req.onerror = () => reject(req.error);
155
- req.onblocked = () => resolve(null);
155
+ req.onblocked = () => {
156
+ blocked = true;
157
+ reject(new DOMException(`opening IndexedDB "${this.#databaseName}" to create its ` +
158
+ `"${this.#storeName}" store was blocked by another open ` +
159
+ `connection, such as another tab`, "InvalidStateError"));
160
+ };
156
161
  });
157
162
  }
158
163
  }
@@ -200,6 +205,16 @@ export class SessionStorageAuthRequestStorage {
200
205
  return null;
201
206
  }
202
207
  }
208
+ /**
209
+ * Returns the record for {@linkcode state} and removes it, under the same
210
+ * rules as {@link get}. `sessionStorage` is synchronous and confined to one
211
+ * tab, so the read and the removal cannot interleave with another caller.
212
+ */
213
+ take(state) {
214
+ const record = this.get(state);
215
+ this.delete(state);
216
+ return record;
217
+ }
203
218
  /**
204
219
  * Stores {@linkcode value} under {@linkcode state}. A no-op when
205
220
  * `sessionStorage` is unavailable (SSR / non-browser).
@@ -58,8 +58,11 @@ export interface CheckPermissionsOptions {
58
58
  */
59
59
  resource?: CheckResource;
60
60
  /**
61
- * `fetch` implementation used to call the endpoint. Defaults to the global
62
- * `fetch`; inject to add timeouts or stub the network in tests.
61
+ * `fetch` implementation used to call the endpoint, e.g. to stub the network
62
+ * in tests. Defaults to the global `fetch`. Whichever is used, the call runs
63
+ * under this package's deadline and response-size cap, and a redirect is
64
+ * refused rather than followed so the bearer token never reaches the host
65
+ * it names.
63
66
  */
64
67
  fetch?: typeof fetch;
65
68
  }
@@ -91,7 +94,11 @@ export interface CheckPermissionsResult {
91
94
  * tenant no longer resolves is a 404 and lands there too. Echoing an `org_id`
92
95
  * off a signed claim is how you meet it, because the organization can be
93
96
  * deleted after the signature, so catch it on that path rather than reading a
94
- * throw as a bug in your own setup.
97
+ * throw as a bug in your own setup. A redirect, or a `2xx` whose body is not a
98
+ * JSON object or exceeds the response-size cap, also throws
99
+ * {@linkcode ServerError}, and a request whose response headers do not
100
+ * arrive before the deadline throws {@linkcode TemporarilyUnavailableError}.
101
+ * The body's shape is not validated beyond being a JSON object.
95
102
  *
96
103
  * @example
97
104
  * ```ts