@udibo/oauth2 0.22.0 → 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.
@@ -67,10 +67,10 @@ export interface RefreshToken<Client extends ClientInterface, User, Scope extend
67
67
  */
68
68
  familyCreatedAt?: Date;
69
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.
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
74
  */
75
75
  rotatedAt?: Date;
76
76
  }
@@ -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) {
@@ -122,9 +122,11 @@ export interface TokenServiceInterface<Client extends ClientInterface, User, Sco
122
122
  * cannot terminate the very session it renews. When absent, the refresh
123
123
  * grant falls back to {@linkcode revoke}.
124
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}.
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.
128
130
  */
129
131
  revokeRotated?(token: RefreshToken<Client, User, Scope>): Promise<boolean>;
130
132
  /**
@@ -139,17 +141,32 @@ export interface TokenServiceInterface<Client extends ClientInterface, User, Sco
139
141
  */
140
142
  revokeCode(code: string): Promise<boolean>;
141
143
  /**
142
- * Looks up a refresh token that has already been revoked/rotated out —
143
- * the signal the refresh grant's reuse detection needs. Implement this
144
- * (with {@linkcode revokeFamily}) by keeping revoked refresh-token records
145
- * findable instead of deleting them; when absent, reuse detection is
146
- * 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.
147
155
  */
148
156
  getRevokedRefreshToken?(refreshToken: string): Promise<RefreshToken<Client, User, Scope> | undefined>;
149
157
  /**
150
158
  * Revokes every token belonging to a rotation family — called by the
151
159
  * refresh grant when a rotated-out member is replayed, so a stolen token
152
- * 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.
153
170
  */
154
171
  revokeFamily?(familyId: string): Promise<boolean>;
155
172
  /**
@@ -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
  */
@@ -285,6 +285,24 @@ export function runTokenServiceContractTests(options) {
285
285
  revoked.rotatedAt.getTime() <= Date.now() + 1000, "without rotatedAt the refresh grant's replayGraceSeconds never " +
286
286
  "applies, so every straggler revokes its family");
287
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
+ });
288
306
  it("leaves rotatedAt unset on a token revoked other than by rotation", async () => {
289
307
  const token = await saveFamilyMember(FAMILY);
290
308
  await tokenService.revoke(token.refreshToken, "refresh_token");
@@ -311,7 +329,7 @@ export function runTokenServiceContractTests(options) {
311
329
  });
312
330
  }
313
331
  else {
314
- 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)", () => { });
315
333
  }
316
334
  });
317
335
  }
@@ -256,8 +256,10 @@ export declare class MemoryTokenService<C extends ClientInterface, U extends Mem
256
256
  */
257
257
  getRevokedRefreshToken(refreshToken: string): Promise<RefreshToken<C, U, S> | undefined>;
258
258
  /**
259
- * Revokes every live token in a rotation family (reuse detection response).
260
- * 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.
261
263
  */
262
264
  revokeFamily(familyId: string): Promise<boolean>;
263
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
  *
@@ -340,6 +341,10 @@ export class MemoryTokenService {
340
341
  stored.refreshTokenExpiresAt = rt.refreshTokenExpiresAt;
341
342
  stored.familyId = rt.familyId;
342
343
  stored.familyCreatedAt = rt.familyCreatedAt;
344
+ if (rt.familyId && this.#revokedFamilies.has(rt.familyId)) {
345
+ this.#tombstone(stored.refreshTokenHash, stored);
346
+ return token;
347
+ }
343
348
  this.#tokensByRefreshHash.set(stored.refreshTokenHash, stored);
344
349
  }
345
350
  this.#tokensByAccessHash.set(accessTokenHash, stored);
@@ -408,10 +413,13 @@ export class MemoryTokenService {
408
413
  return this.#hydrate(stored, "", refreshToken);
409
414
  }
410
415
  /**
411
- * Revokes every live token in a rotation family (reuse detection response).
412
- * 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.
413
420
  */
414
421
  revokeFamily(familyId) {
422
+ this.#revokedFamilies.add(familyId);
415
423
  let revoked = false;
416
424
  for (const [hash, stored] of this.#tokensByRefreshHash) {
417
425
  if (stored.familyId === familyId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@udibo/oauth2",
3
- "version": "0.22.0",
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",