@udibo/oauth2 0.6.0 → 0.8.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.
@@ -15,3 +15,17 @@ declare global {
15
15
  }
16
16
  }
17
17
  export {};
18
+ declare global {
19
+ interface PromiseConstructor {
20
+ /**
21
+ * Creates a Promise that can be resolved or rejected using provided functions.
22
+ * @returns An object containing `promise` promise object, `resolve` and `reject` functions.
23
+ */
24
+ withResolvers<T>(): {
25
+ promise: Promise<T>;
26
+ resolve: (value: T | PromiseLike<T>) => void;
27
+ reject: (reason?: any) => void;
28
+ };
29
+ }
30
+ }
31
+ export {};
@@ -12,4 +12,15 @@ if (!Object.hasOwn) {
12
12
  writable: true,
13
13
  });
14
14
  }
15
+ // https://github.com/tc39/proposal-promise-with-resolvers/blob/3a78801e073e99217dbeb2c43ba7212f3bdc8b83/polyfills.js#L1C1-L9C2
16
+ if (Promise.withResolvers === undefined) {
17
+ Promise.withResolvers = () => {
18
+ const out = {};
19
+ out.promise = new Promise((resolve_, reject_) => {
20
+ out.resolve = resolve_;
21
+ out.reject = reject_;
22
+ });
23
+ return out;
24
+ };
25
+ }
15
26
  export {};
@@ -49,7 +49,7 @@
49
49
  */
50
50
  import { Hono } from "hono";
51
51
  import type { Context, Handler, MiddlewareHandler } from "hono";
52
- import type { DirectClient, TokenBundle, UserInfoClaims } from "../../../client/mod.js";
52
+ import type { DirectClient, SessionState, TokenBundle, UserInfoClaims } from "../../../client/mod.js";
53
53
  import type { ClientInterface } from "../../../models/client.js";
54
54
  import type { AbstractScope, BasicScope } from "../../../models/scope.js";
55
55
  import type { HonoAuthorizationServer } from "../authorization-server.js";
@@ -260,6 +260,23 @@ export interface HonoBffBackchannelOptions {
260
260
  * `{ sub, sid }` to log out. Throw or return `null` to reject the request.
261
261
  */
262
262
  verifyLogoutToken: (logoutToken: string) => Promise<BackchannelLogoutSubject | null> | BackchannelLogoutSubject | null;
263
+ /**
264
+ * The largest request body, in bytes, the receiver reads. The body is read
265
+ * before the `logout_token` is verified, so the cap bounds what an
266
+ * anonymous caller can make the BFF buffer. Bytes are counted as they
267
+ * arrive, so a chunked body or a `Content-Length` that understates the body
268
+ * cannot get past it. A larger body is refused with `400 invalid_request` —
269
+ * the status OIDC Back-Channel Logout 1.0 §2.8 requires for a failed
270
+ * request — without calling
271
+ * {@linkcode HonoBffBackchannelOptions.verifyLogoutToken}.
272
+ *
273
+ * The default is far above a `logout_token`, a JWT of a few KiB. When an
274
+ * earlier middleware has already read the body, the receiver uses Hono's
275
+ * parsed copy and the cap does not apply; bound the body in that middleware.
276
+ *
277
+ * @default 65536
278
+ */
279
+ maxBodyBytes?: number;
263
280
  }
264
281
  /**
265
282
  * Why `/auth/callback` could not complete a sign-in. Passed to
@@ -609,7 +626,9 @@ export declare class HonoBff {
609
626
  * {@linkcode HonoBffPaths.basePath}, when the session cookie would die
610
627
  * before the session behind it (see
611
628
  * {@linkcode HonoBffOptions.sessionMaxAgeMs}), when `sessionMaxAgeMs` or a
612
- * numeric `cookie.maxAge` is not positive or exceeds 400 days, or when
629
+ * numeric `cookie.maxAge` is not positive or exceeds 400 days, when
630
+ * {@linkcode HonoBffBackchannelOptions.maxBodyBytes} is not a positive
631
+ * integer (a `RangeError`), or when
613
632
  * {@linkcode HonoBffOptions.extraParams} or
614
633
  * {@linkcode HonoBffOptions.forwardedParams} names a reserved authorize
615
634
  * parameter, gives one a name that is empty or not equal to its trimmed
@@ -712,12 +731,56 @@ export declare class HonoBff {
712
731
  * `logoutUrl` when signed in. Never returns tokens.
713
732
  */
714
733
  sessionHandler(): Handler;
734
+ /**
735
+ * Who is signed in on this request, for a server that renders pages: the
736
+ * answer `GET /auth/session` gives the browser, read without a round trip to
737
+ * it. Seed the React provider's `initialState` with it so a signed-in page
738
+ * renders signed in on the first paint.
739
+ *
740
+ * A session counts exactly when the probe would count it. In `sessionMode:
741
+ * "own"`, a session older than `sessionMaxAgeMs` is destroyed and its cookie
742
+ * cleared on this response, just as the probe does. Shared mode leaves
743
+ * lifetime enforcement to the application and its session store.
744
+ * It never returns a token, and it does
745
+ * not refresh one: `sessionExpiresIn` is `0` once the access token has
746
+ * expired, and the next `attachToken` or `protect` refreshes it when the
747
+ * session holds a refresh token.
748
+ *
749
+ * It skips the CSRF header check, because the answer goes to your server,
750
+ * not to a page that could read it cross-site. Call it only on the server.
751
+ * It sets no caching policy. A response built from its answer is one
752
+ * person's, so send it with a private or `no-store` `Cache-Control`.
753
+ *
754
+ * @param c The request's Hono context. The session cookie is read from it.
755
+ * @returns The same fields as `BffClient.getSession()`. Signed out,
756
+ * `user`, `sessionExpiresIn` and `logoutUrl` are all `null`.
757
+ *
758
+ * @example Seed the React provider from a server-rendered page
759
+ * ```ts
760
+ * import { Hono } from "hono";
761
+ * import type { SessionState } from "@udibo/oauth2/client";
762
+ * import type { HonoBff } from "@udibo/oauth2/hono/bff";
763
+ *
764
+ * declare const bff: HonoBff;
765
+ * declare function renderPage(initialState: SessionState): string;
766
+ *
767
+ * const app = new Hono();
768
+ * app.route("/auth", bff.routes());
769
+ * app.get("/", async (c) => {
770
+ * c.header("Cache-Control", "private, no-store");
771
+ * return c.html(renderPage(await bff.readSession(c)));
772
+ * });
773
+ * ```
774
+ */
775
+ readSession(c: Context): Promise<SessionState>;
715
776
  /**
716
777
  * OIDC Back-Channel Logout receiver. A server-to-server `POST` from the OP
717
778
  * carrying a signed `logout_token`; the configured verifier validates it (and
718
779
  * returns the subject / OP session id), then the matching server-side
719
780
  * session(s) are destroyed. Responds `200` on success and `400` on a
720
- * missing/invalid token, with `Cache-Control: no-store`, per the spec.
781
+ * missing/invalid token or a body over
782
+ * {@linkcode HonoBffBackchannelOptions.maxBodyBytes}, with
783
+ * `Cache-Control: no-store`, per the spec.
721
784
  */
722
785
  backchannelHandler(): Handler;
723
786
  /**
@@ -49,6 +49,7 @@
49
49
  */
50
50
  import { Hono } from "hono";
51
51
  import { deleteCookie, getCookie, setCookie } from "hono/cookie";
52
+ import { assertMaxBodyBytes, DEFAULT_MAX_BODY_BYTES, readBoundedBody, } from "../../../utils/_body.js";
52
53
  import { InvalidRequestError, InvalidTokenError, isOAuth2Error, TemporarilyUnavailableError, } from "../../../errors.js";
53
54
  import { timingSafeMatchIndex } from "../../../utils/_timing-safe.js";
54
55
  import { sha256Hash } from "../../../server/utils/hash.js";
@@ -303,7 +304,9 @@ export class HonoBff {
303
304
  * {@linkcode HonoBffPaths.basePath}, when the session cookie would die
304
305
  * before the session behind it (see
305
306
  * {@linkcode HonoBffOptions.sessionMaxAgeMs}), when `sessionMaxAgeMs` or a
306
- * numeric `cookie.maxAge` is not positive or exceeds 400 days, or when
307
+ * numeric `cookie.maxAge` is not positive or exceeds 400 days, when
308
+ * {@linkcode HonoBffBackchannelOptions.maxBodyBytes} is not a positive
309
+ * integer (a `RangeError`), or when
307
310
  * {@linkcode HonoBffOptions.extraParams} or
308
311
  * {@linkcode HonoBffOptions.forwardedParams} names a reserved authorize
309
312
  * parameter, gives one a name that is empty or not equal to its trimmed
@@ -363,6 +366,9 @@ export class HonoBff {
363
366
  }
364
367
  }
365
368
  }
369
+ if (options.backchannelLogout?.maxBodyBytes !== undefined) {
370
+ assertMaxBodyBytes(options.backchannelLogout.maxBodyBytes, "backchannelLogout.maxBodyBytes");
371
+ }
366
372
  if (options.backchannelLogout && !supportsBackchannelLogout(this.#store)) {
367
373
  throw new Error("backchannelLogout requires a SessionStore that implements " +
368
374
  "destroyByLogout() (a stateful store). The stateless " +
@@ -758,16 +764,74 @@ export class HonoBff {
758
764
  if (!data) {
759
765
  return c.json({ isAuthenticated: false, user: null });
760
766
  }
761
- const expiresAt = data.tokens.accessTokenExpiresAt;
762
- const sessionExpiresIn = expiresAt !== undefined
767
+ return c.json(this.#signedInState(data));
768
+ };
769
+ }
770
+ /**
771
+ * Who is signed in on this request, for a server that renders pages: the
772
+ * answer `GET /auth/session` gives the browser, read without a round trip to
773
+ * it. Seed the React provider's `initialState` with it so a signed-in page
774
+ * renders signed in on the first paint.
775
+ *
776
+ * A session counts exactly when the probe would count it. In `sessionMode:
777
+ * "own"`, a session older than `sessionMaxAgeMs` is destroyed and its cookie
778
+ * cleared on this response, just as the probe does. Shared mode leaves
779
+ * lifetime enforcement to the application and its session store.
780
+ * It never returns a token, and it does
781
+ * not refresh one: `sessionExpiresIn` is `0` once the access token has
782
+ * expired, and the next `attachToken` or `protect` refreshes it when the
783
+ * session holds a refresh token.
784
+ *
785
+ * It skips the CSRF header check, because the answer goes to your server,
786
+ * not to a page that could read it cross-site. Call it only on the server.
787
+ * It sets no caching policy. A response built from its answer is one
788
+ * person's, so send it with a private or `no-store` `Cache-Control`.
789
+ *
790
+ * @param c The request's Hono context. The session cookie is read from it.
791
+ * @returns The same fields as `BffClient.getSession()`. Signed out,
792
+ * `user`, `sessionExpiresIn` and `logoutUrl` are all `null`.
793
+ *
794
+ * @example Seed the React provider from a server-rendered page
795
+ * ```ts
796
+ * import { Hono } from "hono";
797
+ * import type { SessionState } from "@udibo/oauth2/client";
798
+ * import type { HonoBff } from "@udibo/oauth2/hono/bff";
799
+ *
800
+ * declare const bff: HonoBff;
801
+ * declare function renderPage(initialState: SessionState): string;
802
+ *
803
+ * const app = new Hono();
804
+ * app.route("/auth", bff.routes());
805
+ * app.get("/", async (c) => {
806
+ * c.header("Cache-Control", "private, no-store");
807
+ * return c.html(renderPage(await bff.readSession(c)));
808
+ * });
809
+ * ```
810
+ */
811
+ async readSession(c) {
812
+ const cookieValue = getCookie(c, this.#cookieName);
813
+ const data = cookieValue
814
+ ? await this.#readLiveSession(c, cookieValue)
815
+ : null;
816
+ if (!data) {
817
+ return {
818
+ isAuthenticated: false,
819
+ user: null,
820
+ sessionExpiresIn: null,
821
+ logoutUrl: null,
822
+ };
823
+ }
824
+ return this.#signedInState(data);
825
+ }
826
+ #signedInState(data) {
827
+ const expiresAt = data.tokens.accessTokenExpiresAt;
828
+ return {
829
+ isAuthenticated: true,
830
+ user: data.user ?? null,
831
+ sessionExpiresIn: expiresAt !== undefined
763
832
  ? Math.max(0, Math.round((expiresAt - Date.now()) / 1000))
764
- : null;
765
- return c.json({
766
- isAuthenticated: true,
767
- user: data.user ?? null,
768
- sessionExpiresIn,
769
- logoutUrl: this.#paths.logout,
770
- });
833
+ : null,
834
+ logoutUrl: this.#paths.logout,
771
835
  };
772
836
  }
773
837
  /**
@@ -775,7 +839,9 @@ export class HonoBff {
775
839
  * carrying a signed `logout_token`; the configured verifier validates it (and
776
840
  * returns the subject / OP session id), then the matching server-side
777
841
  * session(s) are destroyed. Responds `200` on success and `400` on a
778
- * missing/invalid token, with `Cache-Control: no-store`, per the spec.
842
+ * missing/invalid token or a body over
843
+ * {@linkcode HonoBffBackchannelOptions.maxBodyBytes}, with
844
+ * `Cache-Control: no-store`, per the spec.
779
845
  */
780
846
  backchannelHandler() {
781
847
  return async (c) => {
@@ -785,7 +851,16 @@ export class HonoBff {
785
851
  return c.json({ error: "not_supported" }, 400);
786
852
  let logoutToken;
787
853
  try {
788
- logoutToken = (await c.req.formData()).get("logout_token")?.toString();
854
+ const form = c.req.raw.bodyUsed
855
+ ? await c.req.formData()
856
+ : await this.#readBackchannelForm(c, opts);
857
+ if (!form) {
858
+ return c.json({
859
+ error: "invalid_request",
860
+ error_description: "request body is too large",
861
+ }, 400);
862
+ }
863
+ logoutToken = form.get("logout_token")?.toString();
789
864
  }
790
865
  catch {
791
866
  logoutToken = undefined;
@@ -816,6 +891,14 @@ export class HonoBff {
816
891
  return c.body(null, 200);
817
892
  };
818
893
  }
894
+ async #readBackchannelForm(c, opts) {
895
+ const bytes = await readBoundedBody(c.req.raw, opts.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES);
896
+ if (!bytes)
897
+ return null;
898
+ return await new Response(bytes, {
899
+ headers: { "content-type": c.req.header("content-type") ?? "" },
900
+ }).formData();
901
+ }
819
902
  async #resolveSessionEntry(c) {
820
903
  const cookieValue = getCookie(c, this.#cookieName);
821
904
  if (!cookieValue)
@@ -32,8 +32,11 @@ export type RedirectPolicy = "refuse" | "follow";
32
32
  * @param what The endpoint's name, for error messages.
33
33
  * @param options `redirect` policy and `timeoutMs` deadline.
34
34
  * @returns The response, which may still be a non-OK status.
35
- * @throws {ServerError} when the request cannot be made, times out, or (under
36
- * `"refuse"`) answers with a redirect.
35
+ * @throws {TemporarilyUnavailableError} when the request cannot be made or its
36
+ * response headers do not arrive before the deadline; `cause` is the
37
+ * transport failure.
38
+ * @throws {ServerError} when (under `"refuse"`) the endpoint answers with a
39
+ * redirect.
37
40
  */
38
41
  export declare function sendGuarded(fetchImpl: typeof fetch, url: string, init: RequestInit, what: Endpoint, options?: {
39
42
  redirect?: RedirectPolicy;
@@ -47,8 +47,11 @@ const ERROR_CLASS_BY_CODE = {
47
47
  * @param what The endpoint's name, for error messages.
48
48
  * @param options `redirect` policy and `timeoutMs` deadline.
49
49
  * @returns The response, which may still be a non-OK status.
50
- * @throws {ServerError} when the request cannot be made, times out, or (under
51
- * `"refuse"`) answers with a redirect.
50
+ * @throws {TemporarilyUnavailableError} when the request cannot be made or its
51
+ * response headers do not arrive before the deadline; `cause` is the
52
+ * transport failure.
53
+ * @throws {ServerError} when (under `"refuse"`) the endpoint answers with a
54
+ * redirect.
52
55
  */
53
56
  export async function sendGuarded(fetchImpl, url, init, what, options = {}) {
54
57
  const redirect = options.redirect ?? "refuse";
@@ -62,7 +65,7 @@ export async function sendGuarded(fetchImpl, url, init, what, options = {}) {
62
65
  });
63
66
  }
64
67
  catch (error) {
65
- throw new ServerError(`could not reach the ${what} (${describeError(error)})`, { cause: error });
68
+ throw new TemporarilyUnavailableError(`could not reach the ${what} (${describeError(error)})`, { cause: error });
66
69
  }
67
70
  if (redirect === "refuse" && res.status >= 300 && res.status < 400) {
68
71
  await res.body?.cancel();
@@ -74,38 +74,17 @@ const CHECK_ENDPOINT = "check endpoint";
74
74
  * ```
75
75
  */
76
76
  export async function checkPermissions(options) {
77
- const fetchImpl = options.fetch ?? fetch;
78
- const reportUnreachable = async (input, init) => {
79
- try {
80
- return await fetchImpl(input, init);
81
- }
82
- catch (cause) {
83
- throw new TemporarilyUnavailableError("check endpoint unreachable", {
84
- cause,
85
- });
86
- }
87
- };
88
- let response;
89
- try {
90
- response = await sendGuarded(reportUnreachable, options.endpoint, {
91
- method: "POST",
92
- headers: {
93
- authorization: `Bearer ${options.accessToken}`,
94
- "content-type": "application/json",
95
- },
96
- body: JSON.stringify({
97
- permissions: options.permissions,
98
- ...(options.resource ? { resource: options.resource } : {}),
99
- }),
100
- }, CHECK_ENDPOINT);
101
- }
102
- catch (error) {
103
- if (error instanceof Error &&
104
- error.cause instanceof TemporarilyUnavailableError) {
105
- throw error.cause;
106
- }
107
- throw error;
108
- }
77
+ const response = await sendGuarded(options.fetch ?? fetch, options.endpoint, {
78
+ method: "POST",
79
+ headers: {
80
+ authorization: `Bearer ${options.accessToken}`,
81
+ "content-type": "application/json",
82
+ },
83
+ body: JSON.stringify({
84
+ permissions: options.permissions,
85
+ ...(options.resource ? { resource: options.resource } : {}),
86
+ }),
87
+ }, CHECK_ENDPOINT);
109
88
  if (response.status >= 500) {
110
89
  await response.body?.cancel();
111
90
  throw new TemporarilyUnavailableError(`check endpoint answered ${response.status}`);
@@ -290,9 +290,13 @@ export declare class DirectClient extends OAuth2ClientBase {
290
290
  * @returns The server's metadata document.
291
291
  * @throws {Error} when the client was configured with explicit `endpoints`
292
292
  * rather than an `issuer` — there is nothing to discover.
293
+ * @throws {TemporarilyUnavailableError} the last attempt's error when both
294
+ * well-known paths fail because the server cannot be reached or its
295
+ * response does not arrive in full before the deadline; `cause` is the
296
+ * transport failure.
293
297
  * @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.
298
+ * fail otherwise: the server answers non-OK or with something other than a
299
+ * JSON object, or reports an `issuer` other than the configured one.
296
300
  *
297
301
  * @example
298
302
  * ```ts
@@ -377,6 +381,9 @@ export declare class DirectClient extends OAuth2ClientBase {
377
381
  *
378
382
  * @param input The callback URL, its query string, or the parsed params.
379
383
  * @returns The persisted tokens and the `returnTo` recorded at login.
384
+ * @throws {TemporarilyUnavailableError} when the token endpoint cannot be
385
+ * reached or its response does not arrive in full before the deadline;
386
+ * `cause` is the transport failure.
380
387
  * @throws {OAuth2Error} when the callback carries `?error=…`, or the token
381
388
  * exchange fails.
382
389
  * @throws {TypeError} when `code` or `state` is missing.
@@ -422,6 +429,9 @@ export declare class DirectClient extends OAuth2ClientBase {
422
429
  * `returnTo`, plus the raw response.
423
430
  * @throws {InvalidGrantError} when `state` is unknown or older than
424
431
  * `authRequestTtlMs`.
432
+ * @throws {TemporarilyUnavailableError} when the token endpoint cannot be
433
+ * reached or its response does not arrive in full before the deadline;
434
+ * `cause` is the transport failure.
425
435
  */
426
436
  exchangeAuthorizationCode(code: string, state: string, options?: ExchangeOptions): Promise<ExchangeResult>;
427
437
  /**
@@ -440,6 +450,9 @@ export declare class DirectClient extends OAuth2ClientBase {
440
450
  *
441
451
  * @param refreshToken The refresh token to redeem.
442
452
  * @returns The new tokens, the rotated refresh token, and the raw response.
453
+ * @throws {TemporarilyUnavailableError} when the token endpoint cannot be
454
+ * reached or its response does not arrive in full before the deadline;
455
+ * `cause` is the transport failure.
443
456
  * @throws {OAuth2Error} when the token endpoint rejects the grant.
444
457
  */
445
458
  exchangeRefreshToken(refreshToken: string): Promise<ExchangeResult>;
@@ -451,6 +464,9 @@ export declare class DirectClient extends OAuth2ClientBase {
451
464
  * @returns The persisted token bundle.
452
465
  * @throws {Error} when the client has no `clientSecret` — RFC 6749 §4.4
453
466
  * restricts this grant to confidential clients.
467
+ * @throws {TemporarilyUnavailableError} when the token endpoint cannot be
468
+ * reached or its response does not arrive in full before the deadline;
469
+ * `cause` is the transport failure.
454
470
  * @throws {OAuth2Error} when the token endpoint rejects the grant.
455
471
  *
456
472
  * @example
@@ -491,6 +507,11 @@ export declare class DirectClient extends OAuth2ClientBase {
491
507
  * one is dead.
492
508
  * @throws {Error} whatever the configured `RefreshTokenStorage` threw when
493
509
  * the read itself failed.
510
+ * @throws {TemporarilyUnavailableError} when the token endpoint cannot be
511
+ * reached or its response does not arrive in full before the deadline;
512
+ * `cause` is the transport failure.
513
+ * @throws {OAuth2Error} when the token endpoint rejects the refresh for
514
+ * another reason.
494
515
  */
495
516
  refresh(): Promise<string>;
496
517
  /**
@@ -502,6 +523,9 @@ export declare class DirectClient extends OAuth2ClientBase {
502
523
  * @returns The server's device authorization response.
503
524
  * @throws {Error} when no device authorization endpoint is configured or
504
525
  * discoverable.
526
+ * @throws {TemporarilyUnavailableError} when the device authorization
527
+ * endpoint cannot be reached or its response does not arrive in full before
528
+ * the deadline; `cause` is the transport failure.
505
529
  * @throws {OAuth2Error} when the server rejects the request.
506
530
  *
507
531
  * @example
@@ -534,6 +558,9 @@ export declare class DirectClient extends OAuth2ClientBase {
534
558
  * @throws {InvalidGrantError} when `expiresAt` passes before approval. A
535
559
  * server that reports the code expired first throws `ExpiredTokenError`
536
560
  * instead.
561
+ * @throws {TemporarilyUnavailableError} when the token endpoint cannot be
562
+ * reached or its response does not arrive in full before the deadline;
563
+ * `cause` is the transport failure. Polling stops on it.
537
564
  * @throws {OAuth2Error} on any other terminal error from the server.
538
565
  */
539
566
  pollDeviceToken(deviceCode: string, options?: PollDeviceTokenOptions): Promise<TokenBundle>;
@@ -547,6 +574,9 @@ export declare class DirectClient extends OAuth2ClientBase {
547
574
  * @returns The introspection response; check `active`.
548
575
  * @throws {Error} when the client has no `clientSecret` — RFC 7662 requires
549
576
  * an authenticated caller.
577
+ * @throws {TemporarilyUnavailableError} when the introspection endpoint
578
+ * cannot be reached or its response does not arrive in full before the
579
+ * deadline; `cause` is the transport failure.
550
580
  * @throws {OAuth2Error} when the endpoint rejects the request.
551
581
  */
552
582
  introspect(token: string, options?: {
@@ -560,6 +590,9 @@ export declare class DirectClient extends OAuth2ClientBase {
560
590
  * @param options `tokenTypeHint` tells the server which kind to look up
561
591
  * first.
562
592
  * @throws {Error} when no revocation endpoint is configured or discoverable.
593
+ * @throws {TemporarilyUnavailableError} when the revocation endpoint cannot
594
+ * be reached or its response does not arrive in full before the deadline;
595
+ * `cause` is the transport failure.
563
596
  * @throws {OAuth2Error} when the endpoint rejects the request.
564
597
  */
565
598
  revoke(token: string, options?: {
@@ -573,6 +606,9 @@ export declare class DirectClient extends OAuth2ClientBase {
573
606
  * @throws {Error} when no userinfo endpoint is configured or discoverable.
574
607
  * @throws {InvalidGrantError} when no usable token is stored and there is no
575
608
  * refresh token to mint one.
609
+ * @throws {TemporarilyUnavailableError} when the token or userinfo endpoint
610
+ * cannot be reached or its response does not arrive in full before the
611
+ * deadline; `cause` is the transport failure.
576
612
  * @throws {OAuth2Error} when the refresh or the userinfo endpoint fails.
577
613
  */
578
614
  getUserInfo(): Promise<UserInfoClaims>;
@@ -721,6 +757,9 @@ export declare class DirectClient extends OAuth2ClientBase {
721
757
  * than 30 seconds left, otherwise a freshly refreshed one.
722
758
  * @throws {InvalidGrantError} when a refresh is needed and no refresh token
723
759
  * is stored, or the stored one is dead.
760
+ * @throws {TemporarilyUnavailableError} when the token endpoint cannot be
761
+ * reached or its response does not arrive in full before the deadline;
762
+ * `cause` is the transport failure.
724
763
  * @throws {OAuth2Error} when a needed refresh fails for another reason.
725
764
  */
726
765
  getAccessToken(): Promise<string>;
@@ -159,9 +159,13 @@ 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 {TemporarilyUnavailableError} the last attempt's error when both
163
+ * well-known paths fail because the server cannot be reached or its
164
+ * response does not arrive in full before the deadline; `cause` is the
165
+ * transport failure.
162
166
  * @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.
167
+ * fail otherwise: the server answers non-OK or with something other than a
168
+ * JSON object, or reports an `issuer` other than the configured one.
165
169
  *
166
170
  * @example
167
171
  * ```ts
@@ -368,6 +372,9 @@ export class DirectClient extends OAuth2ClientBase {
368
372
  *
369
373
  * @param input The callback URL, its query string, or the parsed params.
370
374
  * @returns The persisted tokens and the `returnTo` recorded at login.
375
+ * @throws {TemporarilyUnavailableError} when the token endpoint cannot be
376
+ * reached or its response does not arrive in full before the deadline;
377
+ * `cause` is the transport failure.
371
378
  * @throws {OAuth2Error} when the callback carries `?error=…`, or the token
372
379
  * exchange fails.
373
380
  * @throws {TypeError} when `code` or `state` is missing.
@@ -451,6 +458,9 @@ export class DirectClient extends OAuth2ClientBase {
451
458
  * `returnTo`, plus the raw response.
452
459
  * @throws {InvalidGrantError} when `state` is unknown or older than
453
460
  * `authRequestTtlMs`.
461
+ * @throws {TemporarilyUnavailableError} when the token endpoint cannot be
462
+ * reached or its response does not arrive in full before the deadline;
463
+ * `cause` is the transport failure.
454
464
  */
455
465
  async exchangeAuthorizationCode(code, state, options = {}) {
456
466
  const authRequestStorage = options.authRequestStorage ??
@@ -494,6 +504,9 @@ export class DirectClient extends OAuth2ClientBase {
494
504
  *
495
505
  * @param refreshToken The refresh token to redeem.
496
506
  * @returns The new tokens, the rotated refresh token, and the raw response.
507
+ * @throws {TemporarilyUnavailableError} when the token endpoint cannot be
508
+ * reached or its response does not arrive in full before the deadline;
509
+ * `cause` is the transport failure.
497
510
  * @throws {OAuth2Error} when the token endpoint rejects the grant.
498
511
  */
499
512
  async exchangeRefreshToken(refreshToken) {
@@ -515,6 +528,9 @@ export class DirectClient extends OAuth2ClientBase {
515
528
  * @returns The persisted token bundle.
516
529
  * @throws {Error} when the client has no `clientSecret` — RFC 6749 §4.4
517
530
  * restricts this grant to confidential clients.
531
+ * @throws {TemporarilyUnavailableError} when the token endpoint cannot be
532
+ * reached or its response does not arrive in full before the deadline;
533
+ * `cause` is the transport failure.
518
534
  * @throws {OAuth2Error} when the token endpoint rejects the grant.
519
535
  *
520
536
  * @example
@@ -566,6 +582,11 @@ export class DirectClient extends OAuth2ClientBase {
566
582
  * one is dead.
567
583
  * @throws {Error} whatever the configured `RefreshTokenStorage` threw when
568
584
  * the read itself failed.
585
+ * @throws {TemporarilyUnavailableError} when the token endpoint cannot be
586
+ * reached or its response does not arrive in full before the deadline;
587
+ * `cause` is the transport failure.
588
+ * @throws {OAuth2Error} when the token endpoint rejects the refresh for
589
+ * another reason.
569
590
  */
570
591
  refresh() {
571
592
  return this.#sharedRefresh(true);
@@ -642,6 +663,9 @@ export class DirectClient extends OAuth2ClientBase {
642
663
  * @returns The server's device authorization response.
643
664
  * @throws {Error} when no device authorization endpoint is configured or
644
665
  * discoverable.
666
+ * @throws {TemporarilyUnavailableError} when the device authorization
667
+ * endpoint cannot be reached or its response does not arrive in full before
668
+ * the deadline; `cause` is the transport failure.
645
669
  * @throws {OAuth2Error} when the server rejects the request.
646
670
  *
647
671
  * @example
@@ -687,6 +711,9 @@ export class DirectClient extends OAuth2ClientBase {
687
711
  * @throws {InvalidGrantError} when `expiresAt` passes before approval. A
688
712
  * server that reports the code expired first throws `ExpiredTokenError`
689
713
  * instead.
714
+ * @throws {TemporarilyUnavailableError} when the token endpoint cannot be
715
+ * reached or its response does not arrive in full before the deadline;
716
+ * `cause` is the transport failure. Polling stops on it.
690
717
  * @throws {OAuth2Error} on any other terminal error from the server.
691
718
  */
692
719
  async pollDeviceToken(deviceCode, options = {}) {
@@ -734,6 +761,9 @@ export class DirectClient extends OAuth2ClientBase {
734
761
  * @returns The introspection response; check `active`.
735
762
  * @throws {Error} when the client has no `clientSecret` — RFC 7662 requires
736
763
  * an authenticated caller.
764
+ * @throws {TemporarilyUnavailableError} when the introspection endpoint
765
+ * cannot be reached or its response does not arrive in full before the
766
+ * deadline; `cause` is the transport failure.
737
767
  * @throws {OAuth2Error} when the endpoint rejects the request.
738
768
  */
739
769
  async introspect(token, options = {}) {
@@ -760,6 +790,9 @@ export class DirectClient extends OAuth2ClientBase {
760
790
  * @param options `tokenTypeHint` tells the server which kind to look up
761
791
  * first.
762
792
  * @throws {Error} when no revocation endpoint is configured or discoverable.
793
+ * @throws {TemporarilyUnavailableError} when the revocation endpoint cannot
794
+ * be reached or its response does not arrive in full before the deadline;
795
+ * `cause` is the transport failure.
763
796
  * @throws {OAuth2Error} when the endpoint rejects the request.
764
797
  */
765
798
  async revoke(token, options = {}) {
@@ -785,6 +818,9 @@ export class DirectClient extends OAuth2ClientBase {
785
818
  * @throws {Error} when no userinfo endpoint is configured or discoverable.
786
819
  * @throws {InvalidGrantError} when no usable token is stored and there is no
787
820
  * refresh token to mint one.
821
+ * @throws {TemporarilyUnavailableError} when the token or userinfo endpoint
822
+ * cannot be reached or its response does not arrive in full before the
823
+ * deadline; `cause` is the transport failure.
788
824
  * @throws {OAuth2Error} when the refresh or the userinfo endpoint fails.
789
825
  */
790
826
  async getUserInfo() {
@@ -1053,6 +1089,9 @@ export class DirectClient extends OAuth2ClientBase {
1053
1089
  * than 30 seconds left, otherwise a freshly refreshed one.
1054
1090
  * @throws {InvalidGrantError} when a refresh is needed and no refresh token
1055
1091
  * is stored, or the stored one is dead.
1092
+ * @throws {TemporarilyUnavailableError} when the token endpoint cannot be
1093
+ * reached or its response does not arrive in full before the deadline;
1094
+ * `cause` is the transport failure.
1056
1095
  * @throws {OAuth2Error} when a needed refresh fails for another reason.
1057
1096
  */
1058
1097
  async getAccessToken() {
@@ -95,7 +95,14 @@ export interface AuthRequestStorage {
95
95
  take?(state: string): Promise<AuthRequestRecord | null> | AuthRequestRecord | null;
96
96
  /** Removes one entry — a claimed or stale one. */
97
97
  delete(state: string): Promise<void> | void;
98
- /** Removes every entry. Called when the client clears its session. */
98
+ /**
99
+ * Removes every entry. `DirectClient` calls it whenever it clears its
100
+ * session — on sign-out, and when a refresh finds the grant dead. A store
101
+ * shared between users may scope it instead, removing only expired records,
102
+ * so that one user's sign-out does not cancel another user's sign-in still
103
+ * in progress; register such a store with the contract suite's
104
+ * `clear: "scoped"`.
105
+ */
99
106
  clear(): Promise<void> | void;
100
107
  }
101
108
  /** Default in-memory {@link TokenStorage}. Data is lost when the process exits. */