@udibo/oauth2 0.21.2 → 0.22.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.
@@ -26,6 +26,12 @@ export interface HonoResourceServerVariables<Client extends ClientInterface, Use
26
26
  /** The authenticated OAuth2 context set by the protect middleware. */
27
27
  [OAUTH2_CONTEXT_KEY]: AuthenticatedContext<Client, User, S>;
28
28
  }
29
+ /**
30
+ * Answers with `response` while keeping the headers earlier middleware set on
31
+ * `c`, such as the `Set-Cookie` a BFF sets when a refresh rotates or ends the
32
+ * session. A middleware returning a bare `Response` would drop them.
33
+ */
34
+ export declare function respondInContext(c: Context, response: Response): Response;
29
35
  /**
30
36
  * Builds a Hono middleware that authenticates bearer tokens, stores the
31
37
  * authenticated context at {@link OAUTH2_CONTEXT_KEY}, and on failure returns
@@ -13,6 +13,14 @@ import { ResourceServer } from "../../server/resource-server.js";
13
13
  * context. Namespaced to avoid collisions with other middleware.
14
14
  */
15
15
  export const OAUTH2_CONTEXT_KEY = "@udibo/oauth2";
16
+ /**
17
+ * Answers with `response` while keeping the headers earlier middleware set on
18
+ * `c`, such as the `Set-Cookie` a BFF sets when a refresh rotates or ends the
19
+ * session. A middleware returning a bare `Response` would drop them.
20
+ */
21
+ export function respondInContext(c, response) {
22
+ return c.newResponse(response.body, response);
23
+ }
16
24
  /**
17
25
  * Builds a Hono middleware that authenticates bearer tokens, stores the
18
26
  * authenticated context at {@link OAUTH2_CONTEXT_KEY}, and on failure returns
@@ -26,7 +34,7 @@ export function createProtectMiddleware(server, requiredScope) {
26
34
  await next();
27
35
  }
28
36
  catch (error) {
29
- return server.handleAuthError(error);
37
+ return respondInContext(c, server.handleAuthError(error));
30
38
  }
31
39
  };
32
40
  }
@@ -45,7 +53,7 @@ export function createRequireScopeMiddleware(server, adapterName, requiredScope)
45
53
  server.assertScope(scope, requiredScope);
46
54
  }
47
55
  catch (error) {
48
- return server.handleAuthError(error);
56
+ return respondInContext(c, server.handleAuthError(error));
49
57
  }
50
58
  await next();
51
59
  };
@@ -63,7 +71,7 @@ export function createRequireMiddleware(server, adapterName, conditions) {
63
71
  server.assertAuthorized(context, conditions);
64
72
  }
65
73
  catch (error) {
66
- return server.handleAuthError(error);
74
+ return respondInContext(c, server.handleAuthError(error));
67
75
  }
68
76
  await next();
69
77
  };
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Per-instance memory of session cookies a refresh replaced, used by
3
+ * `HonoBff`'s `rotatedCookieGrace`.
4
+ *
5
+ * @module
6
+ */
7
+ /** A refresh {@link RotatedCookies.start} began, to hand to `finish`. */
8
+ export interface PendingReplacement {
9
+ readonly key: string;
10
+ ended: boolean;
11
+ }
12
+ /**
13
+ * Maps a replaced session cookie, by its SHA-256 digest, to the cookie that
14
+ * replaced it, for `ttlMs` and at most `maxEntries` cookies. The oldest entry
15
+ * is evicted first.
16
+ */
17
+ export declare class RotatedCookies {
18
+ #private;
19
+ constructor(ttlMs: number, maxEntries: number);
20
+ /** Records that `successor` replaced `previous`. */
21
+ remember(previous: string, successor: string): Promise<void>;
22
+ /**
23
+ * Starts a refresh of `previous`. If its chain is forgotten before
24
+ * {@link finish}, the replacement is not remembered.
25
+ */
26
+ start(previous: string): Promise<PendingReplacement>;
27
+ /** Ends a refresh, remembering `successor` unless its chain was forgotten. */
28
+ finish(pending: PendingReplacement, successor: string | undefined): Promise<void>;
29
+ /** The cookie that replaced `cookieValue` within the window, if any. */
30
+ successor(cookieValue: string): Promise<string | undefined>;
31
+ /**
32
+ * Drops every entry on the chain of replacements `cookieValue` belongs to,
33
+ * so no cookie of a session that ended leads to another.
34
+ */
35
+ forget(cookieValue: string): Promise<void>;
36
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Per-instance memory of session cookies a refresh replaced, used by
3
+ * `HonoBff`'s `rotatedCookieGrace`.
4
+ *
5
+ * @module
6
+ */
7
+ import { sha256Hash } from "../../../server/utils/hash.js";
8
+ /**
9
+ * Maps a replaced session cookie, by its SHA-256 digest, to the cookie that
10
+ * replaced it, for `ttlMs` and at most `maxEntries` cookies. The oldest entry
11
+ * is evicted first.
12
+ */
13
+ export class RotatedCookies {
14
+ #ttlMs;
15
+ #maxEntries;
16
+ #successors = new Map();
17
+ #pending = new Set();
18
+ constructor(ttlMs, maxEntries) {
19
+ this.#ttlMs = ttlMs;
20
+ this.#maxEntries = maxEntries;
21
+ }
22
+ /** Records that `successor` replaced `previous`. */
23
+ async remember(previous, successor) {
24
+ await this.finish(await this.start(previous), successor);
25
+ }
26
+ /**
27
+ * Starts a refresh of `previous`. If its chain is forgotten before
28
+ * {@link finish}, the replacement is not remembered.
29
+ */
30
+ async start(previous) {
31
+ const pending = { key: await sha256Hash(previous), ended: false };
32
+ this.#pending.add(pending);
33
+ return pending;
34
+ }
35
+ /** Ends a refresh, remembering `successor` unless its chain was forgotten. */
36
+ async finish(pending, successor) {
37
+ this.#pending.delete(pending);
38
+ if (pending.ended || successor === undefined)
39
+ return;
40
+ const key = pending.key;
41
+ this.#successors.delete(key);
42
+ this.#successors.set(key, {
43
+ cookieValue: successor,
44
+ digest: await sha256Hash(successor),
45
+ expiresAt: Date.now() + this.#ttlMs,
46
+ });
47
+ this.#evict();
48
+ }
49
+ /** The cookie that replaced `cookieValue` within the window, if any. */
50
+ async successor(cookieValue) {
51
+ const key = await sha256Hash(cookieValue);
52
+ const entry = this.#successors.get(key);
53
+ if (!entry)
54
+ return undefined;
55
+ if (entry.expiresAt <= Date.now()) {
56
+ this.#successors.delete(key);
57
+ return undefined;
58
+ }
59
+ return entry.cookieValue;
60
+ }
61
+ /**
62
+ * Drops every entry on the chain of replacements `cookieValue` belongs to,
63
+ * so no cookie of a session that ended leads to another.
64
+ */
65
+ async forget(cookieValue) {
66
+ const ended = new Set([await sha256Hash(cookieValue)]);
67
+ let dropped = true;
68
+ while (dropped) {
69
+ dropped = false;
70
+ for (const [key, entry] of this.#successors) {
71
+ if (ended.has(key) || ended.has(entry.digest)) {
72
+ this.#successors.delete(key);
73
+ ended.add(key);
74
+ ended.add(entry.digest);
75
+ dropped = true;
76
+ }
77
+ }
78
+ }
79
+ for (const pending of this.#pending) {
80
+ if (ended.has(pending.key))
81
+ pending.ended = true;
82
+ }
83
+ }
84
+ #evict() {
85
+ const now = Date.now();
86
+ for (const [key, entry] of this.#successors) {
87
+ if (this.#successors.size <= this.#maxEntries && entry.expiresAt > now) {
88
+ return;
89
+ }
90
+ this.#successors.delete(key);
91
+ }
92
+ }
93
+ }
@@ -322,7 +322,8 @@ export interface HonoBffOptions {
322
322
  * shared {@link SessionStore} when you need revocation. Against an
323
323
  * authorization server that rotates refresh tokens, a request still
324
324
  * carrying the cookie from before a refresh replays the old token and
325
- * trips reuse detection.
325
+ * trips reuse detection, unless it reaches the instance that ran the
326
+ * refresh within {@link HonoBffOptions.rotatedCookieGrace}.
326
327
  * - A {@link SessionStore} over a database or cache every instance shares,
327
328
  * for server-side revocation and back-channel logout. Implement
328
329
  * `RefreshLockStore` (`withRefreshLock`) on it so instances refresh a
@@ -637,6 +638,34 @@ export interface HonoBffOptions {
637
638
  * refresh proactively. Defaults to 30s.
638
639
  */
639
640
  refreshSkewSeconds?: number;
641
+ /**
642
+ * How this instance answers a request that still carries the session cookie
643
+ * a refresh replaced. Applies when a refresh hands the browser a new cookie
644
+ * value, as {@link EncryptedCookieSessionStore} does on every refresh.
645
+ *
646
+ * A browser that sent several requests before the refresh's response arrived
647
+ * still sends the old cookie on the others. Against an authorization server
648
+ * that rotates refresh tokens, a request that arrives after the refresh has
649
+ * finished would present the rotated-out token: reuse detection revokes the
650
+ * family and the user is signed out. With this grace, the instance that ran
651
+ * the refresh remembers which cookie replaced which for `ttlMs` and answers
652
+ * such a request with the new session and its cookie, spending nothing.
653
+ *
654
+ * Defaults to `{ ttlMs: 10_000, maxEntries: 1000 }`. Pass `false` to turn it
655
+ * off. Beyond `maxEntries` the oldest entry is dropped. A session that ends
656
+ * on this instance (sign-out, a sign-in replacing it, a refused refresh)
657
+ * forgets its whole chain, including a refresh still in flight.
658
+ *
659
+ * The cost is that a stolen pre-refresh cookie replayed to the same
660
+ * instance within `ttlMs` receives the new session, where without the grace
661
+ * it would trip reuse detection. Only the instance that ran the refresh
662
+ * remembers, so a request on another instance still replays the token. See
663
+ * the session limitations in `docs/known-limitations.md`.
664
+ */
665
+ rotatedCookieGrace?: {
666
+ ttlMs?: number;
667
+ maxEntries?: number;
668
+ } | false;
640
669
  /**
641
670
  * Optional resource server. When provided, {@link HonoBff.protect}
642
671
  * becomes available — a single middleware that composes
@@ -55,11 +55,28 @@ import { defaultFetch } from "../../../utils/_default-fetch.js";
55
55
  import { inboundRequestWithHeaders } from "../../../utils/_inbound-request.js";
56
56
  import { timingSafeMatchIndex } from "../../../utils/_timing-safe.js";
57
57
  import { sha256Hash } from "../../../server/utils/hash.js";
58
- import { OAUTH2_CONTEXT_KEY } from "../_common.js";
58
+ import { OAUTH2_CONTEXT_KEY, respondInContext } from "../_common.js";
59
59
  import { DEFAULT_MAX_RETURN_TO_LENGTH, loginContinuation as computeLoginContinuation, safeReturnTo, } from "../../../utils/url.js";
60
60
  import { resolveCookieName, resolvePrefixConstrainedAttributes, } from "./cookie-prefix.js";
61
61
  import { DEFAULT_SESSION_MAX_AGE_MS, sessionStoreMaxAgeMs, supportsBackchannelLogout, supportsRefreshLock, } from "./session-store.js";
62
+ import { RotatedCookies } from "./_rotated-cookies.js";
62
63
  import { buildDownstreamHeaders, buildUpstreamHeaders, isInvalidTokenChallenge, resolveForwardHeaders, resolveProxyUrl, } from "./proxy.js";
64
+ const DEFAULT_ROTATED_COOKIE_GRACE_MS = 10_000;
65
+ const DEFAULT_ROTATED_COOKIE_MAX_ENTRIES = 1000;
66
+ const MAX_ROTATED_COOKIE_HOPS = 4;
67
+ function rotatedCookies(grace) {
68
+ if (grace === false)
69
+ return undefined;
70
+ const ttlMs = grace?.ttlMs ?? DEFAULT_ROTATED_COOKIE_GRACE_MS;
71
+ const maxEntries = grace?.maxEntries ?? DEFAULT_ROTATED_COOKIE_MAX_ENTRIES;
72
+ if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
73
+ throw new RangeError(`rotatedCookieGrace.ttlMs must be a positive number of milliseconds, got ${ttlMs}`);
74
+ }
75
+ if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) {
76
+ throw new RangeError(`rotatedCookieGrace.maxEntries must be a positive integer, got ${maxEntries}`);
77
+ }
78
+ return new RotatedCookies(ttlMs, maxEntries);
79
+ }
63
80
  function refreshedSince(seen, current) {
64
81
  return (current.refreshToken !== seen.refreshToken ||
65
82
  current.tokens.accessToken !== seen.tokens.accessToken);
@@ -319,6 +336,7 @@ export class HonoBff {
319
336
  #extraAuthorizeParams;
320
337
  #maxReturnToLength;
321
338
  #refreshes = new Map();
339
+ #rotatedCookies;
322
340
  /**
323
341
  * Defaults are applied for session mode (`"own"`), cookie name, and CSRF.
324
342
  * The session store and auth-request storage have no default.
@@ -410,6 +428,7 @@ export class HonoBff {
410
428
  }
411
429
  }
412
430
  }
431
+ this.#rotatedCookies = rotatedCookies(options.rotatedCookieGrace);
413
432
  if (options.backchannelLogout?.maxBodyBytes !== undefined) {
414
433
  assertMaxBodyBytes(options.backchannelLogout.maxBodyBytes, "backchannelLogout.maxBodyBytes");
415
434
  }
@@ -475,7 +494,7 @@ export class HonoBff {
475
494
  return null;
476
495
  if (!this.#isBeyondSessionMaxAge(data))
477
496
  return data;
478
- await this.#store.destroy(cookieValue);
497
+ await this.#destroySession(cookieValue);
479
498
  deleteCookie(c, this.#cookieName, this.#cookieOptions());
480
499
  return null;
481
500
  }
@@ -740,7 +759,7 @@ export class HonoBff {
740
759
  const inbound = getCookie(c, this.#cookieName);
741
760
  const existing = this.#sessionMode === "shared" ? inbound : undefined;
742
761
  if (inbound && !existing)
743
- await this.#store.destroy(inbound);
762
+ await this.#destroySession(inbound);
744
763
  const cookieValue = existing
745
764
  ? await this.#store.update(existing, data)
746
765
  : await this.#store.create(data);
@@ -784,7 +803,7 @@ export class HonoBff {
784
803
  // best-effort
785
804
  }
786
805
  }
787
- await this.#store.destroy(cookieValue);
806
+ await this.#destroySession(cookieValue);
788
807
  deleteCookie(c, this.#cookieName, this.#cookieOptions());
789
808
  }
790
809
  const returnTo = this.#safeReturnTo(c.req.query("return_to"));
@@ -1009,12 +1028,52 @@ export class HonoBff {
1009
1028
  const inFlight = this.#refreshes.get(entry.cookieValue);
1010
1029
  if (inFlight)
1011
1030
  return inFlight;
1012
- const pending = this.#lockedRefresh(entry).finally(() => {
1031
+ const pending = this.#refreshUnlessReplaced(entry).finally(() => {
1013
1032
  this.#refreshes.delete(entry.cookieValue);
1014
1033
  });
1015
1034
  this.#refreshes.set(entry.cookieValue, pending);
1016
1035
  return pending;
1017
1036
  }
1037
+ async #refreshUnlessReplaced(entry) {
1038
+ const memo = this.#rotatedCookies;
1039
+ if (!memo)
1040
+ return await this.#lockedRefresh(entry);
1041
+ let latest = entry.cookieValue;
1042
+ for (let hop = 0; hop < MAX_ROTATED_COOKIE_HOPS; hop++) {
1043
+ const next = await memo.successor(latest);
1044
+ if (next === undefined)
1045
+ break;
1046
+ latest = next;
1047
+ }
1048
+ if (latest !== entry.cookieValue) {
1049
+ try {
1050
+ const data = await this.#store.read(latest);
1051
+ return data
1052
+ ? { status: "refreshed", entry: { cookieValue: latest, data } }
1053
+ : { status: "revoked" };
1054
+ }
1055
+ catch {
1056
+ return { status: "unavailable" };
1057
+ }
1058
+ }
1059
+ const pending = await memo.start(entry.cookieValue);
1060
+ let successor;
1061
+ try {
1062
+ const outcome = await this.#lockedRefresh(entry);
1063
+ if (outcome.status === "refreshed" &&
1064
+ outcome.entry.cookieValue !== entry.cookieValue) {
1065
+ successor = outcome.entry.cookieValue;
1066
+ }
1067
+ return outcome;
1068
+ }
1069
+ finally {
1070
+ await memo.finish(pending, successor);
1071
+ }
1072
+ }
1073
+ async #destroySession(cookieValue) {
1074
+ await this.#rotatedCookies?.forget(cookieValue);
1075
+ await this.#store.destroy(cookieValue);
1076
+ }
1018
1077
  async #lockedRefresh(entry) {
1019
1078
  const store = this.#store;
1020
1079
  if (!supportsRefreshLock(store))
@@ -1065,7 +1124,7 @@ export class HonoBff {
1065
1124
  if (current && current.refreshToken !== data.refreshToken) {
1066
1125
  return { status: "refreshed", entry: { cookieValue, data: current } };
1067
1126
  }
1068
- await this.#store.destroy(cookieValue);
1127
+ await this.#destroySession(cookieValue);
1069
1128
  return { status: "revoked" };
1070
1129
  }
1071
1130
  }
@@ -1208,7 +1267,7 @@ export class HonoBff {
1208
1267
  await next();
1209
1268
  }
1210
1269
  catch (error) {
1211
- return resourceServer.handleAuthError(error);
1270
+ return respondInContext(c, resourceServer.handleAuthError(error));
1212
1271
  }
1213
1272
  };
1214
1273
  }
@@ -66,6 +66,13 @@ export interface RefreshToken<Client extends ClientInterface, User, Scope extend
66
66
  * with a fresh anchor, so the family is capped from then on.
67
67
  */
68
68
  familyCreatedAt?: Date;
69
+ /**
70
+ * When the refresh grant rotated this token out. A token service that
71
+ * supports `RefreshTokenGrantOptions.replayGraceSeconds` sets it in
72
+ * `revokeRotated` and returns it from `getRevokedRefreshToken`. A token
73
+ * revoked any other way has none.
74
+ */
75
+ rotatedAt?: Date;
69
76
  }
70
77
  /**
71
78
  * Standard OAuth2 token response.
@@ -40,6 +40,33 @@ export interface RefreshTokenGrantOptions<Client extends ClientInterface, User,
40
40
  * client-facing `invalid_grant` a replayed token always yields.
41
41
  */
42
42
  onTokenReuse?: (event: TokenReuseEvent<Client, User>) => void | Promise<void>;
43
+ /**
44
+ * Seconds after a refresh token is rotated during which presenting it again
45
+ * is answered with `invalid_grant` but not treated as reuse: the family
46
+ * keeps its live token and `onTokenReuse` does not fire. Defaults to `0`,
47
+ * which turns the grace off.
48
+ *
49
+ * It covers a client that sends a request with the old refresh token after
50
+ * its own concurrent request already rotated it, such as a backend-for-
51
+ * frontend whose instances do not coordinate refreshes, so that one
52
+ * straggler does not sign the user out. It applies only while the family
53
+ * still has a live refresh token, and only to a token the grant rotated
54
+ * out, not to one revoked any other way. Outside the window, or with no
55
+ * live successor, reuse detection runs as usual.
56
+ *
57
+ * The cost is that a stolen refresh token replayed within the window after
58
+ * the legitimate client rotated it goes unflagged: the family is not
59
+ * revoked and no reuse event is raised. It cuts the other way too: when a
60
+ * thief rotates a stolen live token first, the legitimate client's replay
61
+ * within the window is refused without revoking the family, so the thief
62
+ * keeps the session and the user is the one signed out. Keep the window to
63
+ * seconds.
64
+ *
65
+ * Needs a token service that sets {@linkcode RefreshToken.rotatedAt} in
66
+ * `revokeRotated` and implements `hasLiveRefreshToken`; with any other
67
+ * service the grace never applies.
68
+ */
69
+ replayGraceSeconds?: number;
43
70
  }
44
71
  /**
45
72
  * The refresh token grant type.
@@ -16,10 +16,16 @@ export class RefreshTokenGrant extends AbstractGrant {
16
16
  /** The OAuth2 grant type: `refresh_token`. */
17
17
  grantType = "refresh_token";
18
18
  #onTokenReuse;
19
+ #replayGraceMs;
19
20
  /** Creates the grant from its {@linkcode RefreshTokenGrantOptions}. */
20
21
  constructor(options) {
21
22
  super({ ...options, allowRefreshToken: true });
22
23
  this.#onTokenReuse = options.onTokenReuse;
24
+ const graceSeconds = options.replayGraceSeconds ?? 0;
25
+ if (!Number.isFinite(graceSeconds) || graceSeconds < 0) {
26
+ throw new RangeError(`replayGraceSeconds must be a non-negative number of seconds, got ${graceSeconds}`);
27
+ }
28
+ this.#replayGraceMs = graceSeconds * 1000;
23
29
  }
24
30
  /**
25
31
  * Handles a token request for the refresh token grant. Issues a new access
@@ -60,7 +66,8 @@ export class RefreshTokenGrant extends AbstractGrant {
60
66
  if (tokenService.getRevokedRefreshToken && tokenService.revokeFamily) {
61
67
  const replayed = await tokenService.getRevokedRefreshToken(refreshTokenStr);
62
68
  if (replayed?.familyId &&
63
- replayed.client.id.toString() === client.id.toString()) {
69
+ replayed.client.id.toString() === client.id.toString() &&
70
+ !(await this.#withinReplayGrace(tokenService, replayed))) {
64
71
  const familyRevoked = await tokenService.revokeFamily(replayed.familyId);
65
72
  await this.#notifyTokenReuse({
66
73
  familyId: replayed.familyId,
@@ -123,6 +130,14 @@ export class RefreshTokenGrant extends AbstractGrant {
123
130
  }
124
131
  return await tokenService.save(nextRefreshToken);
125
132
  }
133
+ async #withinReplayGrace(tokenService, replayed) {
134
+ const { rotatedAt, familyId } = replayed;
135
+ if (this.#replayGraceMs === 0 || !rotatedAt || !familyId)
136
+ return false;
137
+ if (Date.now() - rotatedAt.getTime() > this.#replayGraceMs)
138
+ return false;
139
+ return (await tokenService.hasLiveRefreshToken?.(familyId)) ?? false;
140
+ }
126
141
  async #notifyTokenReuse(event) {
127
142
  if (!this.#onTokenReuse)
128
143
  return;
@@ -121,6 +121,10 @@ export interface TokenServiceInterface<Client extends ClientInterface, User, Sco
121
121
  * the token belongs to, cascading to sibling records — so a routine refresh
122
122
  * cannot terminate the very session it renews. When absent, the refresh
123
123
  * grant falls back to {@linkcode revoke}.
124
+ *
125
+ * To support the refresh grant's `replayGraceSeconds`, stamp the revoked
126
+ * record with {@linkcode RefreshToken.rotatedAt} here and return it from
127
+ * {@linkcode getRevokedRefreshToken}.
124
128
  */
125
129
  revokeRotated?(token: RefreshToken<Client, User, Scope>): Promise<boolean>;
126
130
  /**
@@ -148,6 +152,12 @@ export interface TokenServiceInterface<Client extends ClientInterface, User, Sco
148
152
  * can't keep a session alive. Returns true if anything was revoked.
149
153
  */
150
154
  revokeFamily?(familyId: string): Promise<boolean>;
155
+ /**
156
+ * Whether the rotation family still has a live, unexpired refresh token.
157
+ * Read by the refresh grant's `replayGraceSeconds`: without it, the grace
158
+ * never applies and every replay is treated as reuse.
159
+ */
160
+ hasLiveRefreshToken?(familyId: string): Promise<boolean>;
151
161
  }
152
162
  /** Options for configuring an abstract token service. */
153
163
  export interface AbstractTokenServiceOptions<Client extends ClientInterface, User> {
@@ -212,14 +212,14 @@ export function runTokenServiceContractTests(options) {
212
212
  describe("refresh-token rotation family", () => {
213
213
  const FAMILY = "family-1";
214
214
  const OTHER_FAMILY = "family-2";
215
- async function saveFamilyMember(familyId) {
215
+ async function saveFamilyMember(familyId, refreshTokenExpiresAt = new Date(Date.now() + 600_000)) {
216
216
  const accessToken = await tokenService.generateAccessToken(client, user);
217
217
  const refreshToken = (await tokenService.generateRefreshToken(client, user));
218
218
  const token = {
219
219
  accessToken,
220
220
  accessTokenExpiresAt: new Date(Date.now() + 60_000),
221
221
  refreshToken,
222
- refreshTokenExpiresAt: new Date(Date.now() + 600_000),
222
+ refreshTokenExpiresAt,
223
223
  familyId,
224
224
  familyCreatedAt: new Date(Date.now() - 60_000),
225
225
  client,
@@ -273,6 +273,41 @@ export function runTokenServiceContractTests(options) {
273
273
  it("returns false when the family has no live tokens", async () => {
274
274
  expect(await tokenService.revokeFamily("never-issued")).toBe(false);
275
275
  });
276
+ it("stamps a token revokeRotated revoked with its rotation time", async ({ skip, }) => {
277
+ skip(typeof tokenService.revokeRotated !== "function", "the service does not implement revokeRotated");
278
+ const token = await saveFamilyMember(FAMILY);
279
+ const before = Date.now();
280
+ expect(await tokenService.revokeRotated(token)).toBe(true);
281
+ const revoked = await tokenService.getRevokedRefreshToken(token.refreshToken);
282
+ assert(revoked !== undefined);
283
+ assert(revoked.rotatedAt instanceof Date &&
284
+ revoked.rotatedAt.getTime() >= before - 1000 &&
285
+ revoked.rotatedAt.getTime() <= Date.now() + 1000, "without rotatedAt the refresh grant's replayGraceSeconds never " +
286
+ "applies, so every straggler revokes its family");
287
+ });
288
+ it("leaves rotatedAt unset on a token revoked other than by rotation", async () => {
289
+ const token = await saveFamilyMember(FAMILY);
290
+ await tokenService.revoke(token.refreshToken, "refresh_token");
291
+ const revoked = await tokenService.getRevokedRefreshToken(token.refreshToken);
292
+ expect(revoked?.rotatedAt, "a revoked token that looks rotated gets the replay grace a " +
293
+ "signed-out session must never have").toBe(undefined);
294
+ });
295
+ it("reports whether a family still has a live refresh token", async ({ skip, }) => {
296
+ skip(typeof tokenService.hasLiveRefreshToken !== "function", "the service does not implement hasLiveRefreshToken");
297
+ const token = await saveFamilyMember(FAMILY);
298
+ await saveFamilyMember(OTHER_FAMILY);
299
+ expect(await tokenService.hasLiveRefreshToken(FAMILY)).toBe(true);
300
+ await tokenService.revoke(token.refreshToken, "refresh_token");
301
+ expect(await tokenService.hasLiveRefreshToken(FAMILY), "a family whose last token was revoked has no successor to protect").toBe(false);
302
+ expect(await tokenService.hasLiveRefreshToken(OTHER_FAMILY)).toBe(true);
303
+ expect(await tokenService.hasLiveRefreshToken("never-issued")).toBe(false);
304
+ });
305
+ it("does not count an expired refresh token as live", async ({ skip, }) => {
306
+ skip(typeof tokenService.hasLiveRefreshToken !== "function", "the service does not implement hasLiveRefreshToken");
307
+ await saveFamilyMember(FAMILY, new Date(Date.now() - 1000));
308
+ expect(await tokenService.hasLiveRefreshToken(FAMILY), "an expired successor cannot be used, so a replay of its " +
309
+ "predecessor must not be excused as a straggler").toBe(false);
310
+ });
276
311
  });
277
312
  }
278
313
  else {
@@ -240,6 +240,15 @@ export declare class MemoryTokenService<C extends ClientInterface, U extends Mem
240
240
  * token. Resolves `true` when something was removed.
241
241
  */
242
242
  revoke(token: string, hint?: string | null): Promise<boolean>;
243
+ /**
244
+ * Revokes a refresh token the refresh grant just rotated out, as
245
+ * {@linkcode revoke} does, and stamps its tombstone with the rotation time
246
+ * so the grant's `replayGraceSeconds` can apply. Resolves `false` when the
247
+ * token was already claimed.
248
+ */
249
+ revokeRotated(token: RefreshToken<C, U, S>): Promise<boolean>;
250
+ /** Whether the family has a live refresh token that has not expired. */
251
+ hasLiveRefreshToken(familyId: string): Promise<boolean>;
243
252
  /**
244
253
  * Looks up a revoked/rotated-out refresh token for the refresh grant's
245
254
  * reuse detection: revoked refresh records are kept as tombstones instead
@@ -290,6 +290,7 @@ export class MemoryTokenService {
290
290
  refreshTokenExpiresAt: stored.refreshTokenExpiresAt,
291
291
  familyId: stored.familyId,
292
292
  familyCreatedAt: stored.familyCreatedAt,
293
+ rotatedAt: stored.rotatedAt,
293
294
  };
294
295
  }
295
296
  return token;
@@ -367,6 +368,31 @@ export class MemoryTokenService {
367
368
  }
368
369
  return true;
369
370
  }
371
+ /**
372
+ * Revokes a refresh token the refresh grant just rotated out, as
373
+ * {@linkcode revoke} does, and stamps its tombstone with the rotation time
374
+ * so the grant's `replayGraceSeconds` can apply. Resolves `false` when the
375
+ * token was already claimed.
376
+ */
377
+ async revokeRotated(token) {
378
+ const stored = this.#tokensByRefreshHash.get(await sha256Hash(token.refreshToken));
379
+ if (!stored)
380
+ return false;
381
+ stored.rotatedAt = new Date();
382
+ return await this.revoke(token);
383
+ }
384
+ /** Whether the family has a live refresh token that has not expired. */
385
+ hasLiveRefreshToken(familyId) {
386
+ const now = Date.now();
387
+ for (const stored of this.#tokensByRefreshHash.values()) {
388
+ if (stored.familyId === familyId &&
389
+ (!stored.refreshTokenExpiresAt ||
390
+ stored.refreshTokenExpiresAt.getTime() > now)) {
391
+ return Promise.resolve(true);
392
+ }
393
+ }
394
+ return Promise.resolve(false);
395
+ }
370
396
  #tombstone(refreshTokenHash, stored) {
371
397
  this.#revokedByRefreshHash.set(refreshTokenHash, stored);
372
398
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@udibo/oauth2",
3
- "version": "0.21.2",
3
+ "version": "0.22.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",