@udibo/oauth2 0.31.0 → 0.32.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.
@@ -526,7 +526,20 @@ export interface HonoBffOptions {
526
526
  * `auth_time` but no particular age. A token response without an ID token
527
527
  * is not checked, unless {@link HonoBffOptions.forwardedParams} names
528
528
  * `scope`: the browser could then leave out `openid` to skip the check, so
529
- * a missing ID token fails with `login_required` too. A pinned `scope` is
529
+ * a missing ID token fails with `login_required` too.
530
+ *
531
+ * Both rules follow the sign-in, not the instance that finishes it.
532
+ * `/auth/login` records the `max_age` it sent, and whether it forwarded
533
+ * `scope`, on the pending authorization request
534
+ * ({@link AuthRequestRecord.maxAge}). `/auth/callback` checks against that
535
+ * record, so a login-only instance that pins `max_age` for a step-up route
536
+ * is enforced by a callback another instance serves. An instance that pins
537
+ * `max_age` does not check a sign-in that carried none. A record without
538
+ * the field, written by an earlier release or kept by an
539
+ * {@link HonoBffOptions.authRequestStorage} that drops fields it does not
540
+ * know, is checked against the callback instance's own options instead.
541
+ *
542
+ * A pinned `scope` is
530
543
  * accepted only when
531
544
  * {@link scope} is unset; setting both would leave which one applies
532
545
  * ambiguous. Either configuration supplies the scope the login flow records
@@ -168,6 +168,30 @@ function pinnedMaxAge(fixed) {
168
168
  }
169
169
  return strictest;
170
170
  }
171
+ function recordingAuthenticationControls(storage, controls) {
172
+ return {
173
+ get: storage.get.bind(storage),
174
+ set: (state, value) => storage.set(state, { ...value, ...controls }),
175
+ delete: storage.delete.bind(storage),
176
+ clear: storage.clear.bind(storage),
177
+ };
178
+ }
179
+ function recordedAuthenticationControls(recorded) {
180
+ const { maxAge } = recorded;
181
+ const scopeForwarded = recorded.scopeForwarded !== false;
182
+ if (maxAge === null)
183
+ return { maxAge: undefined, scopeForwarded };
184
+ if (typeof maxAge === "number" &&
185
+ Number.isSafeInteger(maxAge) &&
186
+ maxAge >= 0) {
187
+ return { maxAge, scopeForwarded };
188
+ }
189
+ throw new CallbackRefusal({
190
+ error: "login_required",
191
+ error_description: "the pending authorization request recorded a max_age that is not a " +
192
+ "non-negative integer, so the ID token's auth_time could not be checked",
193
+ });
194
+ }
171
195
  class CallbackRefusal extends Error {
172
196
  refusal;
173
197
  constructor(refusal) {
@@ -724,7 +748,10 @@ export class HonoBff {
724
748
  prompt: requested.prompt,
725
749
  extraParams: requested.extraParams,
726
750
  redirectUri: this.#derivedRedirectUri(c, origin),
727
- authRequestStorage: this.#authRequestStorage(c),
751
+ authRequestStorage: recordingAuthenticationControls(this.#authRequestStorage(c), {
752
+ maxAge: this.#extraAuthorizeParams.maxAge ?? null,
753
+ scopeForwarded: this.#extraAuthorizeParams.scopeForwarded,
754
+ }),
728
755
  });
729
756
  this.#writeLoginStateHashes(c, [
730
757
  ...this.#loginStateHashes(c),
@@ -776,15 +803,18 @@ export class HonoBff {
776
803
  this.#writeLoginStateHashes(c, pendingStates.filter((_, index) => index !== matched));
777
804
  try {
778
805
  const origin = this.#options.resolveOrigin?.(c);
779
- const { tokens, refreshToken, returnTo, raw } = await this.#client.exchangeAuthorizationCode(code, state, {
806
+ const exchanged = await this.#client.exchangeAuthorizationCode(code, state, {
780
807
  origin,
781
808
  redirectUri: this.#derivedRedirectUri(c, origin),
782
809
  authRequestStorage: this.#authRequestStorage(c),
783
810
  });
811
+ const { tokens, refreshToken, returnTo, raw } = exchanged;
784
812
  let user = null;
785
813
  let sid;
786
814
  const idToken = raw.id_token;
787
- const { maxAge, scopeForwarded } = this.#extraAuthorizeParams;
815
+ const { maxAge, scopeForwarded } = exchanged.maxAge === undefined
816
+ ? this.#extraAuthorizeParams
817
+ : recordedAuthenticationControls(exchanged);
788
818
  if (maxAge !== undefined && (idToken || scopeForwarded)) {
789
819
  let claims;
790
820
  try {
@@ -210,6 +210,16 @@ export interface ExchangeResult {
210
210
  refreshToken?: string;
211
211
  /** `returnTo` recorded at authorize time, for the code exchange. */
212
212
  returnTo?: string;
213
+ /**
214
+ * {@link AuthRequestRecord.maxAge} as recorded at authorize time, for the
215
+ * code exchange. Absent when the record carries none.
216
+ */
217
+ maxAge?: number | null;
218
+ /**
219
+ * {@link AuthRequestRecord.scopeForwarded} as recorded at authorize time,
220
+ * for the code exchange. Absent when the record carries none.
221
+ */
222
+ scopeForwarded?: boolean;
213
223
  /** The raw token-endpoint response. */
214
224
  raw: TokenResponse;
215
225
  }
@@ -565,6 +565,10 @@ export class DirectClient extends OAuth2ClientBase {
565
565
  tokens: responseToBundle(raw),
566
566
  refreshToken: raw.refresh_token,
567
567
  returnTo: record.returnTo,
568
+ ...(record.maxAge === undefined ? {} : { maxAge: record.maxAge }),
569
+ ...(record.scopeForwarded === undefined
570
+ ? {}
571
+ : { scopeForwarded: record.scopeForwarded }),
568
572
  raw,
569
573
  };
570
574
  }
@@ -64,6 +64,25 @@ export interface AuthRequestRecord {
64
64
  returnTo?: string;
65
65
  /** Scope requested for this authorization, space-delimited. */
66
66
  scope?: string;
67
+ /**
68
+ * The `max_age` the authorize request carried, in seconds, or `null` when
69
+ * it carried none. `HonoBff` records it at `/auth/login` and checks the ID
70
+ * token's `auth_time` against it at `/auth/callback`, whichever instance
71
+ * serves either one. Absent on a record from a release that did not record
72
+ * it; the callback then falls back to its own pinned `max_age`. A storage
73
+ * must return `null` as `null`, not as an absent field, and a number as a
74
+ * number: the callback refuses any other value with `login_required`.
75
+ */
76
+ maxAge?: number | null;
77
+ /**
78
+ * Whether the browser chose this request's `scope` (`HonoBff`'s
79
+ * `forwardedParams` names `scope`). When it did and {@link maxAge} is set,
80
+ * the callback refuses a token response without an ID token, because a
81
+ * scope without `openid` would otherwise skip the `auth_time` check.
82
+ * Recorded alongside {@link maxAge}; the callback reads any value other than
83
+ * `false` as `true`.
84
+ */
85
+ scopeForwarded?: boolean;
67
86
  /** When the request was created (epoch ms), used for TTL expiry. */
68
87
  createdAt: number;
69
88
  }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Not part of the public API. Lets `useOAuth2().logout()` tell
3
+ * `<RequireAuth>` that a sign-out is under way, so the `logged_out` event the
4
+ * client emits before the browser leaves does not start a login over the
5
+ * logout navigation.
6
+ *
7
+ * @module
8
+ */
9
+ import { type Context } from "react";
10
+ /** The provider's view of `useOAuth2().logout()` calls. */
11
+ export interface LogoutTracker {
12
+ /**
13
+ * True from the start of a logout until it settles without navigating. A
14
+ * logout that navigates away stays in flight until the page is restored
15
+ * from the back-forward cache.
16
+ */
17
+ inFlight: boolean;
18
+ /** Marks a logout as started. Call before `client.logout()`. */
19
+ start(): void;
20
+ /** Marks a started logout as finished without leaving the page. */
21
+ settle(): void;
22
+ }
23
+ /** Supplied by `<OAuth2Provider>`; `null` under any other provider. */
24
+ export declare const LogoutTrackerContext: Context<LogoutTracker | null>;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Not part of the public API. Lets `useOAuth2().logout()` tell
3
+ * `<RequireAuth>` that a sign-out is under way, so the `logged_out` event the
4
+ * client emits before the browser leaves does not start a login over the
5
+ * logout navigation.
6
+ *
7
+ * @module
8
+ */
9
+ import { createContext } from "react";
10
+ /** Supplied by `<OAuth2Provider>`; `null` under any other provider. */
11
+ export const LogoutTrackerContext = createContext(null);
@@ -32,7 +32,12 @@ export interface OAuth2ProviderProps {
32
32
  * state via `useOAuth2()`.
33
33
  *
34
34
  * While authenticated it renews the session on a timer shortly before the
35
- * access token expires, re-arming after each renew. Renewal is per tab and
35
+ * access token expires (at least 5 seconds apart), re-arming after each
36
+ * renew. A renew that fails, or reports no later expiry than the one before,
37
+ * as a `BffClient` session does (`sessionExpiresIn: 0`) until a proxied call
38
+ * refreshes the token, backs off instead: the wait doubles after each one, up
39
+ * to one renew every 5 minutes. A renew or client event that reports a later
40
+ * expiry returns it to the pre-expiry schedule. Renewal is per tab and
36
41
  * uncoordinated: a `DirectClient` given a refresh-token storage shared across
37
42
  * tabs (e.g. `IndexedDBRefreshTokenStorage`) against a server that
38
43
  * rotates refresh tokens can still have two tabs redeem the same token, which
@@ -6,8 +6,9 @@ import { jsx as _jsx } from "react/jsx-runtime";
6
6
  *
7
7
  * @module
8
8
  */
9
- import { useEffect, useMemo, useState } from "react";
9
+ import { useCallback, useEffect, useMemo, useRef, useState, } from "react";
10
10
  import { toOAuth2Error } from "../errors.js";
11
+ import { LogoutTrackerContext } from "./_logout.js";
11
12
  import { OAuth2Context, } from "./context.js";
12
13
  const DEFAULT_STATE = {
13
14
  isAuthenticated: false,
@@ -19,10 +20,25 @@ const DEFAULT_STATE = {
19
20
  };
20
21
  const RENEW_SKEW_SECONDS = 30;
21
22
  const MIN_RENEW_MS = 5_000;
23
+ const MAX_RENEW_BACKOFF_MS = 300_000;
22
24
  const RENEW_JITTER = 0.1;
23
25
  const isBrowser = () => typeof window !== "undefined";
24
- function renewDelayMs(expiresIn) {
25
- const base = Math.max(MIN_RENEW_MS, (expiresIn - RENEW_SKEW_SECONDS) * 1000);
26
+ const EXPIRY_ROUNDING_MS = 1_000;
27
+ function reportExpiry(expiresIn) {
28
+ return expiresIn == null ? null : { expiresIn, at: Date.now() };
29
+ }
30
+ function isLaterExpiry(next, prev) {
31
+ if (next == null || next.expiresIn <= 0)
32
+ return false;
33
+ if (prev == null)
34
+ return true;
35
+ return (next.at + next.expiresIn * 1000 >
36
+ prev.at + prev.expiresIn * 1000 + EXPIRY_ROUNDING_MS);
37
+ }
38
+ function renewDelayMs(expiresIn, staleRenewals) {
39
+ const base = staleRenewals === 0
40
+ ? Math.max(MIN_RENEW_MS, (expiresIn - RENEW_SKEW_SECONDS) * 1000)
41
+ : Math.min(MAX_RENEW_BACKOFF_MS, MIN_RENEW_MS * 2 ** staleRenewals);
26
42
  return base * (1 - RENEW_JITTER * Math.random());
27
43
  }
28
44
  function expiresInFrom(tokens) {
@@ -35,7 +51,12 @@ function expiresInFrom(tokens) {
35
51
  * state via `useOAuth2()`.
36
52
  *
37
53
  * While authenticated it renews the session on a timer shortly before the
38
- * access token expires, re-arming after each renew. Renewal is per tab and
54
+ * access token expires (at least 5 seconds apart), re-arming after each
55
+ * renew. A renew that fails, or reports no later expiry than the one before,
56
+ * as a `BffClient` session does (`sessionExpiresIn: 0`) until a proxied call
57
+ * refreshes the token, backs off instead: the wait doubles after each one, up
58
+ * to one renew every 5 minutes. A renew or client event that reports a later
59
+ * expiry returns it to the pre-expiry schedule. Renewal is per tab and
39
60
  * uncoordinated: a `DirectClient` given a refresh-token storage shared across
40
61
  * tabs (e.g. `IndexedDBRefreshTokenStorage`) against a server that
41
62
  * rotates refresh tokens can still have two tabs redeem the same token, which
@@ -49,6 +70,9 @@ export function OAuth2Provider(props) {
49
70
  ...initialState,
50
71
  }));
51
72
  const [renewCount, setRenewCount] = useState(0);
73
+ const staleRenewals = useRef(0);
74
+ const reportedExpiry = useRef(null);
75
+ const [logoutsInFlight, setLogoutsInFlight] = useState(0);
52
76
  useEffect(() => {
53
77
  let cancelled = false;
54
78
  const handle = (event) => {
@@ -114,23 +138,40 @@ export function OAuth2Provider(props) {
114
138
  useEffect(() => {
115
139
  if (!isBrowser())
116
140
  return;
117
- if (!state.isAuthenticated)
141
+ if (!state.isAuthenticated) {
142
+ staleRenewals.current = 0;
143
+ reportedExpiry.current = null;
118
144
  return;
145
+ }
119
146
  const expiresIn = state.sessionExpiresIn;
120
147
  if (expiresIn == null || !Number.isFinite(expiresIn) || expiresIn < 0) {
121
148
  return;
122
149
  }
150
+ if (reportedExpiry.current?.expiresIn !== expiresIn) {
151
+ const reported = reportExpiry(expiresIn);
152
+ if (isLaterExpiry(reported, reportedExpiry.current)) {
153
+ staleRenewals.current = 0;
154
+ }
155
+ reportedExpiry.current = reported;
156
+ }
123
157
  let cancelled = false;
124
- const delayMs = renewDelayMs(expiresIn);
158
+ const delayMs = renewDelayMs(expiresIn, staleRenewals.current);
125
159
  const timer = setTimeout(() => {
126
160
  client.renewSession().then((session) => {
127
161
  if (cancelled)
128
162
  return;
163
+ const reported = reportExpiry(session.sessionExpiresIn);
164
+ staleRenewals.current = isLaterExpiry(reported, reportedExpiry.current)
165
+ ? 0
166
+ : staleRenewals.current + 1;
167
+ reportedExpiry.current = reported;
129
168
  setState((prev) => ({ ...prev, ...session }));
130
169
  setRenewCount((count) => count + 1);
131
170
  }, () => {
132
- if (!cancelled)
133
- setRenewCount((count) => count + 1);
171
+ if (cancelled)
172
+ return;
173
+ staleRenewals.current += 1;
174
+ setRenewCount((count) => count + 1);
134
175
  });
135
176
  }, delayMs);
136
177
  return () => {
@@ -138,11 +179,28 @@ export function OAuth2Provider(props) {
138
179
  clearTimeout(timer);
139
180
  };
140
181
  }, [client, state.isAuthenticated, state.sessionExpiresIn, renewCount]);
182
+ useEffect(() => {
183
+ if (!isBrowser())
184
+ return;
185
+ const onPageShow = (event) => {
186
+ if (event.persisted)
187
+ setLogoutsInFlight(0);
188
+ };
189
+ window.addEventListener("pageshow", onPageShow);
190
+ return () => window.removeEventListener("pageshow", onPageShow);
191
+ }, []);
141
192
  const value = useMemo(() => ({
142
193
  client,
143
194
  ...state,
144
195
  }), [client, state]);
145
- return (_jsx(OAuth2Context.Provider, { value: value, children: children }));
196
+ const startLogout = useCallback(() => setLogoutsInFlight((count) => count + 1), []);
197
+ const settleLogout = useCallback(() => setLogoutsInFlight((count) => Math.max(0, count - 1)), []);
198
+ const logoutTracker = useMemo(() => ({
199
+ inFlight: logoutsInFlight > 0,
200
+ start: startLogout,
201
+ settle: settleLogout,
202
+ }), [logoutsInFlight, startLogout, settleLogout]);
203
+ return (_jsx(OAuth2Context.Provider, { value: value, children: _jsx(LogoutTrackerContext.Provider, { value: logoutTracker, children: children }) }));
146
204
  }
147
205
  function reduce(prev, event) {
148
206
  switch (event.type) {
@@ -5,6 +5,12 @@
5
5
  * `returnTo` set to the current path, and renders a configurable
6
6
  * `fallback` while the redirect is in flight.
7
7
  *
8
+ * It starts no login while a `logout()` from {@link useOAuth2} is running or
9
+ * navigating away, so signing out from a protected page ends at the logout
10
+ * destination rather than the login page. A logout that returns no URL (a
11
+ * `DirectClient` whose server has no end-session endpoint) leaves the page
12
+ * signed out, and the gate then starts a login as usual.
13
+ *
8
14
  * @module
9
15
  */
10
16
  import { type ReactNode } from "react";
@@ -23,6 +29,9 @@ export interface RequireAuthProps {
23
29
  /**
24
30
  * Render `children` only when authenticated. Otherwise call
25
31
  * `login({ returnTo })` once and render `fallback` while the redirect
26
- * is in flight.
32
+ * is in flight. While a `useOAuth2().logout()` is running or navigating away,
33
+ * no new login starts; what renders still follows `isLoading` and
34
+ * `isAuthenticated`, so the page shows `fallback` once the logout has signed
35
+ * it out.
27
36
  */
28
37
  export declare function RequireAuth(props: RequireAuthProps): ReactNode;
@@ -5,27 +5,38 @@
5
5
  * `returnTo` set to the current path, and renders a configurable
6
6
  * `fallback` while the redirect is in flight.
7
7
  *
8
+ * It starts no login while a `logout()` from {@link useOAuth2} is running or
9
+ * navigating away, so signing out from a protected page ends at the logout
10
+ * destination rather than the login page. A logout that returns no URL (a
11
+ * `DirectClient` whose server has no end-session endpoint) leaves the page
12
+ * signed out, and the gate then starts a login as usual.
13
+ *
8
14
  * @module
9
15
  */
10
- import { useEffect } from "react";
16
+ import { useContext, useEffect } from "react";
17
+ import { LogoutTrackerContext } from "./_logout.js";
11
18
  import { useOAuth2 } from "./use-oauth2.js";
12
19
  const isBrowser = () => typeof window !== "undefined";
13
20
  /**
14
21
  * Render `children` only when authenticated. Otherwise call
15
22
  * `login({ returnTo })` once and render `fallback` while the redirect
16
- * is in flight.
23
+ * is in flight. While a `useOAuth2().logout()` is running or navigating away,
24
+ * no new login starts; what renders still follows `isLoading` and
25
+ * `isAuthenticated`, so the page shows `fallback` once the logout has signed
26
+ * it out.
17
27
  */
18
28
  export function RequireAuth(props) {
19
29
  const { children, fallback = null, returnTo } = props;
20
30
  const { isAuthenticated, isLoading, login } = useOAuth2();
31
+ const logoutInFlight = useContext(LogoutTrackerContext)?.inFlight ?? false;
21
32
  useEffect(() => {
22
- if (isLoading || isAuthenticated)
33
+ if (isLoading || isAuthenticated || logoutInFlight)
23
34
  return;
24
35
  if (!isBrowser())
25
36
  return;
26
37
  const target = returnTo ?? `${window.location.pathname}${window.location.search}`;
27
38
  login({ returnTo: target }).catch(() => { });
28
- }, [isLoading, isAuthenticated, login, returnTo]);
39
+ }, [isLoading, isAuthenticated, logoutInFlight, login, returnTo]);
29
40
  if (isLoading || !isAuthenticated)
30
41
  return fallback;
31
42
  return children;
@@ -42,6 +42,11 @@ export interface UseOAuth2Result {
42
42
  * navigates to the returned URL if the flow needs a redirect (the BFF
43
43
  * logout endpoint, or the IDP end-session URL). Returns `{ url }` for
44
44
  * SSR callers.
45
+ *
46
+ * Under an `<OAuth2Provider>`, a mounted {@link RequireAuth} starts no
47
+ * login while this runs or navigates, so signing out from a protected page
48
+ * ends at the logout destination. Calling `client.logout()` directly skips
49
+ * that guard.
45
50
  */
46
51
  logout(opts?: LogoutOptions): Promise<{
47
52
  url?: string;
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { useCallback, useContext } from "react";
9
9
  import { BffClient, DirectClient, } from "../client/mod.js";
10
+ import { LogoutTrackerContext } from "./_logout.js";
10
11
  import { OAuth2Context } from "./context.js";
11
12
  function useOAuth2Context() {
12
13
  const context = useContext(OAuth2Context);
@@ -46,13 +47,27 @@ export function useOAuth2() {
46
47
  }
47
48
  return result;
48
49
  }, [client]);
50
+ const logoutTracker = useContext(LogoutTrackerContext);
51
+ const startLogout = logoutTracker?.start;
52
+ const settleLogout = logoutTracker?.settle;
49
53
  const logout = useCallback(async (opts) => {
50
- const result = await client.logout(opts);
54
+ startLogout?.();
55
+ let result;
56
+ try {
57
+ result = await client.logout(opts);
58
+ }
59
+ catch (error) {
60
+ settleLogout?.();
61
+ throw error;
62
+ }
51
63
  if (result.url && typeof window !== "undefined") {
52
64
  window.location.assign(result.url);
53
65
  }
66
+ else {
67
+ settleLogout?.();
68
+ }
54
69
  return result;
55
- }, [client]);
70
+ }, [client, startLogout, settleLogout]);
56
71
  const boundFetch = useCallback((input, init) => client.fetch(input, init), [client]);
57
72
  return {
58
73
  isAuthenticated: context.isAuthenticated,
@@ -9,6 +9,11 @@
9
9
  * concurrent callers taking one record. This suite races eight of them on one
10
10
  * record.
11
11
  *
12
+ * Every field of a record must come back as it was stored, `maxAge: null`
13
+ * included: a store that keeps only the fields it knows makes `HonoBff` check
14
+ * a sign-in against the callback instance's options instead of the
15
+ * `max_age` the sign-in was started with.
16
+ *
12
17
  * @example Verify a Redis-backed store
13
18
  * ```ts
14
19
  * import { runAuthRequestStorageContractTests } from "@udibo/oauth2/testing/contract";
@@ -9,6 +9,11 @@
9
9
  * concurrent callers taking one record. This suite races eight of them on one
10
10
  * record.
11
11
  *
12
+ * Every field of a record must come back as it was stored, `maxAge: null`
13
+ * included: a store that keeps only the fields it knows makes `HonoBff` check
14
+ * a sign-in against the callback instance's options instead of the
15
+ * `max_age` the sign-in was started with.
16
+ *
12
17
  * @example Verify a Redis-backed store
13
18
  * ```ts
14
19
  * import { runAuthRequestStorageContractTests } from "@udibo/oauth2/testing/contract";
@@ -45,6 +50,8 @@ function record(overrides = {}) {
45
50
  codeVerifier: "verifier-1",
46
51
  returnTo: "/dashboard",
47
52
  scope: "openid profile",
53
+ maxAge: 300,
54
+ scopeForwarded: true,
48
55
  createdAt: Date.now(),
49
56
  ...overrides,
50
57
  };
@@ -75,6 +82,12 @@ export function runAuthRequestStorageContractTests(options) {
75
82
  await store.set("state-1", stored);
76
83
  expect(await store.get("state-1")).toStrictEqual(stored);
77
84
  });
85
+ it("returns a recorded maxAge of null as null, not as an absent field", async () => {
86
+ const stored = record({ maxAge: null, scopeForwarded: false });
87
+ await store.set("state-1", stored);
88
+ expect(await store.get("state-1"), "null records a sign-in that carried no max_age; an absent field " +
89
+ "makes the callback fall back to its own pinned max_age").toStrictEqual(stored);
90
+ });
78
91
  it("keeps records under different states independent", async () => {
79
92
  await store.set("state-1", record({ codeVerifier: "verifier-1" }));
80
93
  await store.set("state-2", record({ codeVerifier: "verifier-2" }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@udibo/oauth2",
3
- "version": "0.31.0",
3
+ "version": "0.32.1",
4
4
  "description": "A flexible OAuth2 toolkit: framework-agnostic authorization & resource servers, a typed client, Hono adapters (including a backend-for-frontend), and React bindings.",
5
5
  "keywords": [
6
6
  "authorization-server",