@udibo/oauth2 0.29.0 → 0.31.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/server/grants/refresh-token.d.ts +7 -1
- package/dist/server/grants/refresh-token.js +16 -4
- package/dist/server/jwks-token-reader.d.ts +29 -12
- package/dist/server/jwks-token-reader.js +16 -7
- package/dist/server/services/token.d.ts +23 -3
- package/dist/testing/contract/token.js +36 -0
- package/dist/testing/services.d.ts +7 -0
- package/dist/testing/services.js +12 -0
- package/package.json +1 -1
|
@@ -64,7 +64,13 @@ export interface RefreshTokenGrantOptions<Client extends ClientInterface, User,
|
|
|
64
64
|
*
|
|
65
65
|
* Needs a token service that sets {@linkcode RefreshToken.rotatedAt} in
|
|
66
66
|
* `revokeRotated` and implements `hasLiveRefreshToken`; with any other
|
|
67
|
-
* service the grace never applies.
|
|
67
|
+
* service the grace never applies. The window is measured by the token
|
|
68
|
+
* service's `isWithinReplayGrace` when it implements one, on the store's
|
|
69
|
+
* clock, and otherwise by comparing `rotatedAt` with this process's clock.
|
|
70
|
+
* The store's answer is honored only while `rotatedAt` is no more than five
|
|
71
|
+
* minutes ahead of this process's clock and no more than the grace plus
|
|
72
|
+
* five minutes behind it, so a faulty store check cannot excuse replays
|
|
73
|
+
* indefinitely.
|
|
68
74
|
*/
|
|
69
75
|
replayGraceSeconds?: number;
|
|
70
76
|
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { InvalidClientError, InvalidGrantError, InvalidRequestError, InvalidScopeError, } from "../../errors.js";
|
|
7
7
|
import { AbstractGrant, } from "./grant.js";
|
|
8
|
+
const REPLAY_GRACE_CLOCK_TOLERANCE_SECONDS = 300;
|
|
8
9
|
/**
|
|
9
10
|
* The refresh token grant type.
|
|
10
11
|
* https://datatracker.ietf.org/doc/html/rfc6749.html#section-6
|
|
@@ -16,7 +17,7 @@ export class RefreshTokenGrant extends AbstractGrant {
|
|
|
16
17
|
/** The OAuth2 grant type: `refresh_token`. */
|
|
17
18
|
grantType = "refresh_token";
|
|
18
19
|
#onTokenReuse;
|
|
19
|
-
#
|
|
20
|
+
#replayGraceSeconds;
|
|
20
21
|
/** Creates the grant from its {@linkcode RefreshTokenGrantOptions}. */
|
|
21
22
|
constructor(options) {
|
|
22
23
|
super({ ...options, allowRefreshToken: true });
|
|
@@ -25,7 +26,7 @@ export class RefreshTokenGrant extends AbstractGrant {
|
|
|
25
26
|
if (!Number.isFinite(graceSeconds) || graceSeconds < 0) {
|
|
26
27
|
throw new RangeError(`replayGraceSeconds must be a non-negative number of seconds, got ${graceSeconds}`);
|
|
27
28
|
}
|
|
28
|
-
this.#
|
|
29
|
+
this.#replayGraceSeconds = graceSeconds;
|
|
29
30
|
}
|
|
30
31
|
/**
|
|
31
32
|
* Handles a token request for the refresh token grant. Issues a new access
|
|
@@ -143,12 +144,23 @@ export class RefreshTokenGrant extends AbstractGrant {
|
|
|
143
144
|
}
|
|
144
145
|
async #withinReplayGrace(tokenService, replayed) {
|
|
145
146
|
const { rotatedAt, familyId } = replayed;
|
|
146
|
-
if (this.#
|
|
147
|
+
if (this.#replayGraceSeconds === 0 || !rotatedAt || !familyId) {
|
|
147
148
|
return false;
|
|
148
|
-
|
|
149
|
+
}
|
|
150
|
+
const elapsedMs = Date.now() - rotatedAt.getTime();
|
|
151
|
+
const withinWindow = tokenService.isWithinReplayGrace
|
|
152
|
+
? this.#withinClockTolerance(elapsedMs) &&
|
|
153
|
+
(await tokenService.isWithinReplayGrace(replayed, this.#replayGraceSeconds))
|
|
154
|
+
: elapsedMs <= this.#replayGraceSeconds * 1000;
|
|
155
|
+
if (!withinWindow)
|
|
149
156
|
return false;
|
|
150
157
|
return (await tokenService.hasLiveRefreshToken?.(familyId)) ?? false;
|
|
151
158
|
}
|
|
159
|
+
#withinClockTolerance(elapsedMs) {
|
|
160
|
+
const toleranceMs = REPLAY_GRACE_CLOCK_TOLERANCE_SECONDS * 1000;
|
|
161
|
+
return (elapsedMs >= -toleranceMs &&
|
|
162
|
+
elapsedMs <= this.#replayGraceSeconds * 1000 + toleranceMs);
|
|
163
|
+
}
|
|
152
164
|
async #notifyTokenReuse(event) {
|
|
153
165
|
if (!this.#onTokenReuse)
|
|
154
166
|
return;
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
* Token reader that validates JWT access tokens locally against the
|
|
3
3
|
* issuer's published JWKS, with no per-request network hop.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* It validates RFC 9068 (`at+jwt`) access tokens: the authorization server
|
|
6
|
+
* signs access tokens and publishes its public keys at a `jwks_uri`; the
|
|
7
|
+
* resource server fetches those keys once, caches them, and verifies every
|
|
8
|
+
* subsequent token in-process, including the `typ` header and every claim
|
|
9
|
+
* RFC 9068 §2.2 makes REQUIRED. Signature verification uses Web Crypto
|
|
9
10
|
* only — the package takes no JOSE dependency.
|
|
10
11
|
*
|
|
11
12
|
* @see https://datatracker.ietf.org/doc/html/rfc9068
|
|
@@ -27,19 +28,25 @@ import type { TokenReaderInterface } from "./services/token.ts";
|
|
|
27
28
|
export interface JwtAccessTokenClaims {
|
|
28
29
|
/** Issuer that signed the token. Verified against the configured issuer. */
|
|
29
30
|
iss: string;
|
|
30
|
-
/** Subject — the end user, or the client itself for client-credentials tokens. */
|
|
31
|
-
sub
|
|
31
|
+
/** Subject — the end user, or the client itself for client-credentials tokens. Required. */
|
|
32
|
+
sub: string;
|
|
32
33
|
/** Audience(s) the token is intended for. Verified against the configured audience. */
|
|
33
34
|
aud: string | string[];
|
|
34
35
|
/** Expiration time, seconds since the epoch. */
|
|
35
36
|
exp: number;
|
|
36
|
-
/** Issued-at time, seconds since the epoch. */
|
|
37
|
-
iat
|
|
37
|
+
/** Issued-at time, seconds since the epoch. Required. */
|
|
38
|
+
iat: number;
|
|
38
39
|
/** Not-before time, seconds since the epoch. */
|
|
39
40
|
nbf?: number;
|
|
40
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Unique token identifier. Required unless
|
|
43
|
+
* {@linkcode JwksTokenReaderOptions.tokenIdClaim} names another claim.
|
|
44
|
+
*/
|
|
41
45
|
jti?: string;
|
|
42
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* Client the token was issued to. Required unless
|
|
48
|
+
* {@linkcode JwksTokenReaderOptions.clientIdClaim} names another claim.
|
|
49
|
+
*/
|
|
43
50
|
client_id?: string;
|
|
44
51
|
/** Space-delimited granted scopes. */
|
|
45
52
|
scope?: string;
|
|
@@ -105,9 +112,18 @@ export interface JwksTokenReaderOptions<Client extends ClientInterface, User, S
|
|
|
105
112
|
* §2.2 makes `client_id` REQUIRED, and its absence is what tells an
|
|
106
113
|
* `id_token` apart from an access token). Defaults to `"client_id"`; point
|
|
107
114
|
* it at the vendor claim for issuers that deviate — Okta's `cid`, Azure
|
|
108
|
-
* AD v1's `appid`.
|
|
115
|
+
* AD v1's `appid`. The requirement cannot be turned off, but pointing this
|
|
116
|
+
* at a claim every token carries defeats it.
|
|
109
117
|
*/
|
|
110
118
|
clientIdClaim?: string;
|
|
119
|
+
/**
|
|
120
|
+
* Claim carrying the unique token identifier, required on every accepted
|
|
121
|
+
* token (RFC 9068 §2.2 makes `jti` REQUIRED). Defaults to `"jti"`; point it
|
|
122
|
+
* at the vendor claim for issuers that deviate — Microsoft Entra ID's
|
|
123
|
+
* `uti`. The requirement cannot be turned off, but pointing this at a claim
|
|
124
|
+
* every token carries defeats it.
|
|
125
|
+
*/
|
|
126
|
+
tokenIdClaim?: string;
|
|
111
127
|
/**
|
|
112
128
|
* Leeway in seconds applied to `exp` and `nbf` to absorb clock drift
|
|
113
129
|
* between the issuer and this server. Defaults to 30. Must be finite and
|
|
@@ -239,7 +255,8 @@ export declare class JwksTokenReader<Client extends ClientInterface, User, S ext
|
|
|
239
255
|
#private;
|
|
240
256
|
/**
|
|
241
257
|
* Defaults: every Web Crypto asymmetric algorithm accepted, `at+jwt`
|
|
242
|
-
* required as `typ`, `client_id` as the required client-id claim,
|
|
258
|
+
* required as `typ`, `client_id` as the required client-id claim, `jti` as
|
|
259
|
+
* the required token-identifier claim, 30
|
|
243
260
|
* seconds of clock skew, a 10-minute cache with a 30-second minimum
|
|
244
261
|
* between fetches, a 5-second request deadline, a 256 KiB response cap,
|
|
245
262
|
* {@linkcode BasicScope}, and the global `fetch`.
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
* Token reader that validates JWT access tokens locally against the
|
|
3
3
|
* issuer's published JWKS, with no per-request network hop.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* It validates RFC 9068 (`at+jwt`) access tokens: the authorization server
|
|
6
|
+
* signs access tokens and publishes its public keys at a `jwks_uri`; the
|
|
7
|
+
* resource server fetches those keys once, caches them, and verifies every
|
|
8
|
+
* subsequent token in-process, including the `typ` header and every claim
|
|
9
|
+
* RFC 9068 §2.2 makes REQUIRED. Signature verification uses Web Crypto
|
|
9
10
|
* only — the package takes no JOSE dependency.
|
|
10
11
|
*
|
|
11
12
|
* @see https://datatracker.ietf.org/doc/html/rfc9068
|
|
@@ -208,6 +209,7 @@ export class JwksTokenReader {
|
|
|
208
209
|
#algorithms;
|
|
209
210
|
#types;
|
|
210
211
|
#clientIdClaim;
|
|
212
|
+
#tokenIdClaim;
|
|
211
213
|
#clockSkewSeconds;
|
|
212
214
|
#cacheMaxAgeMs;
|
|
213
215
|
#minFetchIntervalMs;
|
|
@@ -225,7 +227,8 @@ export class JwksTokenReader {
|
|
|
225
227
|
#inFlight;
|
|
226
228
|
/**
|
|
227
229
|
* Defaults: every Web Crypto asymmetric algorithm accepted, `at+jwt`
|
|
228
|
-
* required as `typ`, `client_id` as the required client-id claim,
|
|
230
|
+
* required as `typ`, `client_id` as the required client-id claim, `jti` as
|
|
231
|
+
* the required token-identifier claim, 30
|
|
229
232
|
* seconds of clock skew, a 10-minute cache with a 30-second minimum
|
|
230
233
|
* between fetches, a 5-second request deadline, a 256 KiB response cap,
|
|
231
234
|
* {@linkcode BasicScope}, and the global `fetch`.
|
|
@@ -254,6 +257,7 @@ export class JwksTokenReader {
|
|
|
254
257
|
}
|
|
255
258
|
this.#types = (options.types ?? ["at+jwt"]).map(normalizeType);
|
|
256
259
|
this.#clientIdClaim = options.clientIdClaim ?? "client_id";
|
|
260
|
+
this.#tokenIdClaim = options.tokenIdClaim ?? "jti";
|
|
257
261
|
this.#clockSkewSeconds = options.clockSkewSeconds ?? 30;
|
|
258
262
|
assertClockSkewSeconds(this.#clockSkewSeconds);
|
|
259
263
|
this.#cacheMaxAgeMs = options.cacheMaxAgeMs ?? 600_000;
|
|
@@ -336,9 +340,14 @@ export class JwksTokenReader {
|
|
|
336
340
|
#validClaims(claims) {
|
|
337
341
|
if (claims.iss !== this.#issuer)
|
|
338
342
|
return false;
|
|
339
|
-
const
|
|
340
|
-
|
|
343
|
+
for (const claim of [this.#clientIdClaim, this.#tokenIdClaim, "sub"]) {
|
|
344
|
+
const value = claims[claim];
|
|
345
|
+
if (typeof value !== "string" || !value)
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
if (typeof claims.iat !== "number" || !Number.isFinite(claims.iat)) {
|
|
341
349
|
return false;
|
|
350
|
+
}
|
|
342
351
|
for (const claim of ID_TOKEN_ONLY_CLAIMS) {
|
|
343
352
|
if (claims[claim] !== undefined)
|
|
344
353
|
return false;
|
|
@@ -175,9 +175,10 @@ export interface TokenServiceInterface<Client extends ClientInterface, User, Sco
|
|
|
175
175
|
* is silently disabled and `/revoke` cannot reach a successor.
|
|
176
176
|
*
|
|
177
177
|
* Return the tombstone unconditionally. Apply any replay grace through
|
|
178
|
-
* `RefreshTokenGrantOptions.replayGraceSeconds`,
|
|
179
|
-
*
|
|
180
|
-
*
|
|
178
|
+
* `RefreshTokenGrantOptions.replayGraceSeconds`, judged on the store's clock
|
|
179
|
+
* by {@linkcode isWithinReplayGrace} when it needs one, never by hiding
|
|
180
|
+
* recent tombstones here: a hidden tombstone also hides the family from a
|
|
181
|
+
* sign-out racing the refresh, which then leaves the successor live.
|
|
181
182
|
*/
|
|
182
183
|
getRevokedRefreshToken?(refreshToken: string): Promise<RefreshToken<Client, User, Scope> | undefined>;
|
|
183
184
|
/**
|
|
@@ -209,6 +210,25 @@ export interface TokenServiceInterface<Client extends ClientInterface, User, Sco
|
|
|
209
210
|
* never applies and every replay is treated as reuse.
|
|
210
211
|
*/
|
|
211
212
|
hasLiveRefreshToken?(familyId: string): Promise<boolean>;
|
|
213
|
+
/**
|
|
214
|
+
* Whether `tombstone`, a record {@linkcode getRevokedRefreshToken} just
|
|
215
|
+
* returned, was rotated out no more than `graceSeconds` ago, measured on the
|
|
216
|
+
* clock that stamped its rotation. Implement it when that clock is the
|
|
217
|
+
* store's own, such as a database's, so instances whose clocks drift
|
|
218
|
+
* apart agree on the refresh grant's `replayGraceSeconds` window. Without
|
|
219
|
+
* it, the grant compares {@linkcode RefreshToken.rotatedAt} with its own
|
|
220
|
+
* `Date.now()`.
|
|
221
|
+
*
|
|
222
|
+
* The grant calls it only when `replayGraceSeconds` is above zero and the
|
|
223
|
+
* tombstone carries `rotatedAt` and a `familyId`; `graceSeconds` may be
|
|
224
|
+
* fractional. The window still needs {@linkcode hasLiveRefreshToken}.
|
|
225
|
+
* The grant treats the replay as outside the window, whatever this
|
|
226
|
+
* resolves, once `rotatedAt` is more than five minutes ahead of the
|
|
227
|
+
* grant's clock or more than `graceSeconds` plus five minutes behind it.
|
|
228
|
+
* Implementing this never licenses hiding a tombstone from
|
|
229
|
+
* `getRevokedRefreshToken`.
|
|
230
|
+
*/
|
|
231
|
+
isWithinReplayGrace?(tombstone: RefreshToken<Client, User, Scope>, graceSeconds: number): Promise<boolean>;
|
|
212
232
|
}
|
|
213
233
|
/** Options for configuring an abstract token service. */
|
|
214
234
|
export interface AbstractTokenServiceOptions<Client extends ClientInterface, User> {
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*
|
|
18
18
|
* @module
|
|
19
19
|
*/
|
|
20
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
20
21
|
import { assert, beforeEach, describe, expect, it } from "vitest";
|
|
21
22
|
import { BasicScope } from "../../models/scope.js";
|
|
22
23
|
const defaultMakeUser = (seq) => ({ id: `u${seq}`, username: `user${seq}` });
|
|
@@ -368,6 +369,41 @@ export function runTokenServiceContractTests(options) {
|
|
|
368
369
|
expect(await tokenService.hasLiveRefreshToken(FAMILY), "an expired successor cannot be used, so a replay of its " +
|
|
369
370
|
"predecessor must not be excused as a straggler").toBe(false);
|
|
370
371
|
});
|
|
372
|
+
async function rotateOut(skip) {
|
|
373
|
+
skip(typeof tokenService.isWithinReplayGrace !== "function", "the service does not implement isWithinReplayGrace");
|
|
374
|
+
skip(typeof tokenService.rotate !== "function" &&
|
|
375
|
+
typeof tokenService.revokeRotated !== "function", "the service implements neither rotate nor revokeRotated");
|
|
376
|
+
const parent = await saveFamilyMember(FAMILY);
|
|
377
|
+
const successor = await familyMember(FAMILY);
|
|
378
|
+
if (tokenService.rotate) {
|
|
379
|
+
assert(await tokenService.rotate(parent, successor));
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
assert(await tokenService.revokeRotated(parent));
|
|
383
|
+
await tokenService.save(successor);
|
|
384
|
+
}
|
|
385
|
+
const tombstone = await tokenService.getRevokedRefreshToken(parent.refreshToken);
|
|
386
|
+
assert(tombstone?.familyId === FAMILY && tombstone.rotatedAt, "a store that judges the replay grace must still return every " +
|
|
387
|
+
"rotated-out tombstone, with its family and rotatedAt; hiding " +
|
|
388
|
+
"one inside the window hides the family from a sign-out " +
|
|
389
|
+
"racing the refresh, which then leaves the successor live");
|
|
390
|
+
return { tombstone, successor };
|
|
391
|
+
}
|
|
392
|
+
it("returns the tombstone of a token inside its replay grace, so revoking it ends the successor", async ({ skip, }) => {
|
|
393
|
+
const { tombstone, successor } = await rotateOut(skip);
|
|
394
|
+
expect(await tokenService.isWithinReplayGrace(tombstone, 60), "a token rotated out a moment ago is inside a 60-second grace").toBe(true);
|
|
395
|
+
expect(await tokenService.revokeFamily(tombstone.familyId)).toBe(true);
|
|
396
|
+
expect(await tokenService.getRefreshToken(successor.refreshToken)).toBe(undefined);
|
|
397
|
+
});
|
|
398
|
+
it("judges a rotated-out token inside a one-second grace, then outside it once the second has passed", async ({ skip, }) => {
|
|
399
|
+
const { tombstone } = await rotateOut(skip);
|
|
400
|
+
expect(await tokenService.isWithinReplayGrace(tombstone, 1), "a token rotated out a moment ago is inside a one-second grace").toBe(true);
|
|
401
|
+
await delay(1_200);
|
|
402
|
+
expect(await tokenService.isWithinReplayGrace(tombstone, 1), "a grace that answers true regardless of elapsed time excuses " +
|
|
403
|
+
"every replay while the family is live, which turns reuse " +
|
|
404
|
+
"detection off").toBe(false);
|
|
405
|
+
expect(await tokenService.isWithinReplayGrace(tombstone, 3600)).toBe(true);
|
|
406
|
+
});
|
|
371
407
|
});
|
|
372
408
|
}
|
|
373
409
|
else {
|
|
@@ -256,6 +256,13 @@ export declare class MemoryTokenService<C extends ClientInterface, U extends Mem
|
|
|
256
256
|
rotate(current: RefreshToken<C, U, S>, next: RefreshToken<C, U, S>): Promise<RefreshToken<C, U, S> | undefined>;
|
|
257
257
|
/** Whether the family has a live refresh token that has not expired. */
|
|
258
258
|
hasLiveRefreshToken(familyId: string): Promise<boolean>;
|
|
259
|
+
/**
|
|
260
|
+
* Whether the tombstone was rotated out no more than `graceSeconds` ago,
|
|
261
|
+
* judged from the rotation time this store recorded rather than the one on
|
|
262
|
+
* the record passed in. Resolves `false` for a token it holds no rotated-out
|
|
263
|
+
* tombstone for.
|
|
264
|
+
*/
|
|
265
|
+
isWithinReplayGrace(tombstone: RefreshToken<C, U, S>, graceSeconds: number): Promise<boolean>;
|
|
259
266
|
/**
|
|
260
267
|
* Looks up a revoked/rotated-out refresh token for the refresh grant's
|
|
261
268
|
* reuse detection: revoked refresh records are kept as tombstones instead
|
package/dist/testing/services.js
CHANGED
|
@@ -427,6 +427,18 @@ export class MemoryTokenService {
|
|
|
427
427
|
}
|
|
428
428
|
return Promise.resolve(false);
|
|
429
429
|
}
|
|
430
|
+
/**
|
|
431
|
+
* Whether the tombstone was rotated out no more than `graceSeconds` ago,
|
|
432
|
+
* judged from the rotation time this store recorded rather than the one on
|
|
433
|
+
* the record passed in. Resolves `false` for a token it holds no rotated-out
|
|
434
|
+
* tombstone for.
|
|
435
|
+
*/
|
|
436
|
+
async isWithinReplayGrace(tombstone, graceSeconds) {
|
|
437
|
+
const rotatedAt = this.#revokedByRefreshHash.get(await sha256Hash(tombstone.refreshToken))?.rotatedAt;
|
|
438
|
+
if (!rotatedAt)
|
|
439
|
+
return false;
|
|
440
|
+
return Date.now() - rotatedAt.getTime() <= graceSeconds * 1000;
|
|
441
|
+
}
|
|
430
442
|
#tombstone(refreshTokenHash, stored) {
|
|
431
443
|
this.#revokedByRefreshHash.set(refreshTokenHash, stored);
|
|
432
444
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@udibo/oauth2",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.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",
|