@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
@@ -27,6 +27,8 @@
27
27
  * @module
28
28
  */
29
29
  import { ServerError, TemporarilyUnavailableError } from "../errors.js";
30
+ import { receiveJson, sendGuarded } from "./_http.js";
31
+ const CHECK_ENDPOINT = "check endpoint";
30
32
  /**
31
33
  * Ask a check endpoint whether the caller holds the named permissions.
32
34
  *
@@ -46,7 +48,11 @@ import { ServerError, TemporarilyUnavailableError } from "../errors.js";
46
48
  * tenant no longer resolves is a 404 and lands there too. Echoing an `org_id`
47
49
  * off a signed claim is how you meet it, because the organization can be
48
50
  * deleted after the signature, so catch it on that path rather than reading a
49
- * throw as a bug in your own setup.
51
+ * throw as a bug in your own setup. A redirect, or a `2xx` whose body is not a
52
+ * JSON object or exceeds the response-size cap, also throws
53
+ * {@linkcode ServerError}, and a request whose response headers do not
54
+ * arrive before the deadline throws {@linkcode TemporarilyUnavailableError}.
55
+ * The body's shape is not validated beyond being a JSON object.
50
56
  *
51
57
  * @example
52
58
  * ```ts
@@ -68,9 +74,19 @@ import { ServerError, TemporarilyUnavailableError } from "../errors.js";
68
74
  */
69
75
  export async function checkPermissions(options) {
70
76
  const fetchImpl = options.fetch ?? fetch;
77
+ const reportUnreachable = async (input, init) => {
78
+ try {
79
+ return await fetchImpl(input, init);
80
+ }
81
+ catch (cause) {
82
+ throw new TemporarilyUnavailableError("check endpoint unreachable", {
83
+ cause,
84
+ });
85
+ }
86
+ };
71
87
  let response;
72
88
  try {
73
- response = await fetchImpl(options.endpoint, {
89
+ response = await sendGuarded(reportUnreachable, options.endpoint, {
74
90
  method: "POST",
75
91
  headers: {
76
92
  authorization: `Bearer ${options.accessToken}`,
@@ -80,18 +96,22 @@ export async function checkPermissions(options) {
80
96
  permissions: options.permissions,
81
97
  ...(options.resource ? { resource: options.resource } : {}),
82
98
  }),
83
- });
99
+ }, CHECK_ENDPOINT);
84
100
  }
85
- catch (cause) {
86
- throw new TemporarilyUnavailableError("check endpoint unreachable", {
87
- cause,
88
- });
101
+ catch (error) {
102
+ if (error instanceof Error &&
103
+ error.cause instanceof TemporarilyUnavailableError) {
104
+ throw error.cause;
105
+ }
106
+ throw error;
89
107
  }
90
108
  if (response.status >= 500) {
109
+ await response.body?.cancel();
91
110
  throw new TemporarilyUnavailableError(`check endpoint answered ${response.status}`);
92
111
  }
93
112
  if (!response.ok) {
113
+ await response.body?.cancel();
94
114
  throw new ServerError(`check request refused: ${response.status}`);
95
115
  }
96
- return await response.json();
116
+ return await receiveJson(response, CHECK_ENDPOINT);
97
117
  }
@@ -62,9 +62,9 @@ export interface DirectClientOptions extends BaseOptions {
62
62
  /** Default scope string to request when none is passed explicitly. */
63
63
  scope?: string;
64
64
  /**
65
- * Issuer URL. If provided, endpoints are fetched from
66
- * `${issuer}/.well-known/oauth-authorization-server` on first use.
67
- * Mutually exclusive with an explicit `endpoints` record below.
65
+ * Issuer URL. If provided, endpoints come from the issuer's discovery
66
+ * document, fetched on first use and re-fetched once it expires (see
67
+ * {@link DirectClient.discover}). Mutually exclusive with `endpoints`.
68
68
  */
69
69
  issuer?: string;
70
70
  /** Explicit endpoint URLs, bypassing discovery. */
@@ -99,8 +99,9 @@ export interface DirectClientOptions extends BaseOptions {
99
99
  /**
100
100
  * Max age of a pending auth-request (`state`) record before
101
101
  * {@link DirectClient.exchangeAuthorizationCode} rejects it as stale, in ms.
102
- * Bounds the callback replay window and unbounded growth of the in-memory
103
- * store. Defaults to 10 minutes; set `Infinity` to disable the check.
102
+ * Bounds the callback replay window. Both default storages also expire
103
+ * records older than this; a storage you pass expires records by its own
104
+ * rules. Defaults to 10 minutes; set `Infinity` to disable the check.
104
105
  */
105
106
  authRequestTtlMs?: number;
106
107
  }
@@ -184,7 +185,7 @@ export interface ExchangeOptions {
184
185
  export interface ExchangeResult {
185
186
  /** The token bundle, not persisted to this client's storage. */
186
187
  tokens: TokenBundle;
187
- /** The rotated refresh token, when the server issued one. */
188
+ /** The refresh token the server issued with this response, if any. */
188
189
  refreshToken?: string;
189
190
  /** `returnTo` recorded at authorize time, for the code exchange. */
190
191
  returnTo?: string;
@@ -268,8 +269,8 @@ export declare class DirectClient extends OAuth2ClientBase {
268
269
  * this works against any compliant server. **Lazy + cached**: it only runs
269
270
  * on the first endpoint use of an `issuer`-configured client (never at
270
271
  * construction), so there is no startup cost and no per-request fetch.
271
- * Concurrent calls share one in-flight request; a failure clears the cache
272
- * so a later call can retry.
272
+ * Concurrent calls share one in-flight request; a failure is not cached, so
273
+ * a later call retries.
273
274
  *
274
275
  * **Re-resolution contract.** The document this client holds is never
275
276
  * permanent: every endpoint use re-resolves once the copy in hand is past
@@ -289,7 +290,9 @@ export declare class DirectClient extends OAuth2ClientBase {
289
290
  * @returns The server's metadata document.
290
291
  * @throws {Error} when the client was configured with explicit `endpoints`
291
292
  * rather than an `issuer` — there is nothing to discover.
292
- * @throws {ServerError} when both well-known paths fail.
293
+ * @throws {OAuth2Error} the last attempt's error when both well-known paths
294
+ * fail: the server is unreachable, answers non-OK or with something other
295
+ * than a JSON object, or reports an `issuer` other than the configured one.
293
296
  *
294
297
  * @example
295
298
  * ```ts
@@ -367,9 +370,10 @@ export declare class DirectClient extends OAuth2ClientBase {
367
370
  * URL, exchanges the code at the token endpoint, persists the token bundle,
368
371
  * and emits `authenticated`.
369
372
  *
370
- * Idempotent on duplicate calls with the same `code` — React strict-mode
371
- * double-mounts would otherwise double-redeem a single-use code and fail the
372
- * second call, so the first in-flight promise is returned again.
373
+ * A duplicate call with the same `code` while the first is still in flight
374
+ * (a React strict-mode double mount) returns the first call's promise rather
375
+ * than redeeming the single-use code twice. A call after it settles redeems
376
+ * again, and fails.
373
377
  *
374
378
  * @param input The callback URL, its query string, or the parsed params.
375
379
  * @returns The persisted tokens and the `returnTo` recorded at login.
@@ -403,8 +407,12 @@ export declare class DirectClient extends OAuth2ClientBase {
403
407
  * token state across this client's storage and the caller's external store,
404
408
  * so refresh, revocation, and logout all stop working as expected.
405
409
  *
406
- * The verifier and `returnTo` are looked up under `state`; on success the
407
- * record is consumed. Emits nothing — the caller owns session semantics.
410
+ * The verifier and `returnTo` are claimed under `state` before the token
411
+ * call, so the record is consumed whether or not the exchange succeeds and
412
+ * two callbacks racing on one `state` reach the token endpoint at most once
413
+ * (atomically so for a storage that implements
414
+ * {@link AuthRequestStorage.take}). Emits nothing — the caller owns session
415
+ * semantics.
408
416
  *
409
417
  * @param code The authorization code from the callback.
410
418
  * @param state The `state` the callback echoed back.
@@ -523,7 +531,9 @@ export declare class DirectClient extends OAuth2ClientBase {
523
531
  * minimum interval and deadline, and `signal` to cancel.
524
532
  * @returns The persisted token bundle.
525
533
  * @throws {AccessDeniedError} when `signal` aborts, or the user declines.
526
- * @throws {InvalidGrantError} when the device code expires before approval.
534
+ * @throws {InvalidGrantError} when `expiresAt` passes before approval. A
535
+ * server that reports the code expired first throws `ExpiredTokenError`
536
+ * instead.
527
537
  * @throws {OAuth2Error} on any other terminal error from the server.
528
538
  */
529
539
  pollDeviceToken(deviceCode: string, options?: PollDeviceTokenOptions): Promise<TokenBundle>;
@@ -561,18 +571,19 @@ export declare class DirectClient extends OAuth2ClientBase {
561
571
  *
562
572
  * @returns The claims the server returns.
563
573
  * @throws {Error} when no userinfo endpoint is configured or discoverable.
564
- * @throws {AccessDeniedError} when no token is stored and none can be
565
- * refreshed.
574
+ * @throws {InvalidGrantError} when no usable token is stored and there is no
575
+ * refresh token to mint one.
576
+ * @throws {OAuth2Error} when the refresh or the userinfo endpoint fails.
566
577
  */
567
578
  getUserInfo(): Promise<UserInfoClaims>;
568
579
  /**
569
580
  * Base64url-decodes an OIDC `id_token` payload and returns the claims.
570
581
  *
571
- * **Does not verify the signature** — the token arrives from the token
572
- * endpoint over TLS, so verification would only defend against a compromised
573
- * TLS chain and is not a normal client concern. If you need verification
574
- * (e.g. the token was obtained out-of-band), use a dedicated JWT library
575
- * with your server's JWKS.
582
+ * **Validates nothing** — not the signature, `iss`, `aud`, `exp`, or
583
+ * `nonce`. Use it only on an `id_token` this client received from its own
584
+ * token endpoint (which is what {@link getUser} does); the client does not
585
+ * require that endpoint to be `https`. For a token obtained any other way,
586
+ * verify it with a JWT library against the server's JWKS.
576
587
  *
577
588
  * @param idToken A compact-serialized JWS.
578
589
  * @returns The decoded payload claims.
@@ -590,6 +601,11 @@ export declare class DirectClient extends OAuth2ClientBase {
590
601
  * A `401` without that challenge is returned as-is — it means "not allowed",
591
602
  * not "token stale", and re-minting would not help.
592
603
  *
604
+ * The stored access token is sent as-is, even when it has expired; recovery
605
+ * depends on the resource server answering with that challenge. It is
606
+ * attached to whatever URL you pass, so send only requests meant for the
607
+ * APIs this token is for.
608
+ *
593
609
  * @param input Request target, as for `fetch`. A `Request` keeps the headers
594
610
  * and body it was built with; it is cloned up front so the retry has an
595
611
  * unread body.
@@ -613,9 +629,10 @@ export declare class DirectClient extends OAuth2ClientBase {
613
629
  * state, emits `logged_out`, and returns the OIDC end-session URL when the
614
630
  * server advertises one.
615
631
  *
616
- * Best effort covers the read too: a refresh-token store that cannot be read
617
- * skips the revocation rather than failing the sign-out, because a client
618
- * that stays signed in locally because its store broke is the worse outcome.
632
+ * Best effort covers the reads too: a refresh-token store that cannot be
633
+ * read skips the revocation, and a token store that cannot be read omits
634
+ * `id_token_hint`, rather than failing the sign-out, because a client that
635
+ * stays signed in locally because its store broke is the worse outcome.
619
636
  *
620
637
  * @param options `returnTo` becomes `post_logout_redirect_uri` on the
621
638
  * end-session URL.
@@ -643,7 +660,8 @@ export declare class DirectClient extends OAuth2ClientBase {
643
660
  getUser(): Promise<UserInfoClaims | null>;
644
661
  /**
645
662
  * The session as this client sees it, derived from stored tokens — no
646
- * network call unless the claims come from a userinfo endpoint.
663
+ * network call unless the claims come from a userinfo endpoint, which may
664
+ * refresh the access token first.
647
665
  *
648
666
  * `isAuthenticated` requires a usable access token: a stored bundle whose
649
667
  * `accessToken` is non-empty and not past its expiry. A holder of a valid
@@ -699,9 +717,11 @@ export declare class DirectClient extends OAuth2ClientBase {
699
717
  * For ordinary HTTP calls prefer {@link fetch}, which attaches the token and
700
718
  * handles the 401/refresh dance for you.
701
719
  *
702
- * @returns A non-expired access token.
703
- * @throws {InvalidGrantError} when nothing is stored and no refresh token is
704
- * available.
720
+ * @returns The stored access token when it has no recorded expiry or more
721
+ * than 30 seconds left, otherwise a freshly refreshed one.
722
+ * @throws {InvalidGrantError} when a refresh is needed and no refresh token
723
+ * is stored, or the stored one is dead.
724
+ * @throws {OAuth2Error} when a needed refresh fails for another reason.
705
725
  */
706
726
  getAccessToken(): Promise<string>;
707
727
  }
@@ -121,9 +121,9 @@ export class DirectClient extends OAuth2ClientBase {
121
121
  this.#tokenStorage = options.tokenStorage ?? new MemoryTokenStorage();
122
122
  this.#refreshTokenStorage = options.refreshTokenStorage ??
123
123
  new MemoryRefreshTokenStorage();
124
- this.#authRequestStorage = options.authRequestStorage ??
125
- defaultAuthRequestStorage();
126
124
  this.#authRequestTtlMs = options.authRequestTtlMs ?? 10 * 60 * 1000;
125
+ this.#authRequestStorage = options.authRequestStorage ??
126
+ defaultAuthRequestStorage(this.#authRequestTtlMs);
127
127
  }
128
128
  /** True when the client was given a secret and authenticates via Basic. */
129
129
  get isConfidential() {
@@ -138,8 +138,8 @@ export class DirectClient extends OAuth2ClientBase {
138
138
  * this works against any compliant server. **Lazy + cached**: it only runs
139
139
  * on the first endpoint use of an `issuer`-configured client (never at
140
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.
141
+ * Concurrent calls share one in-flight request; a failure is not cached, so
142
+ * a later call retries.
143
143
  *
144
144
  * **Re-resolution contract.** The document this client holds is never
145
145
  * permanent: every endpoint use re-resolves once the copy in hand is past
@@ -159,7 +159,9 @@ export class DirectClient extends OAuth2ClientBase {
159
159
  * @returns The server's metadata document.
160
160
  * @throws {Error} when the client was configured with explicit `endpoints`
161
161
  * rather than an `issuer` — there is nothing to discover.
162
- * @throws {ServerError} when both well-known paths fail.
162
+ * @throws {OAuth2Error} the last attempt's error when both well-known paths
163
+ * fail: the server is unreachable, answers non-OK or with something other
164
+ * than a JSON object, or reports an `issuer` other than the configured one.
163
165
  *
164
166
  * @example
165
167
  * ```ts
@@ -359,9 +361,10 @@ export class DirectClient extends OAuth2ClientBase {
359
361
  * URL, exchanges the code at the token endpoint, persists the token bundle,
360
362
  * and emits `authenticated`.
361
363
  *
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.
364
+ * A duplicate call with the same `code` while the first is still in flight
365
+ * (a React strict-mode double mount) returns the first call's promise rather
366
+ * than redeeming the single-use code twice. A call after it settles redeems
367
+ * again, and fails.
365
368
  *
366
369
  * @param input The callback URL, its query string, or the parsed params.
367
370
  * @returns The persisted tokens and the `returnTo` recorded at login.
@@ -433,8 +436,12 @@ export class DirectClient extends OAuth2ClientBase {
433
436
  * token state across this client's storage and the caller's external store,
434
437
  * so refresh, revocation, and logout all stop working as expected.
435
438
  *
436
- * The verifier and `returnTo` are looked up under `state`; on success the
437
- * record is consumed. Emits nothing — the caller owns session semantics.
439
+ * The verifier and `returnTo` are claimed under `state` before the token
440
+ * call, so the record is consumed whether or not the exchange succeeds and
441
+ * two callbacks racing on one `state` reach the token endpoint at most once
442
+ * (atomically so for a storage that implements
443
+ * {@link AuthRequestStorage.take}). Emits nothing — the caller owns session
444
+ * semantics.
438
445
  *
439
446
  * @param code The authorization code from the callback.
440
447
  * @param state The `state` the callback echoed back.
@@ -448,12 +455,11 @@ export class DirectClient extends OAuth2ClientBase {
448
455
  async exchangeAuthorizationCode(code, state, options = {}) {
449
456
  const authRequestStorage = options.authRequestStorage ??
450
457
  this.#authRequestStorage;
451
- const record = await authRequestStorage.get(state);
458
+ const record = await claimAuthRequest(authRequestStorage, state);
452
459
  if (!record)
453
460
  throw new InvalidGrantError("unknown state parameter");
454
461
  if (this.#authRequestTtlMs !== Infinity &&
455
462
  Date.now() - record.createdAt > this.#authRequestTtlMs) {
456
- await authRequestStorage.delete(state);
457
463
  throw new InvalidGrantError("authorization request expired");
458
464
  }
459
465
  const body = new URLSearchParams({
@@ -465,7 +471,6 @@ export class DirectClient extends OAuth2ClientBase {
465
471
  if (redirectUri)
466
472
  body.set("redirect_uri", redirectUri);
467
473
  const raw = await this.#postToken(body);
468
- await authRequestStorage.delete(state);
469
474
  return {
470
475
  tokens: responseToBundle(raw),
471
476
  refreshToken: raw.refresh_token,
@@ -679,7 +684,9 @@ export class DirectClient extends OAuth2ClientBase {
679
684
  * minimum interval and deadline, and `signal` to cancel.
680
685
  * @returns The persisted token bundle.
681
686
  * @throws {AccessDeniedError} when `signal` aborts, or the user declines.
682
- * @throws {InvalidGrantError} when the device code expires before approval.
687
+ * @throws {InvalidGrantError} when `expiresAt` passes before approval. A
688
+ * server that reports the code expired first throws `ExpiredTokenError`
689
+ * instead.
683
690
  * @throws {OAuth2Error} on any other terminal error from the server.
684
691
  */
685
692
  async pollDeviceToken(deviceCode, options = {}) {
@@ -776,8 +783,9 @@ export class DirectClient extends OAuth2ClientBase {
776
783
  *
777
784
  * @returns The claims the server returns.
778
785
  * @throws {Error} when no userinfo endpoint is configured or discoverable.
779
- * @throws {AccessDeniedError} when no token is stored and none can be
780
- * refreshed.
786
+ * @throws {InvalidGrantError} when no usable token is stored and there is no
787
+ * refresh token to mint one.
788
+ * @throws {OAuth2Error} when the refresh or the userinfo endpoint fails.
781
789
  */
782
790
  async getUserInfo() {
783
791
  return await this.#fetchUserInfo(false);
@@ -796,11 +804,11 @@ export class DirectClient extends OAuth2ClientBase {
796
804
  /**
797
805
  * Base64url-decodes an OIDC `id_token` payload and returns the claims.
798
806
  *
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.
807
+ * **Validates nothing** — not the signature, `iss`, `aud`, `exp`, or
808
+ * `nonce`. Use it only on an `id_token` this client received from its own
809
+ * token endpoint (which is what {@link getUser} does); the client does not
810
+ * require that endpoint to be `https`. For a token obtained any other way,
811
+ * verify it with a JWT library against the server's JWKS.
804
812
  *
805
813
  * @param idToken A compact-serialized JWS.
806
814
  * @returns The decoded payload claims.
@@ -824,6 +832,11 @@ export class DirectClient extends OAuth2ClientBase {
824
832
  * A `401` without that challenge is returned as-is — it means "not allowed",
825
833
  * not "token stale", and re-minting would not help.
826
834
  *
835
+ * The stored access token is sent as-is, even when it has expired; recovery
836
+ * depends on the resource server answering with that challenge. It is
837
+ * attached to whatever URL you pass, so send only requests meant for the
838
+ * APIs this token is for.
839
+ *
827
840
  * @param input Request target, as for `fetch`. A `Request` keeps the headers
828
841
  * and body it was built with; it is cloned up front so the retry has an
829
842
  * unread body.
@@ -871,9 +884,10 @@ export class DirectClient extends OAuth2ClientBase {
871
884
  * state, emits `logged_out`, and returns the OIDC end-session URL when the
872
885
  * server advertises one.
873
886
  *
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.
887
+ * Best effort covers the reads too: a refresh-token store that cannot be
888
+ * read skips the revocation, and a token store that cannot be read omits
889
+ * `id_token_hint`, rather than failing the sign-out, because a client that
890
+ * stays signed in locally because its store broke is the worse outcome.
877
891
  *
878
892
  * @param options `returnTo` becomes `post_logout_redirect_uri` on the
879
893
  * end-session URL.
@@ -893,7 +907,9 @@ export class DirectClient extends OAuth2ClientBase {
893
907
  async logout(options = {}) {
894
908
  const version = ++this.#sessionVersion;
895
909
  const refreshToken = await this.#readRefreshToken(false).catch(() => null);
896
- const idToken = (await this.#tokenStorage.get())?.idToken;
910
+ const idToken = await Promise.resolve()
911
+ .then(() => this.#tokenStorage.get())
912
+ .then((tokens) => tokens?.idToken, () => undefined);
897
913
  if (version !== this.#sessionVersion)
898
914
  return {};
899
915
  await this.#clearSession();
@@ -945,7 +961,8 @@ export class DirectClient extends OAuth2ClientBase {
945
961
  }
946
962
  /**
947
963
  * The session as this client sees it, derived from stored tokens — no
948
- * network call unless the claims come from a userinfo endpoint.
964
+ * network call unless the claims come from a userinfo endpoint, which may
965
+ * refresh the access token first.
949
966
  *
950
967
  * `isAuthenticated` requires a usable access token: a stored bundle whose
951
968
  * `accessToken` is non-empty and not past its expiry. A holder of a valid
@@ -1032,9 +1049,11 @@ export class DirectClient extends OAuth2ClientBase {
1032
1049
  * For ordinary HTTP calls prefer {@link fetch}, which attaches the token and
1033
1050
  * handles the 401/refresh dance for you.
1034
1051
  *
1035
- * @returns A non-expired access token.
1036
- * @throws {InvalidGrantError} when nothing is stored and no refresh token is
1037
- * available.
1052
+ * @returns The stored access token when it has no recorded expiry or more
1053
+ * than 30 seconds left, otherwise a freshly refreshed one.
1054
+ * @throws {InvalidGrantError} when a refresh is needed and no refresh token
1055
+ * is stored, or the stored one is dead.
1056
+ * @throws {OAuth2Error} when a needed refresh fails for another reason.
1038
1057
  */
1039
1058
  async getAccessToken() {
1040
1059
  return await this.#accessTokenFor(false);
@@ -1051,11 +1070,6 @@ export class DirectClient extends OAuth2ClientBase {
1051
1070
  #basicAuth() {
1052
1071
  return encodeBasicAuth(this.#clientId, this.#clientSecret);
1053
1072
  }
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
1073
  #applyClientAuth(headers, body) {
1060
1074
  if (this.isConfidential) {
1061
1075
  headers.Authorization = this.#basicAuth();
@@ -1075,10 +1089,6 @@ export class DirectClient extends OAuth2ClientBase {
1075
1089
  assertTokenResponse(response, "token endpoint");
1076
1090
  return response;
1077
1091
  }
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
1092
  async #postForm(endpoint, headers, body, what) {
1083
1093
  const res = await sendGuarded((input, init) => this.fetchImpl(input, init), endpoint, {
1084
1094
  method: "POST",
@@ -1123,11 +1133,6 @@ export class DirectClient extends OAuth2ClientBase {
1123
1133
  });
1124
1134
  }
1125
1135
  }
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
1136
  function withOrigin(urlStr, origin) {
1132
1137
  const url = new URL(urlStr);
1133
1138
  return new URL(`${url.pathname}${url.search}${url.hash}`, origin).toString();
@@ -1176,12 +1181,20 @@ function toSearchParams(input) {
1176
1181
  return new URLSearchParams(input.startsWith("?") ? input.slice(1) : input);
1177
1182
  }
1178
1183
  }
1179
- function defaultAuthRequestStorage() {
1184
+ function defaultAuthRequestStorage(ttlMs) {
1180
1185
  const inBrowserDocument = typeof document !== "undefined" &&
1181
1186
  typeof sessionStorage !== "undefined";
1182
1187
  return inBrowserDocument
1183
- ? new SessionStorageAuthRequestStorage()
1184
- : new MemoryAuthRequestStorage();
1188
+ ? new SessionStorageAuthRequestStorage({ ttlMs })
1189
+ : new MemoryAuthRequestStorage({ ttlMs });
1190
+ }
1191
+ async function claimAuthRequest(storage, state) {
1192
+ if (storage.take)
1193
+ return await storage.take(state);
1194
+ const record = await storage.get(state);
1195
+ if (record)
1196
+ await storage.delete(state);
1197
+ return record;
1185
1198
  }
1186
1199
  function assertUsableSecret(secret) {
1187
1200
  if (secret === undefined)
@@ -1199,12 +1212,6 @@ function assertUsableSecret(secret) {
1199
1212
  "exchange behind a BffClient.");
1200
1213
  }
1201
1214
  }
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
1215
  function assertIssuer(meta, issuer) {
1209
1216
  const reported = meta.issuer;
1210
1217
  if (typeof reported !== "string" || reported.length === 0) {
@@ -1223,13 +1230,6 @@ function trimSlash(url) {
1223
1230
  function isOAuth2ErrorCode(error, code) {
1224
1231
  return isOAuth2Error(error) && error.extensions.error === code;
1225
1232
  }
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
1233
  function pollDelay(ms, signal) {
1234
1234
  return delay(ms, { signal }).catch(() => {
1235
1235
  throw new AccessDeniedError("aborted");
@@ -5,7 +5,11 @@
5
5
  * @module
6
6
  */
7
7
  import type { AuthorizationServerMetadata } from "../models/responses.js";
8
- /** Default entry lifetime for {@link MemoryDiscoveryCache}: one hour. */
8
+ /**
9
+ * Default discovery-document lifetime, one hour: the TTL of a
10
+ * {@link MemoryDiscoveryCache}, and how long a `DirectClient` without a
11
+ * `discoveryCache` keeps its own copy.
12
+ */
9
13
  export declare const DEFAULT_DISCOVERY_TTL_MS: number;
10
14
  /** Default entry ceiling for {@link MemoryDiscoveryCache}. */
11
15
  export declare const DEFAULT_DISCOVERY_CACHE_MAX_ENTRIES = 100;
@@ -86,15 +90,15 @@ export interface MemoryDiscoveryCacheOptions {
86
90
  /**
87
91
  * Maximum number of issuers held. Defaults to
88
92
  * {@link DEFAULT_DISCOVERY_CACHE_MAX_ENTRIES}. Expired entries are dropped
89
- * first, then the least recently stored, so a multi-tenant server with an
93
+ * first, then the least recently used, so a multi-tenant server with an
90
94
  * unbounded issuer list cannot grow without limit.
91
95
  */
92
96
  maxEntries?: number;
93
97
  }
94
98
  /**
95
99
  * In-memory {@link DiscoveryCache}: TTL entries plus in-flight de-duplication,
96
- * so a burst of requests against a cold issuer triggers exactly one discovery
97
- * fetch. Failures are never cached.
100
+ * so a burst of requests against a cold issuer triggers one `load`. Failures
101
+ * are never cached.
98
102
  *
99
103
  * Construct one per process (or per trust boundary) and hand it to every
100
104
  * client that should share it.
@@ -133,6 +137,9 @@ export declare class MemoryDiscoveryCache implements DiscoveryCache {
133
137
  resolve(issuer: string, load: () => Promise<AuthorizationServerMetadata>): Promise<DiscoveryCacheEntry>;
134
138
  /** Drops the entry for `issuer`, forcing the next resolve to re-fetch. */
135
139
  delete(issuer: string): void;
136
- /** Drops every entry. In-flight loads still settle for their callers. */
140
+ /**
141
+ * Drops every entry. A load already in flight still settles for its callers
142
+ * and stores its result.
143
+ */
137
144
  clear(): void;
138
145
  }
@@ -4,14 +4,18 @@
4
4
  *
5
5
  * @module
6
6
  */
7
- /** Default entry lifetime for {@link MemoryDiscoveryCache}: one hour. */
7
+ /**
8
+ * Default discovery-document lifetime, one hour: the TTL of a
9
+ * {@link MemoryDiscoveryCache}, and how long a `DirectClient` without a
10
+ * `discoveryCache` keeps its own copy.
11
+ */
8
12
  export const DEFAULT_DISCOVERY_TTL_MS = 60 * 60 * 1000;
9
13
  /** Default entry ceiling for {@link MemoryDiscoveryCache}. */
10
14
  export const DEFAULT_DISCOVERY_CACHE_MAX_ENTRIES = 100;
11
15
  /**
12
16
  * In-memory {@link DiscoveryCache}: TTL entries plus in-flight de-duplication,
13
- * so a burst of requests against a cold issuer triggers exactly one discovery
14
- * fetch. Failures are never cached.
17
+ * so a burst of requests against a cold issuer triggers one `load`. Failures
18
+ * are never cached.
15
19
  *
16
20
  * Construct one per process (or per trust boundary) and hand it to every
17
21
  * client that should share it.
@@ -81,7 +85,10 @@ export class MemoryDiscoveryCache {
81
85
  delete(issuer) {
82
86
  this.#entries.delete(normalizeIssuer(issuer));
83
87
  }
84
- /** Drops every entry. In-flight loads still settle for their callers. */
88
+ /**
89
+ * Drops every entry. A load already in flight still settles for its callers
90
+ * and stores its result.
91
+ */
85
92
  clear() {
86
93
  this.#entries.clear();
87
94
  }
@@ -14,11 +14,19 @@ export interface AuthenticatedEvent {
14
14
  /** The token bundle that was just persisted for the session. */
15
15
  tokens: TokenBundle;
16
16
  }
17
- /** The client has cleared its session and the user is no longer authenticated. */
17
+ /**
18
+ * The user is signed out, or is about to be: `BffClient.logout` emits it before
19
+ * the browser has navigated to the BFF's logout route.
20
+ */
18
21
  export interface LoggedOutEvent {
19
22
  /** Discriminant tag for this event variant. */
20
23
  type: "logged_out";
21
- /** Why the session ended. */
24
+ /**
25
+ * Why the session ended: `user` when `logout` ran, `invalid_grant` when a
26
+ * `DirectClient` refresh found the grant dead (local tokens are already
27
+ * cleared), `session_expired` when a `BffClient.fetch` got a `401`.
28
+ * `refresh_failed` is never emitted by the shipped clients.
29
+ */
22
30
  reason: "user" | "refresh_failed" | "invalid_grant" | "session_expired";
23
31
  }
24
32
  /** A refresh has just completed; the access token has rotated. */
@@ -39,7 +47,7 @@ export interface ErrorEvent {
39
47
  export type OAuth2ClientEvent = AuthenticatedEvent | LoggedOutEvent | TokenRefreshedEvent | ErrorEvent;
40
48
  /** Subscriber callback. Return values are ignored. */
41
49
  export type OAuth2ClientEventListener = (event: OAuth2ClientEvent) => void;
42
- /** Minimal event bus for the client to avoid pulling in a dependency. */
50
+ /** The clients' listener registry; one throwing listener never blocks the rest. */
43
51
  export declare class EventBus {
44
52
  #private;
45
53
  /** Registers `listener` and returns a function that removes it. */
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * @module
8
8
  */
9
- /** Minimal event bus for the client to avoid pulling in a dependency. */
9
+ /** The clients' listener registry; one throwing listener never blocks the rest. */
10
10
  export class EventBus {
11
11
  #listeners = new Set();
12
12
  /** Registers `listener` and returns a function that removes it. */