@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.
- package/dist/adapters/hono/bff/bff.d.ts +14 -1
- package/dist/adapters/hono/bff/bff.js +33 -3
- package/dist/client/direct-client.d.ts +10 -0
- package/dist/client/direct-client.js +4 -0
- package/dist/client/storage.d.ts +19 -0
- 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/dist/testing/contract/auth-request-storage.d.ts +5 -0
- package/dist/testing/contract/auth-request-storage.js +13 -0
- package/package.json +1 -1
|
@@ -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.
|
|
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
|
|
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 } =
|
|
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
|
}
|
package/dist/client/storage.d.ts
CHANGED
|
@@ -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);
|
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,
|
|
@@ -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.
|
|
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",
|