@udibo/oauth2 0.32.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.
- package/dist/react/_logout.d.ts +24 -0
- package/dist/react/_logout.js +11 -0
- package/dist/react/provider.d.ts +6 -1
- package/dist/react/provider.js +67 -9
- package/dist/react/require-auth.d.ts +10 -1
- package/dist/react/require-auth.js +15 -4
- package/dist/react/use-oauth2.d.ts +5 -0
- package/dist/react/use-oauth2.js +17 -2
- package/package.json +1 -1
|
@@ -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);
|
package/dist/react/provider.d.ts
CHANGED
|
@@ -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
|
|
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
|
package/dist/react/provider.js
CHANGED
|
@@ -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
|
-
|
|
25
|
-
|
|
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
|
|
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 (
|
|
133
|
-
|
|
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
|
-
|
|
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;
|
package/dist/react/use-oauth2.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@udibo/oauth2",
|
|
3
|
-
"version": "0.32.
|
|
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",
|