@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.
- package/dist/models/token.d.ts +4 -4
- package/dist/server/authorization-server.d.ts +12 -0
- package/dist/server/authorization-server.js +27 -2
- package/dist/server/services/token.d.ts +26 -9
- package/dist/testing/contract/token.d.ts +3 -1
- package/dist/testing/contract/token.js +19 -1
- package/dist/testing/services.d.ts +4 -2
- package/dist/testing/services.js +10 -2
- package/package.json +1 -1
package/dist/models/token.d.ts
CHANGED
|
@@ -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
|
|
71
|
-
*
|
|
72
|
-
* `
|
|
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
|
-
|
|
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
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
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
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
/**
|
package/dist/testing/services.js
CHANGED
|
@@ -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
|
-
*
|
|
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.
|
|
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",
|