@udibo/oauth2 0.21.3 → 0.23.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.
@@ -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
@@ -59,7 +59,24 @@ 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
  }
@@ -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 sets it in
71
+ * `revokeRotated` and returns it from `getRevokedRefreshToken`; the refresh
72
+ * grant's `replayGraceSeconds` and the revocation endpoint's family
73
+ * revocation both need it. A token revoked any other way has none.
74
+ */
75
+ rotatedAt?: Date;
69
76
  }
70
77
  /**
71
78
  * Standard OAuth2 token response.
@@ -641,6 +641,18 @@ export declare class AuthorizationServer<Client extends ClientInterface, User, S
641
641
  * end another application's live sessions at will. A token belonging to a
642
642
  * different client is left alone.
643
643
  *
644
+ * **A refresh token that rotation already replaced revokes its family.** When
645
+ * the token is no longer live but the token service's
646
+ * `getRevokedRefreshToken` returns it with {@linkcode RefreshToken.rotatedAt}
647
+ * and a `familyId`, and it was issued to the authenticated client, the
648
+ * server calls `revokeFamily`, so the successor rotation issued dies with
649
+ * it (RFC 7009 §2.1 allows revoking related tokens). A sign-out that races
650
+ * the client's own refresh therefore still ends the session. A token
651
+ * revoked other than by rotation, a token with no family, and a service
652
+ * without `getRevokedRefreshToken` and `revokeFamily` revoke nothing more.
653
+ * The refresh grant's `replayGraceSeconds` does not apply here, and
654
+ * `onTokenReuse` does not fire.
655
+ *
644
656
  * Always returns 200 OK, even if the token was invalid, already revoked, or
645
657
  * issued to another client: RFC 7009 Section 2.2 requires that answer so the
646
658
  * endpoint cannot be used as an oracle for whether a token value is live or
@@ -935,6 +935,18 @@ export class AuthorizationServer extends ResourceServer {
935
935
  * end another application's live sessions at will. A token belonging to a
936
936
  * different client is left alone.
937
937
  *
938
+ * **A refresh token that rotation already replaced revokes its family.** When
939
+ * the token is no longer live but the token service's
940
+ * `getRevokedRefreshToken` returns it with {@linkcode RefreshToken.rotatedAt}
941
+ * and a `familyId`, and it was issued to the authenticated client, the
942
+ * server calls `revokeFamily`, so the successor rotation issued dies with
943
+ * it (RFC 7009 §2.1 allows revoking related tokens). A sign-out that races
944
+ * the client's own refresh therefore still ends the session. A token
945
+ * revoked other than by rotation, a token with no family, and a service
946
+ * without `getRevokedRefreshToken` and `revokeFamily` revoke nothing more.
947
+ * The refresh grant's `replayGraceSeconds` does not apply here, and
948
+ * `onTokenReuse` does not fire.
949
+ *
938
950
  * Always returns 200 OK, even if the token was invalid, already revoked, or
939
951
  * issued to another client: RFC 7009 Section 2.2 requires that answer so the
940
952
  * endpoint cannot be used as an oracle for whether a token value is live or
@@ -962,8 +974,10 @@ export class AuthorizationServer extends ResourceServer {
962
974
  const hint = typeof tokenTypeHint === "string" ? tokenTypeHint : null;
963
975
  const { tokenService } = context.services;
964
976
  const resolved = await this.#resolveToken(tokenService, token, hint);
965
- if (resolved &&
966
- resolved.token.client.id.toString() === client.id.toString()) {
977
+ if (!resolved) {
978
+ await this.#revokeRotatedOutFamily(tokenService, token, client);
979
+ }
980
+ else if (resolved.token.client.id.toString() === client.id.toString()) {
967
981
  await tokenService.revoke(token, resolved.tokenType);
968
982
  }
969
983
  return new Response(null, { status: 200 });
@@ -972,6 +986,17 @@ export class AuthorizationServer extends ResourceServer {
972
986
  return this.#handleEndpointError(error, this.prepareClientAuthError(request));
973
987
  }
974
988
  }
989
+ async #revokeRotatedOutFamily(tokenService, value, client) {
990
+ if (!tokenService.getRevokedRefreshToken || !tokenService.revokeFamily) {
991
+ return;
992
+ }
993
+ const rotatedOut = await tokenService.getRevokedRefreshToken(value);
994
+ if (rotatedOut?.rotatedAt &&
995
+ rotatedOut.familyId &&
996
+ rotatedOut.client.id.toString() === client.id.toString()) {
997
+ await tokenService.revokeFamily(rotatedOut.familyId);
998
+ }
999
+ }
975
1000
  async #resolveToken(tokenService, value, hint) {
976
1001
  const refreshFirst = hint === "refresh_token";
977
1002
  if (refreshFirst) {
@@ -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,12 @@ 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
+ * Stamp the revoked record with {@linkcode RefreshToken.rotatedAt} here and
126
+ * return it, with its `familyId`, from {@linkcode getRevokedRefreshToken}.
127
+ * The refresh grant's `replayGraceSeconds` reads it, and the revocation
128
+ * endpoint revokes the family of a token presented after rotation replaced
129
+ * it, so a sign-out racing a refresh still ends the successor.
124
130
  */
125
131
  revokeRotated?(token: RefreshToken<Client, User, Scope>): Promise<boolean>;
126
132
  /**
@@ -135,19 +141,40 @@ export interface TokenServiceInterface<Client extends ClientInterface, User, Sco
135
141
  */
136
142
  revokeCode(code: string): Promise<boolean>;
137
143
  /**
138
- * Looks up a refresh token that has already been revoked/rotated out —
139
- * the signal the refresh grant's reuse detection needs. Implement this
140
- * (with {@linkcode revokeFamily}) by keeping revoked refresh-token records
141
- * findable instead of deleting them; when absent, reuse detection is
142
- * silently disabled.
144
+ * Looks up a refresh token that has already been revoked/rotated out. The
145
+ * refresh grant's reuse detection reads it, and so does the revocation
146
+ * endpoint, to revoke the family of a token rotation replaced. Implement
147
+ * this (with {@linkcode revokeFamily}) by keeping revoked refresh-token
148
+ * records findable instead of deleting them; when absent, reuse detection
149
+ * is silently disabled and `/revoke` cannot reach a successor.
150
+ *
151
+ * Return the tombstone unconditionally. Apply any replay grace through
152
+ * `RefreshTokenGrantOptions.replayGraceSeconds`, never by hiding recent
153
+ * tombstones here: a hidden tombstone also hides the family from a sign-out
154
+ * racing the refresh, which then leaves the successor live.
143
155
  */
144
156
  getRevokedRefreshToken?(refreshToken: string): Promise<RefreshToken<Client, User, Scope> | undefined>;
145
157
  /**
146
158
  * Revokes every token belonging to a rotation family — called by the
147
159
  * refresh grant when a rotated-out member is replayed, so a stolen token
148
- * can't keep a session alive. Returns true if anything was revoked.
160
+ * can't keep a session alive, and by the revocation endpoint when it is
161
+ * given a token whose tombstone carries {@linkcode RefreshToken.rotatedAt}.
162
+ * Returns true if anything was revoked.
163
+ *
164
+ * `/revoke` ends the family without firing `onTokenReuse`, so any audit
165
+ * trail for that case has to come from this method.
166
+ *
167
+ * Keep the family revoked: a successor that a concurrent rotation saves
168
+ * after this call must be stored already revoked, as
169
+ * `MemoryTokenService` does.
149
170
  */
150
171
  revokeFamily?(familyId: string): Promise<boolean>;
172
+ /**
173
+ * Whether the rotation family still has a live, unexpired refresh token.
174
+ * Read by the refresh grant's `replayGraceSeconds`: without it, the grace
175
+ * never applies and every replay is treated as reuse.
176
+ */
177
+ hasLiveRefreshToken?(familyId: string): Promise<boolean>;
151
178
  }
152
179
  /** Options for configuring an abstract token service. */
153
180
  export interface AbstractTokenServiceOptions<Client extends ClientInterface, User> {
@@ -49,7 +49,9 @@ export interface TokenServiceContractOptions<C extends ClientInterface, U extend
49
49
  * and {@linkcode TokenServiceInterface.revokeFamily}. The suite then
50
50
  * registers an ignored case naming what is not covered — a replayed
51
51
  * rotated-out refresh token cannot be detected, so a stolen token keeps
52
- * working alongside the victim's — instead of passing in silence. Defaults to
52
+ * working alongside the victim's, and revoking a rotated-out token at the
53
+ * revocation endpoint leaves its successor live — instead of passing in
54
+ * silence. Defaults to
53
55
  * `true`, so a service that meant to implement the pair and does not fails
54
56
  * loudly rather than losing reuse detection quietly.
55
57
  */
@@ -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,10 +273,63 @@ 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("finds a rotated-out token's family, so revoking it ends the live successor", async ({ skip, }) => {
289
+ skip(typeof tokenService.revokeRotated !== "function", "the service does not implement revokeRotated");
290
+ const rotatedOut = await saveFamilyMember(FAMILY);
291
+ expect(await tokenService.revokeRotated(rotatedOut)).toBe(true);
292
+ const successor = await saveFamilyMember(FAMILY);
293
+ const unrelated = await saveFamilyMember(OTHER_FAMILY);
294
+ const tombstone = await tokenService.getRevokedRefreshToken(rotatedOut.refreshToken);
295
+ assert(tombstone?.familyId === FAMILY && tombstone.rotatedAt, "the revocation endpoint finds the family to end from the " +
296
+ "rotated-out token's record; without its familyId and " +
297
+ "rotatedAt, a sign-out racing a refresh leaves the successor live");
298
+ expect(tombstone.client.id.toString(), "the endpoint revokes the family only for the client the token " +
299
+ "was issued to").toBe(client.id.toString());
300
+ expect(await tokenService.revokeFamily(tombstone.familyId)).toBe(true);
301
+ expect(await tokenService.getRefreshToken(successor.refreshToken)).toBe(undefined);
302
+ expect(await tokenService.getToken(successor.accessToken)).toBe(undefined);
303
+ assert((await tokenService.getRefreshToken(unrelated.refreshToken)) !==
304
+ undefined);
305
+ });
306
+ it("leaves rotatedAt unset on a token revoked other than by rotation", async () => {
307
+ const token = await saveFamilyMember(FAMILY);
308
+ await tokenService.revoke(token.refreshToken, "refresh_token");
309
+ const revoked = await tokenService.getRevokedRefreshToken(token.refreshToken);
310
+ expect(revoked?.rotatedAt, "a revoked token that looks rotated gets the replay grace a " +
311
+ "signed-out session must never have").toBe(undefined);
312
+ });
313
+ it("reports whether a family still has a live refresh token", async ({ skip, }) => {
314
+ skip(typeof tokenService.hasLiveRefreshToken !== "function", "the service does not implement hasLiveRefreshToken");
315
+ const token = await saveFamilyMember(FAMILY);
316
+ await saveFamilyMember(OTHER_FAMILY);
317
+ expect(await tokenService.hasLiveRefreshToken(FAMILY)).toBe(true);
318
+ await tokenService.revoke(token.refreshToken, "refresh_token");
319
+ expect(await tokenService.hasLiveRefreshToken(FAMILY), "a family whose last token was revoked has no successor to protect").toBe(false);
320
+ expect(await tokenService.hasLiveRefreshToken(OTHER_FAMILY)).toBe(true);
321
+ expect(await tokenService.hasLiveRefreshToken("never-issued")).toBe(false);
322
+ });
323
+ it("does not count an expired refresh token as live", async ({ skip, }) => {
324
+ skip(typeof tokenService.hasLiveRefreshToken !== "function", "the service does not implement hasLiveRefreshToken");
325
+ await saveFamilyMember(FAMILY, new Date(Date.now() - 1000));
326
+ expect(await tokenService.hasLiveRefreshToken(FAMILY), "an expired successor cannot be used, so a replay of its " +
327
+ "predecessor must not be excused as a straggler").toBe(false);
328
+ });
276
329
  });
277
330
  }
278
331
  else {
279
- it.skip("refresh-token reuse detection is not implemented — a replayed rotated-out token cannot be detected (refreshTokenReuseDetection: false)", () => { });
332
+ it.skip("refresh-token reuse detection is not implemented — a replayed rotated-out token cannot be detected, and revoking one does not end its successor (refreshTokenReuseDetection: false)", () => { });
280
333
  }
281
334
  });
282
335
  }
@@ -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
@@ -247,8 +256,10 @@ export declare class MemoryTokenService<C extends ClientInterface, U extends Mem
247
256
  */
248
257
  getRevokedRefreshToken(refreshToken: string): Promise<RefreshToken<C, U, S> | undefined>;
249
258
  /**
250
- * Revokes every live token in a rotation family (reuse detection response).
251
- * Resolves `true` when at least one live token was removed.
259
+ * Revokes every live token in a rotation family (reuse detection response)
260
+ * and keeps the family revoked, so a successor a concurrent rotation saves
261
+ * afterwards is stored already revoked. Resolves `true` when at least one
262
+ * live token was removed.
252
263
  */
253
264
  revokeFamily(familyId: string): Promise<boolean>;
254
265
  /**
@@ -207,6 +207,7 @@ export class MemoryTokenService {
207
207
  #tokensByRefreshHash = new Map();
208
208
  #tokensByCodeHash = new Map();
209
209
  #revokedByRefreshHash = new Map();
210
+ #revokedFamilies = new Set();
210
211
  /**
211
212
  * Creates an empty token store.
212
213
  *
@@ -290,6 +291,7 @@ export class MemoryTokenService {
290
291
  refreshTokenExpiresAt: stored.refreshTokenExpiresAt,
291
292
  familyId: stored.familyId,
292
293
  familyCreatedAt: stored.familyCreatedAt,
294
+ rotatedAt: stored.rotatedAt,
293
295
  };
294
296
  }
295
297
  return token;
@@ -339,6 +341,10 @@ export class MemoryTokenService {
339
341
  stored.refreshTokenExpiresAt = rt.refreshTokenExpiresAt;
340
342
  stored.familyId = rt.familyId;
341
343
  stored.familyCreatedAt = rt.familyCreatedAt;
344
+ if (rt.familyId && this.#revokedFamilies.has(rt.familyId)) {
345
+ this.#tombstone(stored.refreshTokenHash, stored);
346
+ return token;
347
+ }
342
348
  this.#tokensByRefreshHash.set(stored.refreshTokenHash, stored);
343
349
  }
344
350
  this.#tokensByAccessHash.set(accessTokenHash, stored);
@@ -367,6 +373,31 @@ export class MemoryTokenService {
367
373
  }
368
374
  return true;
369
375
  }
376
+ /**
377
+ * Revokes a refresh token the refresh grant just rotated out, as
378
+ * {@linkcode revoke} does, and stamps its tombstone with the rotation time
379
+ * so the grant's `replayGraceSeconds` can apply. Resolves `false` when the
380
+ * token was already claimed.
381
+ */
382
+ async revokeRotated(token) {
383
+ const stored = this.#tokensByRefreshHash.get(await sha256Hash(token.refreshToken));
384
+ if (!stored)
385
+ return false;
386
+ stored.rotatedAt = new Date();
387
+ return await this.revoke(token);
388
+ }
389
+ /** Whether the family has a live refresh token that has not expired. */
390
+ hasLiveRefreshToken(familyId) {
391
+ const now = Date.now();
392
+ for (const stored of this.#tokensByRefreshHash.values()) {
393
+ if (stored.familyId === familyId &&
394
+ (!stored.refreshTokenExpiresAt ||
395
+ stored.refreshTokenExpiresAt.getTime() > now)) {
396
+ return Promise.resolve(true);
397
+ }
398
+ }
399
+ return Promise.resolve(false);
400
+ }
370
401
  #tombstone(refreshTokenHash, stored) {
371
402
  this.#revokedByRefreshHash.set(refreshTokenHash, stored);
372
403
  }
@@ -382,10 +413,13 @@ export class MemoryTokenService {
382
413
  return this.#hydrate(stored, "", refreshToken);
383
414
  }
384
415
  /**
385
- * Revokes every live token in a rotation family (reuse detection response).
386
- * Resolves `true` when at least one live token was removed.
416
+ * Revokes every live token in a rotation family (reuse detection response)
417
+ * and keeps the family revoked, so a successor a concurrent rotation saves
418
+ * afterwards is stored already revoked. Resolves `true` when at least one
419
+ * live token was removed.
387
420
  */
388
421
  revokeFamily(familyId) {
422
+ this.#revokedFamilies.add(familyId);
389
423
  let revoked = false;
390
424
  for (const [hash, stored] of this.#tokensByRefreshHash) {
391
425
  if (stored.familyId === familyId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@udibo/oauth2",
3
- "version": "0.21.3",
3
+ "version": "0.23.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",