@udibo/oauth2 0.24.0 → 0.25.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.
@@ -86,10 +86,14 @@ export declare class RefreshTokenGrant<Client extends ClientInterface, User, S e
86
86
  * token (and rotates the refresh token when the token service supports it)
87
87
  * from a valid, unexpired refresh token.
88
88
  *
89
- * Rotation revokes the exchanged token through the token service's
90
- * `revokeRotated` when implemented, falling back to `revoke` — so a service
91
- * whose `revoke` ends the owning session can keep this bookkeeping step
92
- * from signing the person out.
89
+ * Rotation claims the exchanged token and saves its successor in one call
90
+ * to the token service's `rotate` when implemented. Otherwise it takes two
91
+ * calls: it revokes the exchanged token through `revokeRotated` when
92
+ * implemented, falling back to `revoke` — so a service whose `revoke` ends
93
+ * the owning session can keep this bookkeeping step from signing the
94
+ * person out — and then saves the successor. A refresh that presents the
95
+ * exchanged token between those two calls is treated as reuse
96
+ * ({@linkcode TokenServiceInterface.rotate} describes the cost).
93
97
  *
94
98
  * Rotation renews the refresh token's sliding lifetime, bounded by the
95
99
  * family's absolute cap when the token service implements
@@ -32,10 +32,14 @@ export class RefreshTokenGrant extends AbstractGrant {
32
32
  * token (and rotates the refresh token when the token service supports it)
33
33
  * from a valid, unexpired refresh token.
34
34
  *
35
- * Rotation revokes the exchanged token through the token service's
36
- * `revokeRotated` when implemented, falling back to `revoke` — so a service
37
- * whose `revoke` ends the owning session can keep this bookkeeping step
38
- * from signing the person out.
35
+ * Rotation claims the exchanged token and saves its successor in one call
36
+ * to the token service's `rotate` when implemented. Otherwise it takes two
37
+ * calls: it revokes the exchanged token through `revokeRotated` when
38
+ * implemented, falling back to `revoke` — so a service whose `revoke` ends
39
+ * the owning session can keep this bookkeeping step from signing the
40
+ * person out — and then saves the successor. A refresh that presents the
41
+ * exchanged token between those two calls is treated as reuse
42
+ * ({@linkcode TokenServiceInterface.rotate} describes the cost).
39
43
  *
40
44
  * Rotation renews the refresh token's sliding lifetime, bounded by the
41
45
  * family's absolute cap when the token service implements
@@ -122,6 +126,13 @@ export class RefreshTokenGrant extends AbstractGrant {
122
126
  if (familyExpiresAt) {
123
127
  this.capAtFamilyExpiry(nextRefreshToken, familyExpiresAt);
124
128
  }
129
+ if (tokenService.rotate) {
130
+ const rotated = await tokenService.rotate(currentToken, nextRefreshToken);
131
+ if (!rotated) {
132
+ throw new InvalidGrantError("refresh_token was already consumed");
133
+ }
134
+ return rotated;
135
+ }
125
136
  const claimed = tokenService.revokeRotated
126
137
  ? await tokenService.revokeRotated(currentToken)
127
138
  : await tokenService.revoke(currentToken);
@@ -129,6 +129,32 @@ export interface TokenServiceInterface<Client extends ClientInterface, User, Sco
129
129
  * it, so a sign-out racing a refresh still ends the successor.
130
130
  */
131
131
  revokeRotated?(token: RefreshToken<Client, User, Scope>): Promise<boolean>;
132
+ /**
133
+ * Rotates a refresh token in one atomic step: claims `current` and persists
134
+ * `next`, its successor in the same family, or does neither. Resolves the
135
+ * saved successor, or `undefined` with nothing persisted when `current` was
136
+ * already claimed or revoked, or its family has been revoked. Concurrent
137
+ * calls for one `current` produce exactly one successor.
138
+ *
139
+ * When implemented, the refresh grant calls it instead of
140
+ * {@linkcode revokeRotated} (or {@linkcode revoke}) followed by
141
+ * {@linkcode save}. Without it, the grant claims and saves in two calls,
142
+ * and a request that presents `current` between them finds it revoked with
143
+ * no live successor: reuse detection revokes the family and fires
144
+ * `onTokenReuse`, so a benign concurrent refresh signs the person out, and a
145
+ * store whose `revokeFamily` does not keep the family revoked leaves the
146
+ * successor live.
147
+ *
148
+ * Treat the claim as a rotation, never an end of grant: stamp the
149
+ * rotated-out record with {@linkcode RefreshToken.rotatedAt} exactly as
150
+ * {@linkcode revokeRotated} does, keep it findable through
151
+ * {@linkcode getRevokedRefreshToken}, and revoke its access token as
152
+ * `revokeRotated` would. Make it linearizable with
153
+ * {@linkcode revokeFamily}, as that method requires.
154
+ * `runTokenRotationContractTests` checks it under concurrent callers, with
155
+ * the store's barrier wired between its claim and its insert.
156
+ */
157
+ rotate?(current: RefreshToken<Client, User, Scope>, next: RefreshToken<Client, User, Scope>): Promise<RefreshToken<Client, User, Scope> | undefined>;
132
158
  /**
133
159
  * Revokes all tokens issued with the given authorization code.
134
160
  * Returns true if any tokens were revoked (indicating code replay).
@@ -164,9 +190,17 @@ export interface TokenServiceInterface<Client extends ClientInterface, User, Sco
164
190
  * `/revoke` ends the family without firing `onTokenReuse`, so any audit
165
191
  * trail for that case has to come from this method.
166
192
  *
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.
193
+ * Without {@linkcode rotate}, keep the family revoked: a successor that a
194
+ * concurrent rotation saves after this call must be stored already revoked,
195
+ * as `MemoryTokenService` does. A store that implements `rotate` meets this
196
+ * through the linearizability below, since the refresh grant then never
197
+ * saves a rotation's successor separately.
198
+ *
199
+ * With {@linkcode rotate}, make the two linearizable: once this resolves,
200
+ * the family has no live member and no later `rotate` of a member
201
+ * succeeds, and a `rotate` that committed before this began has its
202
+ * successor revoked. A store whose `rotate` can be paused between its claim
203
+ * and its insert meets this by taking one per-family lock in both methods.
170
204
  */
171
205
  revokeFamily?(familyId: string): Promise<boolean>;
172
206
  /**
@@ -24,6 +24,12 @@
24
24
  * also have separate suites. They use app-owned fixture helpers to verify
25
25
  * conditional credential writes and immediate, user-scoped session revocation.
26
26
  *
27
+ * `TokenServiceInterface.rotate` has a runner of its own for concurrent
28
+ * schedules: two rotations of one refresh token, a family revocation racing a
29
+ * rotation, and a replay racing a refresh. A store that awaits between its
30
+ * claim and its insert wires `TokenRotationBarriers` into `rotate`, so the
31
+ * suite can hold a rotation there.
32
+ *
27
33
  * The service runners take a factory plus seed helpers because `add` /
28
34
  * `register` methods aren't part of the public service interfaces — the
29
35
  * consumer tells the runner how to populate test data.
@@ -33,6 +39,7 @@
33
39
  export { runUserServiceContractTests, type UserServiceContractOptions, } from "./user.ts";
34
40
  export { type ClientServiceContractOptions, runClientServiceContractTests, } from "./client.ts";
35
41
  export { runTokenServiceContractTests, type TokenServiceContractOptions, } from "./token.ts";
42
+ export { runTokenRotationContractTests, type TokenRotationBarriers, type TokenRotationContractOptions, type TokenRotationContractServices, } from "./token-rotation.ts";
36
43
  export { runTokenReaderContractTests, type TokenReaderContractFixture, type TokenReaderContractOptions, } from "./token-reader.ts";
37
44
  export { type AuthorizationCodeServiceContractOptions, runAuthorizationCodeServiceContractTests, } from "./authorization-code.ts";
38
45
  export { type DeviceAuthorizationServiceContractOptions, runDeviceAuthorizationServiceContractTests, } from "./device-authorization.ts";
@@ -24,6 +24,12 @@
24
24
  * also have separate suites. They use app-owned fixture helpers to verify
25
25
  * conditional credential writes and immediate, user-scoped session revocation.
26
26
  *
27
+ * `TokenServiceInterface.rotate` has a runner of its own for concurrent
28
+ * schedules: two rotations of one refresh token, a family revocation racing a
29
+ * rotation, and a replay racing a refresh. A store that awaits between its
30
+ * claim and its insert wires `TokenRotationBarriers` into `rotate`, so the
31
+ * suite can hold a rotation there.
32
+ *
27
33
  * The service runners take a factory plus seed helpers because `add` /
28
34
  * `register` methods aren't part of the public service interfaces — the
29
35
  * consumer tells the runner how to populate test data.
@@ -33,6 +39,7 @@
33
39
  export { runUserServiceContractTests, } from "./user.js";
34
40
  export { runClientServiceContractTests, } from "./client.js";
35
41
  export { runTokenServiceContractTests, } from "./token.js";
42
+ export { runTokenRotationContractTests, } from "./token-rotation.js";
36
43
  export { runTokenReaderContractTests, } from "./token-reader.js";
37
44
  export { runAuthorizationCodeServiceContractTests, } from "./authorization-code.js";
38
45
  export { runDeviceAuthorizationServiceContractTests, } from "./device-authorization.js";
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Contract test suite for {@link TokenServiceInterface.rotate} and its
3
+ * linearizability with {@link TokenServiceInterface.revokeFamily}.
4
+ *
5
+ * `runTokenServiceContractTests` checks what one caller sees. This suite
6
+ * checks what concurrent callers see: two rotations of one refresh token, a
7
+ * family revocation racing a rotation, a replay racing a refresh through the
8
+ * refresh grant, a rotation in an unrelated family, and a rotation whose
9
+ * insert fails. Each case asserts the store's end state, the live tokens of
10
+ * the family, not only the value each call resolved.
11
+ *
12
+ * The store under test awaits {@link TokenRotationBarriers.afterClaim} inside
13
+ * `rotate`, between its claim and its insert, so the suite can hold a rotation
14
+ * there and race the other call against it; a store that never reaches the
15
+ * barrier fails every held case. Only a store whose `rotate` never awaits
16
+ * between its claim and its insert, such as `MemoryTokenService`, opts out with
17
+ * `rotateAwaitsBarrier: false`; the suite then runs those schedules as
18
+ * overlapping calls.
19
+ *
20
+ * @module
21
+ */
22
+ import type { ClientInterface } from "../../models/client.ts";
23
+ import type { AbstractScope, BasicScope } from "../../models/scope.ts";
24
+ import type { ClientServiceInterface } from "../../server/services/client.ts";
25
+ import type { TokenServiceInterface } from "../../server/services/token.ts";
26
+ import type { UserServiceInterface } from "../../server/services/user.ts";
27
+ import type { MemoryUserShape } from "../services.ts";
28
+ /**
29
+ * The point inside a store's {@linkcode TokenServiceInterface.rotate} at which
30
+ * {@link runTokenRotationContractTests} holds a rotation. Wire it into a test
31
+ * build of the store.
32
+ */
33
+ export interface TokenRotationBarriers {
34
+ /**
35
+ * Await this inside `rotate` after claiming `current` and before persisting
36
+ * `next`, still inside the atomic step: within the transaction and holding
37
+ * the family's lock. When it rejects, fail the rotation as a failed insert
38
+ * would: leave `current` live, persist nothing, and reject or resolve
39
+ * `undefined`.
40
+ */
41
+ afterClaim(): Promise<void>;
42
+ }
43
+ /** Services built for each case of {@link runTokenRotationContractTests}. */
44
+ export interface TokenRotationContractServices<C extends ClientInterface, U extends MemoryUserShape, S extends AbstractScope = BasicScope> {
45
+ /** The user service the token service hydrates users from. */
46
+ userService: UserServiceInterface<U>;
47
+ /** The client service the token service hydrates clients from. */
48
+ clientService: ClientServiceInterface<C, U>;
49
+ /** The token service under test. It must implement `rotate`. */
50
+ tokenService: TokenServiceInterface<C, U, S>;
51
+ }
52
+ /** Options for {@link runTokenRotationContractTests}. */
53
+ export interface TokenRotationContractOptions<C extends ClientInterface, U extends MemoryUserShape, S extends AbstractScope = BasicScope> {
54
+ /**
55
+ * Returns fresh services for each case, with `rotate` awaiting
56
+ * `barriers.afterClaim()` between its claim and its insert. A store that
57
+ * sets `rotateAwaitsBarrier: false` ignores `barriers`.
58
+ */
59
+ makeServices(barriers: TokenRotationBarriers): Promise<TokenRotationContractServices<C, U, S>> | TokenRotationContractServices<C, U, S>;
60
+ /** Persists a user with the given password into the user service under test. */
61
+ addUser(service: UserServiceInterface<U>, user: U, password: string): Promise<void>;
62
+ /** Persists a client into the client service under test. */
63
+ addClient(service: ClientServiceInterface<C, U>, client: C): Promise<void>;
64
+ /** Builds a distinct user fixture for the given sequence number. Defaults to a minimal `{ id, username }` shape. */
65
+ makeUser?(seq: number): U;
66
+ /** Builds a distinct client fixture for the given sequence number. Defaults to a client granting `authorization_code` and `refresh_token`. */
67
+ makeClient?(seq: number): C;
68
+ /**
69
+ * Whether `rotate` awaits `barriers.afterClaim()`. Defaults to `true`: the
70
+ * suite holds a rotation between its claim and its insert while it runs a
71
+ * family revocation, a second rotation, a replayed refresh, or a rotation in
72
+ * another family, and fails the insert there. Set `false` only when
73
+ * `rotate` never awaits between its claim and its insert, as in
74
+ * `MemoryTokenService`; the suite then runs those schedules as overlapping
75
+ * calls, which cannot reach a gap inside `rotate`.
76
+ */
77
+ rotateAwaitsBarrier?: boolean;
78
+ /**
79
+ * Milliseconds the suite lets a call run against a held rotation before it
80
+ * releases the rotation; a call that has not settled by then is taken to
81
+ * be waiting on it. Defaults to 100. Raise it for a store whose calls take
82
+ * longer to reach the lock they wait on.
83
+ */
84
+ blockedAfterMs?: number;
85
+ /** Overrides the name passed to the outer `describe` block. */
86
+ describeName?: string;
87
+ }
88
+ /**
89
+ * Runs the rotation contract suite: concurrent schedules of
90
+ * {@linkcode TokenServiceInterface.rotate} against itself, against
91
+ * {@linkcode TokenServiceInterface.revokeFamily}, and through the refresh
92
+ * grant. The service must implement `rotate`, `revokeFamily` and
93
+ * `getRevokedRefreshToken`.
94
+ */
95
+ export declare function runTokenRotationContractTests<C extends ClientInterface, U extends MemoryUserShape, S extends AbstractScope = BasicScope>(options: TokenRotationContractOptions<C, U, S>): void;
@@ -0,0 +1,383 @@
1
+ /**
2
+ * Contract test suite for {@link TokenServiceInterface.rotate} and its
3
+ * linearizability with {@link TokenServiceInterface.revokeFamily}.
4
+ *
5
+ * `runTokenServiceContractTests` checks what one caller sees. This suite
6
+ * checks what concurrent callers see: two rotations of one refresh token, a
7
+ * family revocation racing a rotation, a replay racing a refresh through the
8
+ * refresh grant, a rotation in an unrelated family, and a rotation whose
9
+ * insert fails. Each case asserts the store's end state, the live tokens of
10
+ * the family, not only the value each call resolved.
11
+ *
12
+ * The store under test awaits {@link TokenRotationBarriers.afterClaim} inside
13
+ * `rotate`, between its claim and its insert, so the suite can hold a rotation
14
+ * there and race the other call against it; a store that never reaches the
15
+ * barrier fails every held case. Only a store whose `rotate` never awaits
16
+ * between its claim and its insert, such as `MemoryTokenService`, opts out with
17
+ * `rotateAwaitsBarrier: false`; the suite then runs those schedules as
18
+ * overlapping calls.
19
+ *
20
+ * @module
21
+ */
22
+ import { assert, beforeEach, describe, expect, it } from "vitest";
23
+ import { InvalidGrantError } from "../../errors.js";
24
+ import { RefreshTokenGrant } from "../../server/grants/refresh-token.js";
25
+ import { race } from "./_race.js";
26
+ const defaultMakeUser = (seq) => ({ id: `u${seq}`, username: `user${seq}` });
27
+ const defaultMakeClient = (seq) => ({
28
+ id: `client-${seq}`,
29
+ grants: ["authorization_code", "refresh_token"],
30
+ });
31
+ async function settledWithin(promise, ms) {
32
+ let timer;
33
+ const timeout = new Promise((resolve) => {
34
+ timer = setTimeout(() => resolve(false), ms);
35
+ });
36
+ try {
37
+ return await Promise.race([
38
+ promise.then(() => true, () => true),
39
+ timeout,
40
+ ]);
41
+ }
42
+ finally {
43
+ clearTimeout(timer);
44
+ }
45
+ }
46
+ /**
47
+ * Runs the rotation contract suite: concurrent schedules of
48
+ * {@linkcode TokenServiceInterface.rotate} against itself, against
49
+ * {@linkcode TokenServiceInterface.revokeFamily}, and through the refresh
50
+ * grant. The service must implement `rotate`, `revokeFamily` and
51
+ * `getRevokedRefreshToken`.
52
+ */
53
+ export function runTokenRotationContractTests(options) {
54
+ const makeUser = options.makeUser ?? defaultMakeUser;
55
+ const makeClient = options.makeClient ?? defaultMakeClient;
56
+ const pausable = options.rotateAwaitsBarrier ?? true;
57
+ const blockedAfterMs = options.blockedAfterMs ?? 100;
58
+ describe(options.describeName ?? "TokenServiceInterface.rotate contract", () => {
59
+ let clientService;
60
+ let tokenService;
61
+ let user;
62
+ let client;
63
+ let armed;
64
+ let family;
65
+ let otherFamily;
66
+ const barriers = {
67
+ async afterClaim() {
68
+ const hold = armed;
69
+ if (!hold)
70
+ return;
71
+ armed = undefined;
72
+ hold.reached.resolve();
73
+ await hold.release.promise;
74
+ },
75
+ };
76
+ beforeEach(async () => {
77
+ armed = undefined;
78
+ family = crypto.randomUUID();
79
+ otherFamily = crypto.randomUUID();
80
+ const services = await options.makeServices(barriers);
81
+ clientService = services.clientService;
82
+ tokenService = services.tokenService;
83
+ user = makeUser(1);
84
+ client = makeClient(1);
85
+ await options.addUser(services.userService, user, "pw");
86
+ await options.addClient(clientService, client);
87
+ });
88
+ function rotate(current, next) {
89
+ assert(typeof tokenService.rotate === "function", "runTokenRotationContractTests needs a token service that implements rotate");
90
+ return tokenService.rotate(current, next);
91
+ }
92
+ async function member(familyId) {
93
+ return {
94
+ accessToken: await tokenService.generateAccessToken(client, user),
95
+ accessTokenExpiresAt: new Date(Date.now() + 60_000),
96
+ refreshToken: (await tokenService.generateRefreshToken(client, user)),
97
+ refreshTokenExpiresAt: new Date(Date.now() + 600_000),
98
+ familyId,
99
+ familyCreatedAt: new Date(Date.now() - 60_000),
100
+ client,
101
+ user,
102
+ };
103
+ }
104
+ async function savedMember(familyId) {
105
+ const token = await member(familyId);
106
+ await tokenService.save(token);
107
+ return token;
108
+ }
109
+ async function live(tokens) {
110
+ const found = [];
111
+ for (const token of tokens) {
112
+ if ((await tokenService.getRefreshToken(token.refreshToken)) ||
113
+ (await tokenService.getToken(token.accessToken))) {
114
+ found.push(token);
115
+ }
116
+ }
117
+ return found;
118
+ }
119
+ async function liveOf(refreshTokens) {
120
+ const found = [];
121
+ for (const refreshToken of refreshTokens) {
122
+ if (await tokenService.getRefreshToken(refreshToken)) {
123
+ found.push(refreshToken);
124
+ }
125
+ }
126
+ return found;
127
+ }
128
+ function arm() {
129
+ const hold = {
130
+ reached: Promise.withResolvers(),
131
+ release: Promise.withResolvers(),
132
+ };
133
+ armed = hold;
134
+ return hold;
135
+ }
136
+ async function untilHeld(hold, rotation, unheld = "rotate settled without awaiting barriers.afterClaim; wire " +
137
+ "the barrier into rotate between its claim and its insert, or, " +
138
+ "only if rotate never awaits between them, set " +
139
+ "rotateAwaitsBarrier: false") {
140
+ const outcome = await Promise.race([
141
+ hold.reached.promise.then(() => "held"),
142
+ rotation.then(() => "settled", () => "settled"),
143
+ ]);
144
+ assert(outcome === "held", unheld);
145
+ }
146
+ /**
147
+ * Runs `during` while `rotate(current, next)` is held after its claim,
148
+ * or, for a store that cannot be held, while the rotation is in flight.
149
+ */
150
+ async function whileRotating(current, next, during) {
151
+ if (!pausable) {
152
+ const rotation = rotate(current, next);
153
+ const other = during();
154
+ return await Promise.all([
155
+ Promise.allSettled([rotation]).then(([r]) => r),
156
+ Promise.allSettled([other]).then(([o]) => o),
157
+ ]);
158
+ }
159
+ const hold = arm();
160
+ const rotation = rotate(current, next);
161
+ await untilHeld(hold, rotation);
162
+ const other = during();
163
+ await settledWithin(other, blockedAfterMs);
164
+ hold.release.resolve();
165
+ return await Promise.all([
166
+ Promise.allSettled([rotation]).then(([r]) => r),
167
+ Promise.allSettled([other]).then(([o]) => o),
168
+ ]);
169
+ }
170
+ /**
171
+ * Wraps the token service to record every refresh token the grant
172
+ * generates, and to run `beforeRotate` before each `rotate` reaches the
173
+ * store.
174
+ */
175
+ function observe(beforeRotate) {
176
+ const issued = [];
177
+ const observed = new Proxy(tokenService, {
178
+ get(target, prop) {
179
+ if (prop === "generateRefreshToken") {
180
+ return async (...args) => {
181
+ const value = await target.generateRefreshToken(...args);
182
+ if (value)
183
+ issued.push(value);
184
+ return value;
185
+ };
186
+ }
187
+ if (prop === "rotate" && beforeRotate) {
188
+ return async (current, next) => {
189
+ await beforeRotate();
190
+ return await rotate(current, next);
191
+ };
192
+ }
193
+ const value = Reflect.get(target, prop, target);
194
+ return typeof value === "function" ? value.bind(target) : value;
195
+ },
196
+ });
197
+ return { observed, issued };
198
+ }
199
+ function refreshGrant(service, reuseEvents) {
200
+ return new RefreshTokenGrant({
201
+ resolve: () => ({ clientService, tokenService: service }),
202
+ onTokenReuse: (event) => {
203
+ reuseEvents.push(event);
204
+ },
205
+ });
206
+ }
207
+ function refresh(grant, refreshToken) {
208
+ const body = new FormData();
209
+ body.set("grant_type", "refresh_token");
210
+ body.set("refresh_token", refreshToken);
211
+ return grant.token(new Request("https://as.test/token", { method: "POST" }), client, body);
212
+ }
213
+ it("implements rotate, revokeFamily and getRevokedRefreshToken", () => {
214
+ assert(typeof tokenService.rotate === "function" &&
215
+ typeof tokenService.revokeFamily === "function" &&
216
+ typeof tokenService.getRevokedRefreshToken === "function", "the rotation contract covers rotate together with reuse detection");
217
+ });
218
+ it("refuses to rotate a member of a family revoked before it began", async () => {
219
+ const parent = await savedMember(family);
220
+ const next = await member(family);
221
+ await tokenService.revokeFamily(family);
222
+ expect(await rotate(parent, next)).toBe(undefined);
223
+ expect(await live([parent, next]), "a rotation that starts after its family was revoked must not " +
224
+ "give the family a live member again").toStrictEqual([]);
225
+ });
226
+ it("leaves no live member when the family is revoked while a rotation is in flight", async () => {
227
+ const parent = await savedMember(family);
228
+ const next = await member(family);
229
+ const [rotated, revoked] = await whileRotating(parent, next, () => tokenService.revokeFamily(family));
230
+ assert(revoked.status === "fulfilled");
231
+ assert(rotated.status === "fulfilled");
232
+ expect([undefined, next.refreshToken]).toContain(rotated.value?.refreshToken);
233
+ expect(await live([parent, next]), "revokeFamily must wait for a rotation holding the family, or the " +
234
+ "successor that rotation commits afterwards stays live in a " +
235
+ "family the server revoked").toStrictEqual([]);
236
+ });
237
+ it("revokes the successor of a rotation that committed before the family was revoked", async () => {
238
+ const parent = await savedMember(family);
239
+ const next = await member(family);
240
+ expect((await rotate(parent, next))?.refreshToken).toBe(next.refreshToken);
241
+ expect(await tokenService.revokeFamily(family)).toBe(true);
242
+ expect(await live([parent, next])).toStrictEqual([]);
243
+ });
244
+ it("gives concurrent rotations of one refresh token exactly one successor", async () => {
245
+ const parent = await savedMember(family);
246
+ const successors = await race(() => member(family));
247
+ const results = await race((caller) => rotate(parent, successors[caller]));
248
+ expect(results.filter((result) => result !== undefined).length, "rotate must claim its parent once, however many callers race").toBe(1);
249
+ expect(await live([parent, ...successors])).toHaveLength(1);
250
+ });
251
+ it("refuses a second rotation that starts while the first is held after its claim", async ({ skip, }) => {
252
+ skip(!pausable, "rotate cannot be held (rotateAwaitsBarrier: false)");
253
+ const parent = await savedMember(family);
254
+ const first = await member(family);
255
+ const second = await member(family);
256
+ const [held, racing] = await whileRotating(parent, first, () => rotate(parent, second));
257
+ assert(held.status === "fulfilled" && racing.status === "fulfilled");
258
+ expect(held.value?.refreshToken).toBe(first.refreshToken);
259
+ expect(racing.value).toBe(undefined);
260
+ expect(await live([parent, first, second])).toStrictEqual([first]);
261
+ });
262
+ it("answers a refresh that loses the claim to a concurrent one with invalid_grant, keeping the winner's successor", async () => {
263
+ const parent = await savedMember(family);
264
+ const held = Promise.withResolvers();
265
+ const release = Promise.withResolvers();
266
+ let rotations = 0;
267
+ const { observed, issued } = observe(async () => {
268
+ if (rotations++ === 0) {
269
+ held.resolve();
270
+ await release.promise;
271
+ }
272
+ });
273
+ const reuseEvents = [];
274
+ const grant = refreshGrant(observed, reuseEvents);
275
+ const first = refresh(grant, parent.refreshToken);
276
+ await untilHeld({ reached: held }, first, "the refresh grant settled without calling rotate");
277
+ const second = await refresh(grant, parent.refreshToken);
278
+ release.resolve();
279
+ const firstOutcome = await Promise.allSettled([first]);
280
+ assert(firstOutcome[0].status === "rejected");
281
+ expect(firstOutcome[0].reason).toBeInstanceOf(InvalidGrantError);
282
+ expect(reuseEvents, "losing the claim is a race, not a replay of a rotated-out token").toStrictEqual([]);
283
+ const liveRefreshTokens = await liveOf([
284
+ parent.refreshToken,
285
+ ...issued,
286
+ ]);
287
+ expect(liveRefreshTokens).toStrictEqual([second.refreshToken]);
288
+ });
289
+ it("answers a replay that arrives while a rotation is held after its claim with invalid_grant, without revoking the family", async ({ skip, }) => {
290
+ skip(!pausable, "rotate cannot be held (rotateAwaitsBarrier: false)");
291
+ const parent = await savedMember(family);
292
+ const { observed, issued } = observe();
293
+ const reuseEvents = [];
294
+ const grant = refreshGrant(observed, reuseEvents);
295
+ const hold = arm();
296
+ const first = refresh(grant, parent.refreshToken);
297
+ await untilHeld(hold, first);
298
+ const replay = refresh(grant, parent.refreshToken);
299
+ await settledWithin(replay, blockedAfterMs);
300
+ hold.release.resolve();
301
+ const [firstOutcome, replayOutcome] = await Promise.allSettled([
302
+ first,
303
+ replay,
304
+ ]);
305
+ assert(firstOutcome.status === "fulfilled");
306
+ assert(replayOutcome.status === "rejected");
307
+ expect(replayOutcome.reason).toBeInstanceOf(InvalidGrantError);
308
+ expect(reuseEvents, "a replay inside the claim-to-insert gap found no live successor " +
309
+ "and was taken for theft").toStrictEqual([]);
310
+ const liveRefreshTokens = await liveOf([
311
+ parent.refreshToken,
312
+ ...issued,
313
+ ]);
314
+ expect(liveRefreshTokens).toStrictEqual([
315
+ firstOutcome.value.refreshToken,
316
+ ]);
317
+ });
318
+ it("does not hold a rotation in another family behind a held one", async () => {
319
+ const parent = await savedMember(family);
320
+ const next = await member(family);
321
+ const otherParent = await savedMember(otherFamily);
322
+ const otherNext = await member(otherFamily);
323
+ if (!pausable) {
324
+ const [rotated, other] = await Promise.all([
325
+ rotate(parent, next),
326
+ rotate(otherParent, otherNext),
327
+ ]);
328
+ expect(rotated?.refreshToken).toBe(next.refreshToken);
329
+ expect(other?.refreshToken).toBe(otherNext.refreshToken);
330
+ return;
331
+ }
332
+ const hold = arm();
333
+ const rotation = rotate(parent, next);
334
+ await untilHeld(hold, rotation);
335
+ const other = rotate(otherParent, otherNext);
336
+ const settled = await settledWithin(other, 5_000);
337
+ hold.release.resolve();
338
+ expect(settled, "the family lock must be keyed by family, so one login's " +
339
+ "rotation never waits on another's").toBe(true);
340
+ expect((await other)?.refreshToken).toBe(otherNext.refreshToken);
341
+ expect((await rotation)?.refreshToken).toBe(next.refreshToken);
342
+ });
343
+ it("leaves the parent live and persists nothing when the successor cannot be persisted", async () => {
344
+ const parent = await savedMember(family);
345
+ const unpersistable = await member(family);
346
+ Object.defineProperty(unpersistable, "refreshToken", {
347
+ enumerable: true,
348
+ get() {
349
+ throw new Error("forced persistence failure");
350
+ },
351
+ });
352
+ const outcome = await Promise.allSettled([
353
+ rotate(parent, unpersistable),
354
+ ]);
355
+ if (outcome[0].status === "fulfilled") {
356
+ expect(outcome[0].value).toBe(undefined);
357
+ }
358
+ expect(await tokenService.getRefreshToken(parent.refreshToken), "a failed rotation must not spend its parent").not.toBe(undefined);
359
+ expect(await tokenService.getToken(unpersistable.accessToken)).toBe(undefined);
360
+ const retry = await member(family);
361
+ expect((await rotate(parent, retry))?.refreshToken).toBe(retry.refreshToken);
362
+ });
363
+ it("rolls the claim back when the insert fails after it", async ({ skip, }) => {
364
+ skip(!pausable, "rotate cannot be held (rotateAwaitsBarrier: false)");
365
+ const parent = await savedMember(family);
366
+ const next = await member(family);
367
+ const hold = arm();
368
+ const rotation = rotate(parent, next);
369
+ await untilHeld(hold, rotation);
370
+ hold.release.reject(new Error("forced insert failure"));
371
+ const outcome = await Promise.allSettled([rotation]);
372
+ if (outcome[0].status === "fulfilled") {
373
+ expect(outcome[0].value).toBe(undefined);
374
+ }
375
+ expect(await tokenService.getRefreshToken(parent.refreshToken), "the claim and the insert are one step: a failed insert must " +
376
+ "leave the parent live").not.toBe(undefined);
377
+ expect(await live([next])).toStrictEqual([]);
378
+ expect(await tokenService.getRevokedRefreshToken(next.refreshToken)).toBe(undefined);
379
+ const retry = await member(family);
380
+ expect((await rotate(parent, retry))?.refreshToken, "a failed rotation must release the family").toBe(retry.refreshToken);
381
+ });
382
+ });
383
+ }
@@ -12,6 +12,9 @@
12
12
  * with `user: undefined` and persists a token whose `user` is unset — a store
13
13
  * that requires a user cannot hold a machine token.
14
14
  *
15
+ * When the service implements `rotate`, its single-caller contract is checked
16
+ * here too; `runTokenRotationContractTests` checks it under concurrent callers.
17
+ *
15
18
  * @module
16
19
  */
17
20
  import type { ClientInterface } from "../../models/client.ts";
@@ -12,6 +12,9 @@
12
12
  * with `user: undefined` and persists a token whose `user` is unset — a store
13
13
  * that requires a user cannot hold a machine token.
14
14
  *
15
+ * When the service implements `rotate`, its single-caller contract is checked
16
+ * here too; `runTokenRotationContractTests` checks it under concurrent callers.
17
+ *
15
18
  * @module
16
19
  */
17
20
  import { assert, beforeEach, describe, expect, it } from "vitest";
@@ -212,10 +215,10 @@ export function runTokenServiceContractTests(options) {
212
215
  describe("refresh-token rotation family", () => {
213
216
  const FAMILY = "family-1";
214
217
  const OTHER_FAMILY = "family-2";
215
- async function saveFamilyMember(familyId, refreshTokenExpiresAt = new Date(Date.now() + 600_000)) {
218
+ async function familyMember(familyId, refreshTokenExpiresAt = new Date(Date.now() + 600_000)) {
216
219
  const accessToken = await tokenService.generateAccessToken(client, user);
217
220
  const refreshToken = (await tokenService.generateRefreshToken(client, user));
218
- const token = {
221
+ return {
219
222
  accessToken,
220
223
  accessTokenExpiresAt: new Date(Date.now() + 60_000),
221
224
  refreshToken,
@@ -225,6 +228,9 @@ export function runTokenServiceContractTests(options) {
225
228
  client,
226
229
  user,
227
230
  };
231
+ }
232
+ async function saveFamilyMember(familyId, refreshTokenExpiresAt) {
233
+ const token = await familyMember(familyId, refreshTokenExpiresAt);
228
234
  await tokenService.save(token);
229
235
  return token;
230
236
  }
@@ -285,6 +291,42 @@ export function runTokenServiceContractTests(options) {
285
291
  revoked.rotatedAt.getTime() <= Date.now() + 1000, "without rotatedAt the refresh grant's replayGraceSeconds never " +
286
292
  "applies, so every straggler revokes its family");
287
293
  });
294
+ it("rotates in one call: the parent is spent and stamped, the successor live", async ({ skip, }) => {
295
+ skip(typeof tokenService.rotate !== "function", "the service does not implement rotate");
296
+ const parent = await saveFamilyMember(FAMILY);
297
+ const next = await familyMember(FAMILY);
298
+ const before = Date.now();
299
+ const rotated = await tokenService.rotate(parent, next);
300
+ expect(rotated?.refreshToken).toBe(next.refreshToken);
301
+ expect((await tokenService.getRefreshToken(next.refreshToken))?.familyId).toBe(FAMILY);
302
+ expect(await tokenService.getToken(next.accessToken)).not.toBe(undefined);
303
+ expect(await tokenService.getRefreshToken(parent.refreshToken)).toBe(undefined);
304
+ expect(await tokenService.getToken(parent.accessToken), "rotation ends the parent's access token, as revokeRotated does").toBe(undefined);
305
+ const tombstone = await tokenService.getRevokedRefreshToken(parent.refreshToken);
306
+ expect(tombstone?.familyId).toBe(FAMILY);
307
+ assert(tombstone?.rotatedAt instanceof Date &&
308
+ tombstone.rotatedAt.getTime() >= before - 1000 &&
309
+ tombstone.rotatedAt.getTime() <= Date.now() + 1000, "rotate must stamp rotatedAt as revokeRotated does, or the " +
310
+ "refresh grant's replayGraceSeconds and the revocation " +
311
+ "endpoint's family revocation stop applying");
312
+ });
313
+ it("refuses to rotate a refresh token that is no longer live, persisting nothing", async ({ skip, }) => {
314
+ skip(typeof tokenService.rotate !== "function", "the service does not implement rotate");
315
+ const parent = await saveFamilyMember(FAMILY);
316
+ const first = await familyMember(FAMILY);
317
+ const second = await familyMember(FAMILY);
318
+ const revokedParent = await saveFamilyMember(FAMILY);
319
+ const third = await familyMember(FAMILY);
320
+ await tokenService.revoke(revokedParent.refreshToken, "refresh_token");
321
+ expect(await tokenService.rotate(parent, first)).not.toBe(undefined);
322
+ expect(await tokenService.rotate(parent, second), "a refresh token is claimed once").toBe(undefined);
323
+ expect(await tokenService.rotate(revokedParent, third)).toBe(undefined);
324
+ for (const refused of [second, third]) {
325
+ expect(await tokenService.getRefreshToken(refused.refreshToken)).toBe(undefined);
326
+ expect(await tokenService.getToken(refused.accessToken)).toBe(undefined);
327
+ expect(await tokenService.getRevokedRefreshToken(refused.refreshToken)).toBe(undefined);
328
+ }
329
+ });
288
330
  it("finds a rotated-out token's family, so revoking it ends the live successor", async ({ skip, }) => {
289
331
  skip(typeof tokenService.revokeRotated !== "function", "the service does not implement revokeRotated");
290
332
  const rotatedOut = await saveFamilyMember(FAMILY);
@@ -247,6 +247,13 @@ export declare class MemoryTokenService<C extends ClientInterface, U extends Mem
247
247
  * token was already claimed.
248
248
  */
249
249
  revokeRotated(token: RefreshToken<C, U, S>): Promise<boolean>;
250
+ /**
251
+ * Claims `current` and saves `next` in one step, stamping the rotated-out
252
+ * tombstone with `rotatedAt` as {@linkcode revokeRotated} does. Resolves
253
+ * `undefined`, saving nothing, when `current` is no longer live or either
254
+ * token's family has been revoked.
255
+ */
256
+ rotate(current: RefreshToken<C, U, S>, next: RefreshToken<C, U, S>): Promise<RefreshToken<C, U, S> | undefined>;
250
257
  /** Whether the family has a live refresh token that has not expired. */
251
258
  hasLiveRefreshToken(familyId: string): Promise<boolean>;
252
259
  /**
@@ -317,38 +317,46 @@ export class MemoryTokenService {
317
317
  return this.#hydrate(stored, "", refreshToken);
318
318
  }
319
319
  async save(token) {
320
- const accessTokenHash = await sha256Hash(token.accessToken);
320
+ this.#store(await this.#prepare(token));
321
+ return token;
322
+ }
323
+ async #prepare(token) {
321
324
  const stored = {
322
- accessTokenHash,
325
+ accessTokenHash: await sha256Hash(token.accessToken),
323
326
  accessTokenExpiresAt: token.accessTokenExpiresAt,
324
327
  clientId: token.client.id,
325
328
  userId: token.user?.id,
326
329
  scope: token.scope,
327
330
  authenticationContext: snapshotAuthenticationContext(token.authenticationContext),
328
331
  };
329
- if (token.code) {
332
+ if (token.code)
330
333
  stored.codeHash = await sha256Hash(token.code);
331
- let bucket = this.#tokensByCodeHash.get(stored.codeHash);
332
- if (!bucket) {
333
- bucket = new Set();
334
- this.#tokensByCodeHash.set(stored.codeHash, bucket);
335
- }
336
- bucket.add(accessTokenHash);
337
- }
338
334
  if ("refreshToken" in token) {
339
335
  const rt = token;
340
336
  stored.refreshTokenHash = await sha256Hash(rt.refreshToken);
341
337
  stored.refreshTokenExpiresAt = rt.refreshTokenExpiresAt;
342
338
  stored.familyId = rt.familyId;
343
339
  stored.familyCreatedAt = rt.familyCreatedAt;
344
- if (rt.familyId && this.#revokedFamilies.has(rt.familyId)) {
340
+ }
341
+ return stored;
342
+ }
343
+ #store(stored) {
344
+ if (stored.codeHash) {
345
+ let bucket = this.#tokensByCodeHash.get(stored.codeHash);
346
+ if (!bucket) {
347
+ bucket = new Set();
348
+ this.#tokensByCodeHash.set(stored.codeHash, bucket);
349
+ }
350
+ bucket.add(stored.accessTokenHash);
351
+ }
352
+ if (stored.refreshTokenHash) {
353
+ if (stored.familyId && this.#revokedFamilies.has(stored.familyId)) {
345
354
  this.#tombstone(stored.refreshTokenHash, stored);
346
- return token;
355
+ return;
347
356
  }
348
357
  this.#tokensByRefreshHash.set(stored.refreshTokenHash, stored);
349
358
  }
350
- this.#tokensByAccessHash.set(accessTokenHash, stored);
351
- return token;
359
+ this.#tokensByAccessHash.set(stored.accessTokenHash, stored);
352
360
  }
353
361
  async revoke(token, hint) {
354
362
  if (typeof token !== "string") {
@@ -386,6 +394,27 @@ export class MemoryTokenService {
386
394
  stored.rotatedAt = new Date();
387
395
  return await this.revoke(token);
388
396
  }
397
+ /**
398
+ * Claims `current` and saves `next` in one step, stamping the rotated-out
399
+ * tombstone with `rotatedAt` as {@linkcode revokeRotated} does. Resolves
400
+ * `undefined`, saving nothing, when `current` is no longer live or either
401
+ * token's family has been revoked.
402
+ */
403
+ async rotate(current, next) {
404
+ const currentHash = await sha256Hash(current.refreshToken);
405
+ const successor = await this.#prepare(next);
406
+ const stored = this.#tokensByRefreshHash.get(currentHash);
407
+ if (!stored ||
408
+ [stored.familyId, successor.familyId].some((familyId) => familyId && this.#revokedFamilies.has(familyId))) {
409
+ return undefined;
410
+ }
411
+ stored.rotatedAt = new Date();
412
+ this.#tokensByAccessHash.delete(stored.accessTokenHash);
413
+ this.#tokensByRefreshHash.delete(currentHash);
414
+ this.#tombstone(currentHash, stored);
415
+ this.#store(successor);
416
+ return next;
417
+ }
389
418
  /** Whether the family has a live refresh token that has not expired. */
390
419
  hasLiveRefreshToken(familyId) {
391
420
  const now = Date.now();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@udibo/oauth2",
3
- "version": "0.24.0",
3
+ "version": "0.25.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",