@udibo/oauth2 0.32.0 → 0.33.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.
@@ -109,7 +109,8 @@ export interface RefreshLockStore {
109
109
  * longer overwrite or end the session that instance refreshed. A store
110
110
  * that calls `refresh()` with no lease still works, but a lock that
111
111
  * expires mid-refresh then lets the former holder's write or sign-out
112
- * land on top of the new holder's.
112
+ * land on top of the new holder's. `runSessionStoreContractTests` fails
113
+ * such a store unless its options declare that the lock never expires.
113
114
  * - Reject when the lock cannot be acquired within a bound you choose.
114
115
  * `HonoBff` then answers `502` `temporarily_unavailable` and keeps the
115
116
  * session.
@@ -165,6 +165,20 @@ export interface SessionStoreContractOptions {
165
165
  * supplying this factory.
166
166
  */
167
167
  makeBoundedStore?(maxAgeMs: number): Promise<SessionStore> | SessionStore;
168
+ /**
169
+ * Set to `true` when the store's refresh lock never expires, so a holder
170
+ * keeps it until its `refresh` settles, as `MemorySessionStore` does by
171
+ * default. The suite then skips the lease checks for a store whose
172
+ * `withRefreshLock` passes no {@link RefreshLease}, instead of failing it.
173
+ *
174
+ * Without a lease, nothing stops a holder that lost an expiring lock from
175
+ * writing or destroying the session, and the suite cannot tell a lock that
176
+ * never expires from one that does. Declare it only when no holder can lose
177
+ * the lock while its refresh runs, as when the lock lives in the one process
178
+ * that runs every refresh. Then a holder that hangs holds the session's
179
+ * refresh until it settles. Defaults to `false`.
180
+ */
181
+ refreshLockNeverExpires?: boolean;
168
182
  }
169
183
  /**
170
184
  * Contract test suite for {@link SessionStore} implementations. Registers
@@ -175,12 +189,13 @@ export interface SessionStoreContractOptions {
175
189
  *
176
190
  * A store that implements `withRefreshLock` ({@link RefreshLockStore}) also
177
191
  * runs the lock checks; they are skipped for a store without it. The lease
178
- * checks ({@link RefreshLease}) are skipped for a store whose
179
- * `withRefreshLock` passes no lease. They prove that a lease stops acting once
180
- * its call releases the lock or another call holds it, not that it stops
181
- * when the lock expires: expire a lease in a test of your own for that. They
182
- * use one store object, so they prove the lock's behavior, not that separate
183
- * instances share it: point two store objects at one backend in a test of
184
- * your own for that.
192
+ * checks ({@link RefreshLease}) fail a store whose `withRefreshLock` passes no
193
+ * lease, unless the options declare that its lock never expires
194
+ * ({@linkcode SessionStoreContractOptions.refreshLockNeverExpires}). They
195
+ * prove that a lease stops acting once its call releases the lock or another
196
+ * call holds it, not that it stops when the lock expires: expire a lease in a
197
+ * test of your own for that. They use one store object, so they prove the
198
+ * lock's behavior, not that separate instances share it: point two store
199
+ * objects at one backend in a test of your own for that.
185
200
  */
186
201
  export declare function runSessionStoreContractTests(options: SessionStoreContractOptions): void;
@@ -204,17 +204,19 @@ async function rejection(promise) {
204
204
  *
205
205
  * A store that implements `withRefreshLock` ({@link RefreshLockStore}) also
206
206
  * runs the lock checks; they are skipped for a store without it. The lease
207
- * checks ({@link RefreshLease}) are skipped for a store whose
208
- * `withRefreshLock` passes no lease. They prove that a lease stops acting once
209
- * its call releases the lock or another call holds it, not that it stops
210
- * when the lock expires: expire a lease in a test of your own for that. They
211
- * use one store object, so they prove the lock's behavior, not that separate
212
- * instances share it: point two store objects at one backend in a test of
213
- * your own for that.
207
+ * checks ({@link RefreshLease}) fail a store whose `withRefreshLock` passes no
208
+ * lease, unless the options declare that its lock never expires
209
+ * ({@linkcode SessionStoreContractOptions.refreshLockNeverExpires}). They
210
+ * prove that a lease stops acting once its call releases the lock or another
211
+ * call holds it, not that it stops when the lock expires: expire a lease in a
212
+ * test of your own for that. They use one store object, so they prove the
213
+ * lock's behavior, not that separate instances share it: point two store
214
+ * objects at one backend in a test of your own for that.
214
215
  */
215
216
  export function runSessionStoreContractTests(options) {
216
217
  const destroyClearsRead = options.destroyClearsRead ?? true;
217
218
  const makeBoundedStore = options.makeBoundedStore;
219
+ const refreshLockNeverExpires = options.refreshLockNeverExpires ?? false;
218
220
  describe(options.describeName ?? "SessionStore contract", () => {
219
221
  let store;
220
222
  beforeEach(async () => {
@@ -375,9 +377,13 @@ export function runSessionStoreContractTests(options) {
375
377
  });
376
378
  async function leaseOf(locking, value, skip) {
377
379
  const lease = await locking.withRefreshLock(value, (given) => Promise.resolve(given));
378
- skip(lease === undefined, "withRefreshLock passes the refresh no lease, so a holder that lost " +
379
- "the lock can still write and destroy the session");
380
- return required(lease);
380
+ skip(lease === undefined && refreshLockNeverExpires, "withRefreshLock passes the refresh no lease, and the store declares " +
381
+ "that its lock never expires");
382
+ assert(lease !== undefined, "withRefreshLock passed the refresh no lease, so a holder that " +
383
+ "stalls past an expiring lock can still write and destroy the " +
384
+ "session another holder refreshed. Pass a RefreshLease, or set " +
385
+ "refreshLockNeverExpires: true if the lock never expires.");
386
+ return lease;
381
387
  }
382
388
  function refreshedSession() {
383
389
  return {
@@ -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,
@@ -82,6 +82,13 @@ export interface TokenRotationContractOptions<C extends ClientInterface, U exten
82
82
  * longer to reach the lock they wait on.
83
83
  */
84
84
  blockedAfterMs?: number;
85
+ /**
86
+ * Returns a new rotation-family id the store accepts as a
87
+ * {@linkcode RefreshToken.familyId}, distinct on every call. Each case calls
88
+ * it for the two families it uses. Set it when the store keeps family ids in
89
+ * a format other than a UUID. Defaults to `crypto.randomUUID()`.
90
+ */
91
+ makeFamilyId?(): string;
85
92
  /** Overrides the name passed to the outer `describe` block. */
86
93
  describeName?: string;
87
94
  }
@@ -55,6 +55,7 @@ export function runTokenRotationContractTests(options) {
55
55
  const makeClient = options.makeClient ?? defaultMakeClient;
56
56
  const pausable = options.rotateAwaitsBarrier ?? true;
57
57
  const blockedAfterMs = options.blockedAfterMs ?? 100;
58
+ const makeFamilyId = options.makeFamilyId ?? (() => crypto.randomUUID());
58
59
  describe(options.describeName ?? "TokenServiceInterface.rotate contract", () => {
59
60
  let clientService;
60
61
  let tokenService;
@@ -75,8 +76,8 @@ export function runTokenRotationContractTests(options) {
75
76
  };
76
77
  beforeEach(async () => {
77
78
  armed = undefined;
78
- family = crypto.randomUUID();
79
- otherFamily = crypto.randomUUID();
79
+ family = makeFamilyId();
80
+ otherFamily = makeFamilyId();
80
81
  const services = await options.makeServices(barriers);
81
82
  clientService = services.clientService;
82
83
  tokenService = services.tokenService;
@@ -46,6 +46,25 @@ export interface TokenServiceContractOptions<C extends ClientInterface, U extend
46
46
  makeClient?(seq: number): C;
47
47
  /** Builds a scope from its string value. Defaults to a {@linkcode BasicScope}. */
48
48
  makeScope?(value: string): S;
49
+ /**
50
+ * A scope value the service accepts for the fixture client. The
51
+ * `acceptedScope` case with a user requests it and expects it back
52
+ * unchanged; the case with no user requests it and expects it not to be
53
+ * refused. Set it when `acceptedScope` refuses scopes outside a fixed
54
+ * vocabulary, for example `"openid"`. Defaults to `"read"`.
55
+ */
56
+ supportedScope?: string;
57
+ /**
58
+ * Returns a new rotation-family id the store accepts as a
59
+ * {@linkcode RefreshToken.familyId}, distinct on every call. The family cases
60
+ * call it for each family they create and for a family that was never
61
+ * issued, so each case sees only its own families. Users, clients and the
62
+ * authorization-code and unknown-token probes keep fixed values, so a store
63
+ * that keeps state across cases still needs `makeServices` to isolate those.
64
+ * Set it when the store keeps family ids in a format other than a UUID.
65
+ * Defaults to `crypto.randomUUID()`.
66
+ */
67
+ makeFamilyId?(): string;
49
68
  /**
50
69
  * Set to `false` when the service deliberately omits the optional
51
70
  * reuse-detection pair, {@linkcode TokenServiceInterface.getRevokedRefreshToken}
@@ -26,11 +26,14 @@ const defaultMakeClient = (seq) => ({
26
26
  grants: ["authorization_code", "refresh_token"],
27
27
  });
28
28
  const defaultMakeScope = (value) => new BasicScope(value);
29
+ const defaultMakeFamilyId = () => crypto.randomUUID();
29
30
  /** Runs the {@link TokenServiceInterface} contract suite. */
30
31
  export function runTokenServiceContractTests(options) {
31
32
  const makeUser = options.makeUser ?? defaultMakeUser;
32
33
  const makeClient = options.makeClient ?? defaultMakeClient;
33
34
  const makeScope = options.makeScope ?? defaultMakeScope;
35
+ const supportedScope = options.supportedScope ?? "read";
36
+ const makeFamilyId = options.makeFamilyId ?? defaultMakeFamilyId;
34
37
  const expectReuseDetection = options.refreshTokenReuseDetection ?? true;
35
38
  describe(options.describeName ?? "TokenServiceInterface contract", () => {
36
39
  let userService;
@@ -66,9 +69,9 @@ export function runTokenServiceContractTests(options) {
66
69
  });
67
70
  describe("acceptedScope", () => {
68
71
  it("returns the requested scope unchanged for the simple case", async () => {
69
- const requested = makeScope("read");
72
+ const requested = makeScope(supportedScope);
70
73
  const result = await tokenService.acceptedScope(client, user, requested);
71
- expect(result?.toString()).toStrictEqual("read");
74
+ expect(result?.toString()).toStrictEqual(supportedScope);
72
75
  });
73
76
  });
74
77
  describe("save + getToken", () => {
@@ -97,7 +100,7 @@ export function runTokenServiceContractTests(options) {
97
100
  expect(typeof accessToken).toStrictEqual("string");
98
101
  });
99
102
  it("accepts scope for a client with no user", async () => {
100
- const result = await tokenService.acceptedScope(client, undefined, makeScope("read"));
103
+ const result = await tokenService.acceptedScope(client, undefined, makeScope(supportedScope));
101
104
  assert(result !== false, "scope must not be rejected for lack of a user");
102
105
  });
103
106
  it("dates the expiry of an access token with no user", async () => {
@@ -214,8 +217,12 @@ export function runTokenServiceContractTests(options) {
214
217
  });
215
218
  if (expectReuseDetection) {
216
219
  describe("refresh-token rotation family", () => {
217
- const FAMILY = "family-1";
218
- const OTHER_FAMILY = "family-2";
220
+ let family;
221
+ let otherFamily;
222
+ beforeEach(() => {
223
+ family = makeFamilyId();
224
+ otherFamily = makeFamilyId();
225
+ });
219
226
  async function familyMember(familyId, refreshTokenExpiresAt = new Date(Date.now() + 600_000)) {
220
227
  const accessToken = await tokenService.generateAccessToken(client, user);
221
228
  const refreshToken = (await tokenService.generateRefreshToken(client, user));
@@ -244,16 +251,16 @@ export function runTokenServiceContractTests(options) {
244
251
  "`refreshTokenReuseDetection: false` to record the gap.");
245
252
  });
246
253
  it("keeps a rotated-out refresh token findable, with its family", async () => {
247
- const token = await saveFamilyMember(FAMILY);
254
+ const token = await saveFamilyMember(family);
248
255
  await tokenService.revoke(token.refreshToken, "refresh_token");
249
256
  expect(await tokenService.getRefreshToken(token.refreshToken), "a rotated-out token must no longer refresh").toBe(undefined);
250
257
  const revoked = await tokenService.getRevokedRefreshToken(token.refreshToken);
251
258
  assert(revoked !== undefined, "a revoked refresh token must stay findable — deleting the row " +
252
259
  "is what turns a replay into an ordinary unknown token");
253
- expect(revoked.familyId).toBe(FAMILY);
260
+ expect(revoked.familyId).toBe(family);
254
261
  });
255
262
  it("does not report a live refresh token as revoked", async () => {
256
- const token = await saveFamilyMember(FAMILY);
263
+ const token = await saveFamilyMember(family);
257
264
  expect(await tokenService.getRevokedRefreshToken(token.refreshToken), "reporting a live token as revoked turns every legitimate " +
258
265
  "refresh into a detected replay").toBe(undefined);
259
266
  });
@@ -261,28 +268,52 @@ export function runTokenServiceContractTests(options) {
261
268
  expect(await tokenService.getRevokedRefreshToken("never-issued")).toBe(undefined);
262
269
  });
263
270
  it("revokes every live member of a replayed token's family", async () => {
264
- const first = await saveFamilyMember(FAMILY);
265
- const second = await saveFamilyMember(FAMILY);
266
- expect(await tokenService.revokeFamily(FAMILY)).toBe(true);
271
+ const first = await saveFamilyMember(family);
272
+ const second = await saveFamilyMember(family);
273
+ expect(await tokenService.revokeFamily(family)).toBe(true);
267
274
  expect(await tokenService.getRefreshToken(first.refreshToken)).toBe(undefined);
268
275
  expect(await tokenService.getRefreshToken(second.refreshToken)).toBe(undefined);
269
276
  expect(await tokenService.getToken(second.accessToken), "the family's access tokens die with it — leaving them live " +
270
277
  "keeps the thief's session alive for their remaining lifetime").toBe(undefined);
271
278
  });
272
279
  it("leaves other families untouched", async () => {
273
- const mine = await saveFamilyMember(FAMILY);
274
- const theirs = await saveFamilyMember(OTHER_FAMILY);
275
- await tokenService.revokeFamily(FAMILY);
280
+ const mine = await saveFamilyMember(family);
281
+ const theirs = await saveFamilyMember(otherFamily);
282
+ await tokenService.revokeFamily(family);
276
283
  expect(await tokenService.getRefreshToken(mine.refreshToken)).toBe(undefined);
277
284
  assert((await tokenService.getRefreshToken(theirs.refreshToken)) !==
278
285
  undefined, "one compromised family must not sign every other session out");
279
286
  });
280
287
  it("returns false when the family has no live tokens", async () => {
281
- expect(await tokenService.revokeFamily("never-issued")).toBe(false);
288
+ expect(await tokenService.revokeFamily(makeFamilyId())).toBe(false);
289
+ });
290
+ it("keeps a revoked family revoked: a successor saved after revokeFamily is not live", async ({ skip, }) => {
291
+ skip(typeof tokenService.rotate === "function", "the service implements rotate, so the refresh grant never saves " +
292
+ "a successor separately; runTokenRotationContractTests checks " +
293
+ "revokeFamily against rotate");
294
+ const parent = await saveFamilyMember(family);
295
+ const successor = await familyMember(family);
296
+ const claimed = tokenService.revokeRotated
297
+ ? await tokenService.revokeRotated(parent)
298
+ : await tokenService.revoke(parent);
299
+ expect(claimed).toBe(true);
300
+ expect(await tokenService.getRefreshToken(parent.refreshToken), "the claim spends the presented refresh token").toBe(undefined);
301
+ await tokenService.revokeFamily(family);
302
+ await tokenService.save(successor);
303
+ expect(await tokenService.getRefreshToken(successor.refreshToken), "a successor saved after its family was revoked must be stored " +
304
+ "already revoked: the refresh grant claims a token and saves " +
305
+ "its successor in two calls, and a revokeFamily that lands " +
306
+ "between them otherwise leaves the successor live").toBe(undefined);
307
+ expect(await tokenService.getToken(successor.accessToken), "a successor saved after its family was revoked must be stored " +
308
+ "already revoked, its access token included").toBe(undefined);
309
+ if (tokenService.hasLiveRefreshToken) {
310
+ expect(await tokenService.hasLiveRefreshToken(family), "a successor saved after its family was revoked must be stored " +
311
+ "already revoked, so the family has no live member").toBe(false);
312
+ }
282
313
  });
283
314
  it("stamps a token revokeRotated revoked with its rotation time", async ({ skip, }) => {
284
315
  skip(typeof tokenService.revokeRotated !== "function", "the service does not implement revokeRotated");
285
- const token = await saveFamilyMember(FAMILY);
316
+ const token = await saveFamilyMember(family);
286
317
  const before = Date.now();
287
318
  expect(await tokenService.revokeRotated(token)).toBe(true);
288
319
  const revoked = await tokenService.getRevokedRefreshToken(token.refreshToken);
@@ -294,17 +325,17 @@ export function runTokenServiceContractTests(options) {
294
325
  });
295
326
  it("rotates in one call: the parent is spent and stamped, the successor live", async ({ skip, }) => {
296
327
  skip(typeof tokenService.rotate !== "function", "the service does not implement rotate");
297
- const parent = await saveFamilyMember(FAMILY);
298
- const next = await familyMember(FAMILY);
328
+ const parent = await saveFamilyMember(family);
329
+ const next = await familyMember(family);
299
330
  const before = Date.now();
300
331
  const rotated = await tokenService.rotate(parent, next);
301
332
  expect(rotated?.refreshToken).toBe(next.refreshToken);
302
- expect((await tokenService.getRefreshToken(next.refreshToken))?.familyId).toBe(FAMILY);
333
+ expect((await tokenService.getRefreshToken(next.refreshToken))?.familyId).toBe(family);
303
334
  expect(await tokenService.getToken(next.accessToken)).not.toBe(undefined);
304
335
  expect(await tokenService.getRefreshToken(parent.refreshToken)).toBe(undefined);
305
336
  expect(await tokenService.getToken(parent.accessToken), "rotation ends the parent's access token, as revokeRotated does").toBe(undefined);
306
337
  const tombstone = await tokenService.getRevokedRefreshToken(parent.refreshToken);
307
- expect(tombstone?.familyId).toBe(FAMILY);
338
+ expect(tombstone?.familyId).toBe(family);
308
339
  assert(tombstone?.rotatedAt instanceof Date &&
309
340
  tombstone.rotatedAt.getTime() >= before - 1000 &&
310
341
  tombstone.rotatedAt.getTime() <= Date.now() + 1000, "rotate must stamp rotatedAt as revokeRotated does, or the " +
@@ -313,11 +344,11 @@ export function runTokenServiceContractTests(options) {
313
344
  });
314
345
  it("refuses to rotate a refresh token that is no longer live, persisting nothing", async ({ skip, }) => {
315
346
  skip(typeof tokenService.rotate !== "function", "the service does not implement rotate");
316
- const parent = await saveFamilyMember(FAMILY);
317
- const first = await familyMember(FAMILY);
318
- const second = await familyMember(FAMILY);
319
- const revokedParent = await saveFamilyMember(FAMILY);
320
- const third = await familyMember(FAMILY);
347
+ const parent = await saveFamilyMember(family);
348
+ const first = await familyMember(family);
349
+ const second = await familyMember(family);
350
+ const revokedParent = await saveFamilyMember(family);
351
+ const third = await familyMember(family);
321
352
  await tokenService.revoke(revokedParent.refreshToken, "refresh_token");
322
353
  expect(await tokenService.rotate(parent, first)).not.toBe(undefined);
323
354
  expect(await tokenService.rotate(parent, second), "a refresh token is claimed once").toBe(undefined);
@@ -330,12 +361,12 @@ export function runTokenServiceContractTests(options) {
330
361
  });
331
362
  it("finds a rotated-out token's family, so revoking it ends the live successor", async ({ skip, }) => {
332
363
  skip(typeof tokenService.revokeRotated !== "function", "the service does not implement revokeRotated");
333
- const rotatedOut = await saveFamilyMember(FAMILY);
364
+ const rotatedOut = await saveFamilyMember(family);
334
365
  expect(await tokenService.revokeRotated(rotatedOut)).toBe(true);
335
- const successor = await saveFamilyMember(FAMILY);
336
- const unrelated = await saveFamilyMember(OTHER_FAMILY);
366
+ const successor = await saveFamilyMember(family);
367
+ const unrelated = await saveFamilyMember(otherFamily);
337
368
  const tombstone = await tokenService.getRevokedRefreshToken(rotatedOut.refreshToken);
338
- assert(tombstone?.familyId === FAMILY && tombstone.rotatedAt, "the revocation endpoint finds the family to end from the " +
369
+ assert(tombstone?.familyId === family && tombstone.rotatedAt, "the revocation endpoint finds the family to end from the " +
339
370
  "rotated-out token's record; without its familyId and " +
340
371
  "rotatedAt, a sign-out racing a refresh leaves the successor live");
341
372
  expect(tombstone.client.id.toString(), "the endpoint revokes the family only for the client the token " +
@@ -347,7 +378,7 @@ export function runTokenServiceContractTests(options) {
347
378
  undefined);
348
379
  });
349
380
  it("leaves rotatedAt unset on a token revoked other than by rotation", async () => {
350
- const token = await saveFamilyMember(FAMILY);
381
+ const token = await saveFamilyMember(family);
351
382
  await tokenService.revoke(token.refreshToken, "refresh_token");
352
383
  const revoked = await tokenService.getRevokedRefreshToken(token.refreshToken);
353
384
  expect(revoked?.rotatedAt, "a revoked token that looks rotated gets the replay grace a " +
@@ -355,26 +386,26 @@ export function runTokenServiceContractTests(options) {
355
386
  });
356
387
  it("reports whether a family still has a live refresh token", async ({ skip, }) => {
357
388
  skip(typeof tokenService.hasLiveRefreshToken !== "function", "the service does not implement hasLiveRefreshToken");
358
- const token = await saveFamilyMember(FAMILY);
359
- await saveFamilyMember(OTHER_FAMILY);
360
- expect(await tokenService.hasLiveRefreshToken(FAMILY)).toBe(true);
389
+ const token = await saveFamilyMember(family);
390
+ await saveFamilyMember(otherFamily);
391
+ expect(await tokenService.hasLiveRefreshToken(family)).toBe(true);
361
392
  await tokenService.revoke(token.refreshToken, "refresh_token");
362
- expect(await tokenService.hasLiveRefreshToken(FAMILY), "a family whose last token was revoked has no successor to protect").toBe(false);
363
- expect(await tokenService.hasLiveRefreshToken(OTHER_FAMILY)).toBe(true);
364
- expect(await tokenService.hasLiveRefreshToken("never-issued")).toBe(false);
393
+ expect(await tokenService.hasLiveRefreshToken(family), "a family whose last token was revoked has no successor to protect").toBe(false);
394
+ expect(await tokenService.hasLiveRefreshToken(otherFamily)).toBe(true);
395
+ expect(await tokenService.hasLiveRefreshToken(makeFamilyId())).toBe(false);
365
396
  });
366
397
  it("does not count an expired refresh token as live", async ({ skip, }) => {
367
398
  skip(typeof tokenService.hasLiveRefreshToken !== "function", "the service does not implement hasLiveRefreshToken");
368
- await saveFamilyMember(FAMILY, new Date(Date.now() - 1000));
369
- expect(await tokenService.hasLiveRefreshToken(FAMILY), "an expired successor cannot be used, so a replay of its " +
399
+ await saveFamilyMember(family, new Date(Date.now() - 1000));
400
+ expect(await tokenService.hasLiveRefreshToken(family), "an expired successor cannot be used, so a replay of its " +
370
401
  "predecessor must not be excused as a straggler").toBe(false);
371
402
  });
372
403
  async function rotateOut(skip) {
373
404
  skip(typeof tokenService.isWithinReplayGrace !== "function", "the service does not implement isWithinReplayGrace");
374
405
  skip(typeof tokenService.rotate !== "function" &&
375
406
  typeof tokenService.revokeRotated !== "function", "the service implements neither rotate nor revokeRotated");
376
- const parent = await saveFamilyMember(FAMILY);
377
- const successor = await familyMember(FAMILY);
407
+ const parent = await saveFamilyMember(family);
408
+ const successor = await familyMember(family);
378
409
  if (tokenService.rotate) {
379
410
  assert(await tokenService.rotate(parent, successor));
380
411
  }
@@ -383,7 +414,7 @@ export function runTokenServiceContractTests(options) {
383
414
  await tokenService.save(successor);
384
415
  }
385
416
  const tombstone = await tokenService.getRevokedRefreshToken(parent.refreshToken);
386
- assert(tombstone?.familyId === FAMILY && tombstone.rotatedAt, "a store that judges the replay grace must still return every " +
417
+ assert(tombstone?.familyId === family && tombstone.rotatedAt, "a store that judges the replay grace must still return every " +
387
418
  "rotated-out tombstone, with its family and rotatedAt; hiding " +
388
419
  "one inside the window hides the family from a sign-out " +
389
420
  "racing the refresh, which then leaves the successor live");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@udibo/oauth2",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
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",