@fluojs/jwt 1.0.3 → 1.1.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/README.ko.md CHANGED
@@ -150,7 +150,7 @@ const verifier = new DefaultJwtVerifier({
150
150
 
151
151
  `jwksRequestTimeoutMs`의 기본값은 `5_000`이며, 예산을 넘기면 진행 중인 JWKS fetch를 abort합니다.
152
152
 
153
- JWKS key는 `jwksCacheTtl` 밀리초 동안 cache되며 기본값은 `600_000`입니다. in-memory cache는 `jwksCacheMaxEntries`로 제한되고 기본값은 `100`입니다. lookup 전 만료된 entry를 정리하고, 제한을 넘으면 가장 오래 보관된 key를 제거합니다. `JwtModule`은 관리 중인 `DefaultJwtVerifier` shutdown hook을 호출하므로 module teardown 중 보관 중인 remote key material이 정리됩니다. 수동으로 생성한 verifier나 client는 수동 shutdown 또는 identity-provider 재설정 시 여전히 `JwksClient.dispose()` / `DefaultJwtVerifier.dispose()`를 호출해야 합니다. `jwksCacheTtl`을 `0`으로 설정하면 bounded fetch timeout은 유지하면서 key 보관만 비활성화합니다.
153
+ JWKS key는 `jwksCacheTtl` 밀리초 동안 cache되며 기본값은 `600_000`입니다. in-memory cache는 `jwksCacheMaxEntries`로 제한되고 기본값은 `100`입니다. lookup 전 만료된 entry를 정리하고, 제한을 넘으면 가장 오래 보관된 key를 제거합니다. `JwtModule`은 관리 중인 `DefaultJwtVerifier` shutdown hook을 호출하므로 module teardown 중 보관 중인 remote key material이 정리됩니다. 수동으로 생성한 verifier나 client는 수동 shutdown 또는 identity-provider 재설정 시 여전히 `JwksClient.dispose()` / `DefaultJwtVerifier.dispose()`를 호출해야 합니다. 이 dispose method들은 보관 중인 JWKS key material을 정리하고 진행 중인 JWKS fetch를 abort합니다. `jwksCacheTtl`을 `0`으로 설정하면 bounded fetch timeout은 유지하면서 key 보관만 비활성화합니다.
154
154
 
155
155
  `JwtService.verify(token, options)`는 호출 단위의 알고리즘/클레임 정책 재정의(`issuer`, `audience`, `clockSkewSeconds`, `maxAge`, `requireExp`)를 적용하더라도, 내부 JWKS client나 정적 key-resolution cache를 다시 만들지 않습니다. 호출 단위 검증은 `jwksUri`, `keys[]`, `publicKey`, `secret`, `secretOrKeyProvider` 같은 구성된 key source 자체를 교체하지는 않습니다.
156
156
 
@@ -160,7 +160,9 @@ JWKS key는 `jwksCacheTtl` 밀리초 동안 cache되며 기본값은 `600_000`
160
160
 
161
161
  ### 리프레시 토큰
162
162
 
163
- `RefreshTokenService`는 전용 HMAC refresh-token 경로를 사용합니다. `refreshToken.secret`은 access-token 서명 키와 별도로 설정하세요. Rotation은 `RefreshTokenStore.rotate(...)`를 사용해 현재 토큰을 소비 처리하고 대체 토큰을 같은 durable store 작업 안에서 저장할 수 있습니다. 따라서 성공한 rotation은 저장된 후속 토큰 없이 기존 토큰만 소비하지 않습니다. 기존 atomic `consume(...)` hook만 구현한 store도 계속 지원하지만, 대체 토큰 저장의 내구성은 store 소유한 `rotate(...)` 작업에 달려 있습니다.
163
+ `RefreshTokenService`는 전용 HMAC refresh-token 경로를 사용합니다. `refreshToken.secret`은 access-token 서명 키와 별도로 설정하세요. Rotation은 `RefreshTokenStore.rotate(...)`를 사용해 현재 토큰을 소비 처리하고 대체 토큰을 같은 durable store 작업 안에서 저장할 수 있습니다. 따라서 성공한 rotation은 저장된 후속 토큰 없이 기존 토큰만 소비하지 않습니다. 기존 atomic `consume(...)` hook만 구현한 store도 계속 지원합니다. Consume이 성공하면 service가 `save(...)`로 대체 토큰을 저장하지만, 쓰기를 원자적으로 만드는 경로는 store 소유의 `rotate(...)`뿐입니다.
164
+
165
+ 재사용을 감지하면 optional `revokeByFamily(family)` capability를 구현한 store는 침해된 token family만 revoke합니다. 기존 store는 source-compatible 상태를 유지합니다. `revokeByFamily(...)`가 없으면 `RefreshTokenService`는 보수적으로 `revokeBySubject(subject)`로 fallback하며, 이 경우 해당 subject의 독립적인 refresh-token family도 함께 revoke됩니다. 다른 family가 침해된 뒤에도 별도 device 또는 session family를 유지해야 하는 production store는 `revokeByFamily(...)`를 구현하세요.
164
166
 
165
167
  ## 설정 가드레일
166
168
 
@@ -170,8 +172,18 @@ JWT 서명과 검증에는 `algorithms`에 지원되는 알고리즘이 하나
170
172
 
171
173
  검증은 잘못된 시간 정책에 대해 fail closed로 동작합니다. 검증에 참여하는 `exp`, `nbf`, `iat` 클레임은 유한한 JWT NumericDate 숫자여야 하며, `clockSkewSeconds`도 음수가 아닌 유한 숫자여야 합니다. 유한하지 않은 값은 expiration, not-before, age check를 늘리는 대신 거부됩니다. verifier 시간이 `exp` NumericDate에 도달하면 토큰은 만료된 것으로 처리되며, 양수 clock skew가 경계를 덮지 않는 한 equality도 만료로 간주합니다.
172
174
 
175
+ ### Node 런타임 경계
176
+
173
177
  루트 `@fluojs/jwt` import surface는 runtime-specific 인증 경로를 선택하기 전에도 안전하게 로드할 수 있습니다. Node.js `node:crypto` primitive는 서명, 검증, JWKS key parsing, refresh-token id 생성이 실제로 실행될 때만 lazy load됩니다. 이 방식은 기존 public export를 유지하면서 module import 시점의 Node-specific crypto 작업을 피합니다.
174
178
 
179
+ Lazy loading은 import-time 안전성 속성일 뿐입니다. 서명이나 검증을 runtime 간 이식 가능하게 만들지는 **않습니다**. 서명, 검증, JWKS key parsing, refresh-token id 생성 경로가 실행되면 해당 경로는 Node.js 호환 `node:crypto` 구현(`createHmac`, `createSign`, `createVerify`, `createPublicKey`, `timingSafeEqual`, `randomUUID`)을 필요로 합니다. Bun은 Node 호환성 레이어로 이를 만족하지만, Deno와 Cloudflare Workers는 이러한 연산에 호환되는 `node:crypto` surface를 제공하지 않으므로 지원되는 JWT 서명/검증 runtime이 아닙니다. `@fluojs/jwt`를 Node-runtime auth 패키지로 취급하세요. import-time 로딩은 lazy로 유지하되, 호환되는 `node:crypto` polyfill 없이 non-Node runtime에서 토큰을 서명하거나 검증할 수 있다고 가정하지 마세요.
180
+
181
+ ### `decode()` trust boundary
182
+
183
+ `JwtService.decode(token)`는 서명, `alg`, `exp`, `nbf`, `iss`, `aud` 또는 기타 클레임을 검증하지 않고 JWT payload segment를 읽습니다. 반환된 객체는 **검증되지 않은 입력(unverified input)**이며, 권한 결정(authorization decisions), 신원 확인(identity resolution), 또는 접근을 허가하는 모든 코드 경로에 사용해서는 안 됩니다. 먼저 `JwtService.verify(token, options)`(또는 `DefaultJwtVerifier.verifyAccessToken(token)`)를 호출하고, 검증이 반환하는 정규화된 `JwtPrincipal`에서 신원을 읽으세요.
184
+
185
+ `decode()`는 진단(diagnostics) 및 비권위적 검사(non-authoritative inspection)에만 사용됩니다. 예를 들어 로깅을 위해 토큰 메타데이터를 읽거나 `verify()` 호출 전에 검증 키를 선택할 때 사용할 수 있습니다. `decode()` 출력에서 읽은 모든 클레임 값 — `sub`, `roles`, `scopes`, `iss`, `aud`, `exp` 포함 — 은 `verify()`가 성공하기 전까지 공격자가 제어한 값으로 취급해야 합니다. `decode()` 출력을 기준으로 요청을 허가하거나 거부하는 분기를 만들지 말고, 검증되지 않은 클레임을 검증된 것처럼 downstream 코드에 노출하지 마세요.
186
+
175
187
  ## 공개 API 개요
176
188
 
177
189
  ### 주요 클래스
@@ -187,7 +199,8 @@ JWT 서명과 검증에는 `algorithms`에 지원되는 알고리즘이 하나
187
199
  - `JwtVerifierOptions`: 알고리즘, 키, 검증 정책 설정을 위한 타입입니다.
188
200
  - `SignOptions`, `VerifyOptions`: 호출 단위 서명 및 검증 재정의 타입입니다.
189
201
  - `JwtClaims`, `JwtSigner`, `JwtVerifier`, `JwtKeyEntry`, `JwtAlgorithm`: 공개 서명 및 검증 계약입니다.
190
- - `RefreshTokenOptions`, `RefreshTokenStore`, `RefreshTokenRecord`, `RefreshTokenConsumeInput`, `RefreshTokenRotateInput`, `RefreshTokenConsumeResult`: refresh-token 저장, rotation, replay detection 계약입니다.
202
+ - `RefreshTokenOptions`, `RefreshTokenStore`, `RefreshTokenRecord`, `RefreshTokenConsumeInput`, `RefreshTokenRotateInput`, `RefreshTokenConsumeResult`: refresh-token 저장, rotation, family-scoped revocation, replay detection 계약입니다. `RefreshTokenStore.revokeByFamily(...)`는 subject-revocation store와의 호환성을 위해 optional입니다.
203
+ - `JwtPlatformStatusSnapshot`, `JwtStatusAdapterInput`: platform diagnostic helper와 함께 export되는 status snapshot 및 adapter input 타입입니다.
191
204
 
192
205
  ### 에러와 diagnostics
193
206
  - `JwtVerificationError`, `JwtInvalidTokenError`, `JwtExpiredTokenError`, `JwtConfigurationError`: 타입이 지정된 JWT 실패입니다.
package/README.md CHANGED
@@ -150,7 +150,7 @@ const verifier = new DefaultJwtVerifier({
150
150
 
151
151
  `jwksRequestTimeoutMs` defaults to `5_000` and aborts the outbound JWKS fetch once that budget is exceeded.
152
152
 
153
- JWKS keys are cached for `jwksCacheTtl` milliseconds (`600_000` by default) and the in-memory cache is bounded by `jwksCacheMaxEntries` (`100` by default). Expired entries are pruned before lookups, the oldest retained key is evicted when the bound is exceeded, and `JwtModule` calls the managed `DefaultJwtVerifier` shutdown hook so retained remote key material is cleared during module teardown. Manually constructed verifiers or clients should still call `JwksClient.dispose()` / `DefaultJwtVerifier.dispose()` during manual shutdown or identity-provider reconfiguration. A `jwksCacheTtl` of `0` disables key retention while still using bounded fetch timeouts.
153
+ JWKS keys are cached for `jwksCacheTtl` milliseconds (`600_000` by default) and the in-memory cache is bounded by `jwksCacheMaxEntries` (`100` by default). Expired entries are pruned before lookups, the oldest retained key is evicted when the bound is exceeded, and `JwtModule` calls the managed `DefaultJwtVerifier` shutdown hook so retained remote key material is cleared during module teardown. Manually constructed verifiers or clients should still call `JwksClient.dispose()` / `DefaultJwtVerifier.dispose()` during manual shutdown or identity-provider reconfiguration. These dispose methods clear retained JWKS key material and abort active JWKS fetches. A `jwksCacheTtl` of `0` disables key retention while still using bounded fetch timeouts.
154
154
 
155
155
  `JwtService.verify(token, options)` applies per-call algorithm and claim-policy overrides (`issuer`, `audience`, `clockSkewSeconds`, `maxAge`, `requireExp`) without rebuilding the underlying JWKS client or static key-resolution cache. Per-call verification does not replace configured key sources such as `jwksUri`, `keys[]`, `publicKey`, `secret`, or `secretOrKeyProvider`.
156
156
 
@@ -160,7 +160,9 @@ For multi-tenant systems, prefer putting a tenant-specific `kid` in issued token
160
160
 
161
161
  ### Refresh tokens
162
162
 
163
- `RefreshTokenService` uses a dedicated HMAC refresh-token path. Configure `refreshToken.secret` separately from access-token signing keys. Rotation can use `RefreshTokenStore.rotate(...)` to atomically mark the current token as consumed and persist the replacement token in the same durable store operation, so a successful rotation never consumes the old token without a stored successor. Stores that only implement the older atomic `consume(...)` hook remain supported, but durable replacement persistence depends on the store-owned `rotate(...)` operation.
163
+ `RefreshTokenService` uses a dedicated HMAC refresh-token path. Configure `refreshToken.secret` separately from access-token signing keys. Rotation can use `RefreshTokenStore.rotate(...)` to atomically mark the current token as consumed and persist the replacement token in the same durable store operation, so a successful rotation never consumes the old token without a stored successor. Stores that only implement the older atomic `consume(...)` hook remain supported: after a successful consume, the service saves the replacement through `save(...)`, but only store-owned `rotate(...)` makes those two writes atomic.
164
+
165
+ When reuse is detected, stores that implement the optional `revokeByFamily(family)` capability revoke only the compromised token family. Existing stores remain source-compatible: if `revokeByFamily(...)` is absent, `RefreshTokenService` conservatively falls back to `revokeBySubject(subject)`, which also revokes the subject's independent refresh-token families. Implement `revokeByFamily(...)` in production stores when separate device or session families must remain active after another family is compromised.
164
166
 
165
167
  ## Configuration Guardrails
166
168
 
@@ -170,8 +172,18 @@ Access-token TTL must also be a positive finite number. When `accessTokenTtlSeco
170
172
 
171
173
  Verification fails closed on malformed time policy. `exp`, `nbf`, and `iat` claims that participate in verification must be finite JWT NumericDate numbers, and `clockSkewSeconds` must be a non-negative finite number. Non-finite values are rejected instead of extending expiration, not-before, or age checks. A token is expired when verifier time reaches its `exp` NumericDate; equality is treated as expired unless positive clock skew still covers the boundary.
172
174
 
175
+ ### Node runtime boundary
176
+
173
177
  The root `@fluojs/jwt` import surface is safe to load before selecting a runtime-specific authentication path: Node.js `node:crypto` primitives are loaded lazily only when signing, verification, JWKS key parsing, or refresh-token id generation actually executes. This preserves the existing public exports while avoiding Node-specific crypto work at module import time.
174
178
 
179
+ Lazy loading is an import-time safety property only. It does **not** make signing or verification portable across runtimes. Once a signing, verification, JWKS key-parsing, or refresh-token id-generation path executes, that path requires a Node.js-compatible `node:crypto` implementation (`createHmac`, `createSign`, `createVerify`, `createPublicKey`, `timingSafeEqual`, `randomUUID`). Bun satisfies this through its Node compatibility layer; Deno and Cloudflare Workers do not provide a compatible `node:crypto` surface for these operations and are not supported JWT signing/verification runtimes. Treat `@fluojs/jwt` as a Node-runtime auth package: keep import-time loading lazy, but do not assume the package can sign or verify tokens on non-Node runtimes without a compatible `node:crypto` polyfill.
180
+
181
+ ### `decode()` trust boundary
182
+
183
+ `JwtService.decode(token)` reads the JWT payload segment without verifying the signature, `alg`, `exp`, `nbf`, `iss`, `aud`, or any other claim. The returned object is **unverified input** and must never be used for authorization decisions, identity resolution, or any code path that grants access. Use `JwtService.verify(token, options)` (or `DefaultJwtVerifier.verifyAccessToken(token)`) first, and read identity from the normalized `JwtPrincipal` that verification returns.
184
+
185
+ `decode()` exists for diagnostics and non-authoritative inspection only, such as reading token metadata for logging or selecting a verification key before calling `verify()`. Any claim value read from `decode()` — including `sub`, `roles`, `scopes`, `iss`, `aud`, and `exp` — must be treated as attacker-controlled until `verify()` succeeds. Never branch on `decode()` output to allow or deny a request, and never expose decoded claims to downstream code as if they were verified.
186
+
175
187
  ## Public API Overview
176
188
 
177
189
  ### Core Classes
@@ -187,7 +199,8 @@ The root `@fluojs/jwt` import surface is safe to load before selecting a runtime
187
199
  - `JwtVerifierOptions`: Configuration for algorithms, keys, and validation policy.
188
200
  - `SignOptions` and `VerifyOptions`: Per-call signing and verification overrides.
189
201
  - `JwtClaims`, `JwtSigner`, `JwtVerifier`, `JwtKeyEntry`, `JwtAlgorithm`: Public signing and verification contracts.
190
- - `RefreshTokenOptions`, `RefreshTokenStore`, `RefreshTokenRecord`, `RefreshTokenConsumeInput`, `RefreshTokenRotateInput`, and `RefreshTokenConsumeResult`: Refresh-token storage, rotation, and replay-detection contracts.
202
+ - `RefreshTokenOptions`, `RefreshTokenStore`, `RefreshTokenRecord`, `RefreshTokenConsumeInput`, `RefreshTokenRotateInput`, and `RefreshTokenConsumeResult`: Refresh-token storage, rotation, family-scoped revocation, and replay-detection contracts. `RefreshTokenStore.revokeByFamily(...)` is optional for compatibility with subject-revocation stores.
203
+ - `JwtPlatformStatusSnapshot` and `JwtStatusAdapterInput`: Status snapshot and adapter input types exported with the platform diagnostic helpers.
191
204
 
192
205
  ### Errors and diagnostics
193
206
  - `JwtVerificationError`, `JwtInvalidTokenError`, `JwtExpiredTokenError`, `JwtConfigurationError`: Typed JWT failures.
package/dist/module.js CHANGED
@@ -64,7 +64,7 @@ export class JwtModule {
64
64
  provide: JWT_OPTIONS,
65
65
  scope: 'singleton',
66
66
  useValue: options
67
- }, Boolean(options.refreshToken), Boolean(options.refreshToken), 'singleton', false, options.global ?? false);
67
+ }, true, true, options.refreshToken ? 'singleton' : 'transient', false, options.global ?? false);
68
68
  }
69
69
  static forRootAsync(options) {
70
70
  return this.createModule({
@@ -8,6 +8,7 @@ export interface RefreshTokenStore {
8
8
  find(tokenId: string): Promise<RefreshTokenRecord | undefined>;
9
9
  revoke(tokenId: string): Promise<void>;
10
10
  revokeBySubject(subject: string): Promise<void>;
11
+ revokeByFamily?(family: string): Promise<void>;
11
12
  consume?(input: RefreshTokenConsumeInput): Promise<RefreshTokenConsumeResult>;
12
13
  rotate?(input: RefreshTokenRotateInput): Promise<RefreshTokenConsumeResult>;
13
14
  }
@@ -80,6 +81,7 @@ export declare class RefreshTokenService {
80
81
  revokeAllForSubject(subject: string): Promise<void>;
81
82
  private issueRefreshTokenWithFamily;
82
83
  private consumeRefreshToken;
84
+ private revokeCompromisedFamily;
83
85
  private createRefreshTokenWithFamily;
84
86
  private verifyRefreshClaims;
85
87
  }
@@ -1 +1 @@
1
- {"version":3,"file":"refresh-token.d.ts","sourceRoot":"","sources":["../../src/refresh/refresh-token.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAE7D,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAC;AAEjE;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC,IAAI,CAAC,KAAK,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,GAAG,SAAS,CAAC,CAAC;IAC/D,MAAM,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACvC,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAChD,OAAO,CAAC,CAAC,KAAK,EAAE,wBAAwB,GAAG,OAAO,CAAC,yBAAyB,CAAC,CAAC;IAC9E,MAAM,CAAC,CAAC,KAAK,EAAE,uBAAuB,GAAG,OAAO,CAAC,yBAAyB,CAAC,CAAC;CAC7E;AAED;;GAEG;AACH,MAAM,WAAW,wBAAwB;IACvC,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,GAAG,EAAE,IAAI,CAAC;CACX;AAED;;GAEG;AACH,MAAM,WAAW,uBAAwB,SAAQ,wBAAwB;IACvE,WAAW,EAAE,kBAAkB,CAAC;CACjC;AAED;;GAEG;AACH,MAAM,MAAM,yBAAyB,GAAG,UAAU,GAAG,cAAc,GAAG,SAAS,GAAG,WAAW,GAAG,UAAU,GAAG,SAAS,CAAC;AAEvH;;GAEG;AACH,MAAM,WAAW,kBAAkB;IACjC,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,IAAI,CAAC;IAChB,IAAI,EAAE,OAAO,CAAC;IACd,SAAS,EAAE,IAAI,CAAC;CACjB;AAED;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,gBAAgB,EAAE,MAAM,CAAC;IACzB,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,QAAQ,EAAE,OAAO,CAAC;IAClB,KAAK,EAAE,iBAAiB,CAAC;CAC1B;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,4BAA4B,CAAC,OAAO,EAAE,mBAAmB,GAAG,SAAS,GAAG,mBAAmB,CA6B1G;AAQD;;GAEG;AACH,qBAAa,mBAAmB;IAK5B,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,QAAQ;IAL3B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAsB;gBAG5C,OAAO,EAAE,mBAAmB,EACX,MAAM,EAAE,gBAAgB,EACxB,QAAQ,EAAE,kBAAkB;IAKzC,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAOnD,kBAAkB,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,WAAW,EAAE,MAAM,CAAC;QAAC,YAAY,EAAE,MAAM,CAAA;KAAE,CAAC;IA0EhG,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIlD,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;YAI3C,2BAA2B;YAQ3B,mBAAmB;YAUnB,4BAA4B;YA+B5B,mBAAmB;CA4BlC"}
1
+ {"version":3,"file":"refresh-token.d.ts","sourceRoot":"","sources":["../../src/refresh/refresh-token.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAC7D,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAC;AAGjE;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC,IAAI,CAAC,KAAK,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,GAAG,SAAS,CAAC,CAAC;IAC/D,MAAM,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACvC,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAChD,cAAc,CAAC,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C,OAAO,CAAC,CAAC,KAAK,EAAE,wBAAwB,GAAG,OAAO,CAAC,yBAAyB,CAAC,CAAC;IAC9E,MAAM,CAAC,CAAC,KAAK,EAAE,uBAAuB,GAAG,OAAO,CAAC,yBAAyB,CAAC,CAAC;CAC7E;AAED;;GAEG;AACH,MAAM,WAAW,wBAAwB;IACvC,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,GAAG,EAAE,IAAI,CAAC;CACX;AAED;;GAEG;AACH,MAAM,WAAW,uBAAwB,SAAQ,wBAAwB;IACvE,WAAW,EAAE,kBAAkB,CAAC;CACjC;AAED;;GAEG;AACH,MAAM,MAAM,yBAAyB,GAAG,UAAU,GAAG,cAAc,GAAG,SAAS,GAAG,WAAW,GAAG,UAAU,GAAG,SAAS,CAAC;AAEvH;;GAEG;AACH,MAAM,WAAW,kBAAkB;IACjC,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,IAAI,CAAC;IAChB,IAAI,EAAE,OAAO,CAAC;IACd,SAAS,EAAE,IAAI,CAAC;CACjB;AAED;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,gBAAgB,EAAE,MAAM,CAAC;IACzB,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,QAAQ,EAAE,OAAO,CAAC;IAClB,KAAK,EAAE,iBAAiB,CAAC;CAC1B;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,4BAA4B,CAAC,OAAO,EAAE,mBAAmB,GAAG,SAAS,GAAG,mBAAmB,CA6B1G;AAQD;;GAEG;AACH,qBAAa,mBAAmB;IAK5B,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,QAAQ;IAL3B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAsB;gBAG5C,OAAO,EAAE,mBAAmB,EACX,MAAM,EAAE,gBAAgB,EACxB,QAAQ,EAAE,kBAAkB;IAKzC,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAOnD,kBAAkB,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,WAAW,EAAE,MAAM,CAAC;QAAC,YAAY,EAAE,MAAM,CAAA;KAAE,CAAC;IA0EhG,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIlD,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;YAI3C,2BAA2B;YAQ3B,mBAAmB;YAUnB,uBAAuB;YASvB,4BAA4B;YA+B5B,mBAAmB;CA4BlC"}
@@ -104,7 +104,7 @@ export class RefreshTokenService {
104
104
  };
105
105
  }
106
106
  if (consumeResult === 'already_used') {
107
- await this.options.store.revokeBySubject(claims.sub);
107
+ await this.revokeCompromisedFamily(claims.sub, claims.family);
108
108
  throw new JwtInvalidTokenError('Refresh token reuse detected.');
109
109
  }
110
110
  if (consumeResult === 'expired') {
@@ -126,7 +126,7 @@ export class RefreshTokenService {
126
126
  throw new JwtExpiredTokenError('Refresh token has expired.');
127
127
  }
128
128
  if (record.used) {
129
- await this.options.store.revokeBySubject(record.subject);
129
+ await this.revokeCompromisedFamily(record.subject, record.family);
130
130
  throw new JwtInvalidTokenError('Refresh token reuse detected.');
131
131
  }
132
132
  const accessToken = await this.signer.signAccessToken({
@@ -157,6 +157,13 @@ export class RefreshTokenService {
157
157
  }
158
158
  return this.options.store.consume(input);
159
159
  }
160
+ async revokeCompromisedFamily(subject, family) {
161
+ if (this.options.store.revokeByFamily) {
162
+ await this.options.store.revokeByFamily(family);
163
+ return;
164
+ }
165
+ await this.options.store.revokeBySubject(subject);
166
+ }
160
167
  async createRefreshTokenWithFamily(subject, family) {
161
168
  const now = Math.floor(Date.now() / 1000);
162
169
  const {
@@ -8,6 +8,7 @@ export declare class JwksClient {
8
8
  private readonly requestTimeoutMs;
9
9
  private readonly cacheMaxEntries;
10
10
  private readonly cache;
11
+ private readonly activeFetchControllers;
11
12
  private lifecycleGeneration;
12
13
  constructor(uri: string, cacheTtl?: number, requestTimeoutMs?: number, cacheMaxEntries?: number);
13
14
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"jwks.d.ts","sourceRoot":"","sources":["../../src/signing/jwks.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAiC7C;;GAEG;AACH,qBAAa,UAAU;IAKnB,OAAO,CAAC,QAAQ,CAAC,GAAG;IACpB,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,gBAAgB;IACjC,OAAO,CAAC,QAAQ,CAAC,eAAe;IAPlC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA4D;IAClF,OAAO,CAAC,mBAAmB,CAAK;gBAGb,GAAG,EAAE,MAAM,EACX,QAAQ,GAAE,MAAgB,EAC1B,gBAAgB,GAAE,MAAc,EAChC,eAAe,GAAE,MAAuC;IAO3E;;;;;OAKG;IACH,OAAO,IAAI,IAAI;IAKf,OAAO,CAAC,YAAY;IAId,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC;IA2CpD,OAAO,CAAC,wBAAwB;IAQhC,OAAO,CAAC,uBAAuB;YAYjB,SAAS;CAsCxB"}
1
+ {"version":3,"file":"jwks.d.ts","sourceRoot":"","sources":["../../src/signing/jwks.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAiC7C;;GAEG;AACH,qBAAa,UAAU;IAMnB,OAAO,CAAC,QAAQ,CAAC,GAAG;IACpB,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,gBAAgB;IACjC,OAAO,CAAC,QAAQ,CAAC,eAAe;IARlC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA4D;IAClF,OAAO,CAAC,QAAQ,CAAC,sBAAsB,CAA8B;IACrE,OAAO,CAAC,mBAAmB,CAAK;gBAGb,GAAG,EAAE,MAAM,EACX,QAAQ,GAAE,MAAgB,EAC1B,gBAAgB,GAAE,MAAc,EAChC,eAAe,GAAE,MAAuC;IAO3E;;;;;OAKG;IACH,OAAO,IAAI,IAAI;IAWf,OAAO,CAAC,YAAY;IAId,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC;IA2CpD,OAAO,CAAC,wBAAwB;IAQhC,OAAO,CAAC,uBAAuB;YAYjB,SAAS;CAwDxB"}
@@ -21,6 +21,7 @@ function assertPositiveInteger(value, label) {
21
21
  */
22
22
  export class JwksClient {
23
23
  cache = new Map();
24
+ activeFetchControllers = new Set();
24
25
  lifecycleGeneration = 0;
25
26
  constructor(uri, cacheTtl = 600_000, requestTimeoutMs = 5_000, cacheMaxEntries = DEFAULT_JWKS_CACHE_MAX_ENTRIES) {
26
27
  this.uri = uri;
@@ -41,6 +42,10 @@ export class JwksClient {
41
42
  dispose() {
42
43
  this.lifecycleGeneration += 1;
43
44
  this.cache.clear();
45
+ for (const controller of this.activeFetchControllers) {
46
+ controller.abort();
47
+ }
48
+ this.activeFetchControllers.clear();
44
49
  }
45
50
  isAbortError(error) {
46
51
  return error instanceof Error && error.name === 'AbortError';
@@ -99,36 +104,51 @@ export class JwksClient {
99
104
  }
100
105
  }
101
106
  async fetchKeys() {
102
- let response;
107
+ const fetchGeneration = this.lifecycleGeneration;
103
108
  const controller = new AbortController();
109
+ this.activeFetchControllers.add(controller);
110
+ let timedOut = false;
104
111
  const timeout = setTimeout(() => {
112
+ timedOut = true;
105
113
  controller.abort();
106
114
  }, this.requestTimeoutMs);
107
115
  timeout.unref?.();
108
116
  try {
109
- response = await fetch(this.uri, {
117
+ const response = await fetch(this.uri, {
110
118
  signal: controller.signal
111
119
  });
120
+ if (!response.ok) {
121
+ throw new JwtConfigurationError(`JWKS endpoint returned HTTP ${response.status}.`);
122
+ }
123
+ let body;
124
+ try {
125
+ body = await response.json();
126
+ } catch (error) {
127
+ if (this.isAbortError(error)) {
128
+ throw error;
129
+ }
130
+ throw new JwtConfigurationError('JWKS endpoint did not return valid JSON.');
131
+ }
132
+ if (!Array.isArray(body.keys)) {
133
+ throw new JwtConfigurationError('JWKS endpoint did not return a keys array.');
134
+ }
135
+ return body.keys;
112
136
  } catch (error) {
137
+ if (error instanceof JwtConfigurationError) {
138
+ throw error;
139
+ }
113
140
  if (this.isAbortError(error)) {
114
- throw new JwtConfigurationError(`JWKS fetch timed out after ${String(this.requestTimeoutMs)}ms.`);
141
+ if (timedOut) {
142
+ throw new JwtConfigurationError(`JWKS fetch timed out after ${String(this.requestTimeoutMs)}ms.`);
143
+ }
144
+ if (fetchGeneration !== this.lifecycleGeneration) {
145
+ throw new JwtConfigurationError('JWKS client was disposed while fetching keys.');
146
+ }
115
147
  }
116
148
  throw new JwtConfigurationError(`Failed to fetch JWKS from "${this.uri}".`);
117
149
  } finally {
118
150
  clearTimeout(timeout);
151
+ this.activeFetchControllers.delete(controller);
119
152
  }
120
- if (!response.ok) {
121
- throw new JwtConfigurationError(`JWKS endpoint returned HTTP ${response.status}.`);
122
- }
123
- let body;
124
- try {
125
- body = await response.json();
126
- } catch {
127
- throw new JwtConfigurationError('JWKS endpoint did not return valid JSON.');
128
- }
129
- if (!Array.isArray(body.keys)) {
130
- throw new JwtConfigurationError('JWKS endpoint did not return a keys array.');
131
- }
132
- return body.keys;
133
153
  }
134
154
  }
package/package.json CHANGED
@@ -9,7 +9,7 @@
9
9
  "signing",
10
10
  "verification"
11
11
  ],
12
- "version": "1.0.3",
12
+ "version": "1.1.0",
13
13
  "private": false,
14
14
  "license": "MIT",
15
15
  "repository": {
@@ -36,9 +36,9 @@
36
36
  "dist"
37
37
  ],
38
38
  "dependencies": {
39
- "@fluojs/core": "^1.0.3",
40
- "@fluojs/di": "^1.1.0",
41
- "@fluojs/runtime": "^1.1.8"
39
+ "@fluojs/core": "^1.1.0",
40
+ "@fluojs/di": "^2.0.0",
41
+ "@fluojs/runtime": "^2.0.1"
42
42
  },
43
43
  "devDependencies": {
44
44
  "vitest": "^3.2.4"