Initial Commit
This commit is contained in:
338
openspec/archive/2026-04-24-enterprise-jwt-tenant/proposal.md
Normal file
338
openspec/archive/2026-04-24-enterprise-jwt-tenant/proposal.md
Normal file
@@ -0,0 +1,338 @@
|
||||
# OpenSpec Change: enterprise-jwt-tenant
|
||||
|
||||
- **Change ID**: enterprise-jwt-tenant
|
||||
- **Package**: `@suaveplan/jwt-tenant`
|
||||
- **Category**: universal
|
||||
- **Target path**: `universal/auth/jwt-tenant`
|
||||
- **Status**: PENDING
|
||||
- **Date**: 2026-04-24
|
||||
- **Reference spec**: `/mnt/s/web/suaveplan/enterprise/jwt-tenant.md`
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Deliver `@suaveplan/jwt-tenant` on the genesis branch from scratch at enterprise quality. The package provides cross-domain JWT with per-tenant JWKS: key pair generation and serialization, token issuance (`TokenIssuer`) with tenant claims, token verification (`TokenVerifier`) with JWKS cache, in-memory and remote JWKS endpoints, and a `TenantKeyStore` interface for pluggable key storage. The genesis rewrite removes the direct `jose` library coupling from the public interface, adds structured errors via `@suaveplan/error`, Zod option schemas, an injectable clock for deterministic token timestamps, OTel instrumentation on all async operations, `AbortSignal` cancellation on JWKS fetches, property-based tests, and mitata benchmarks.
|
||||
|
||||
---
|
||||
|
||||
## Current-state audit (genesis)
|
||||
|
||||
The package does not exist on genesis yet — this is a fresh build.
|
||||
|
||||
---
|
||||
|
||||
## Available workspace deps on genesis
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| `@suaveplan/error` (`workspace:^`) | Base class for `JwtTenantError`; `defineCodes` for `JWT.*` error codes |
|
||||
| `@suaveplan/types` (`workspace:^`) | `Clock` (injectable token timestamps + JWKS cache TTL), `LoggerLike` |
|
||||
| `@suaveplan/crypto` (`workspace:^`) | `randomBytes`, `constantTimeEqual`, `zeroize` — used in key generation and secure comparison |
|
||||
| `@suaveplan/crypto-keys` (`workspace:^`) | `generateKeyPair`, `KeychainLike`, key serialization |
|
||||
| `@suaveplan/retry` (`workspace:^`) | Retry on transient JWKS fetch failures |
|
||||
| `jose` (`catalog:auth`) | JWT sign/verify/JWK import/export primitives |
|
||||
| `zod` (`catalog:forms`) | Zod schemas for all public option types |
|
||||
| `@opentelemetry/api` (`catalog:observability`) | Spans, counters, histograms |
|
||||
| `tslib` (`catalog:build`) | TypeScript runtime helpers |
|
||||
|
||||
Dev deps: `vitest`, `@vitest/coverage-istanbul`, `fast-check`, `mitata` (all `catalog:testing`), `@suaveplan/testing` (`workspace:^`).
|
||||
|
||||
---
|
||||
|
||||
## Preserved public API surface (from main branch)
|
||||
|
||||
### Types
|
||||
|
||||
```ts
|
||||
type KeyAlgorithm = "ES256" | "ES384" | "ES512" | "EdDSA" | "RS256" | "RS384" | "RS512"
|
||||
|
||||
interface KeyPair {
|
||||
keyId: string
|
||||
algorithm: KeyAlgorithm
|
||||
privateKey: unknown // CryptoKey | KeyLike — opaque to callers
|
||||
publicKey: unknown
|
||||
createdAt: Date
|
||||
expiresAt?: Date
|
||||
}
|
||||
|
||||
interface SerializedKeyPair {
|
||||
keyId: string
|
||||
algorithm: KeyAlgorithm
|
||||
privateKeyJwk: JWK
|
||||
publicKeyJwk: JWK
|
||||
createdAt: string // ISO-8601
|
||||
expiresAt?: string
|
||||
}
|
||||
|
||||
interface JWK {
|
||||
kty: string
|
||||
kid?: string
|
||||
alg?: string
|
||||
use?: string
|
||||
[claim: string]: unknown
|
||||
}
|
||||
|
||||
interface JWKS {
|
||||
keys: JWK[]
|
||||
}
|
||||
|
||||
interface TenantJWTPayload {
|
||||
sub: string
|
||||
iat: number
|
||||
exp: number
|
||||
jti: string
|
||||
tenant_id: string
|
||||
domains?: string[]
|
||||
role?: string
|
||||
permissions?: string[]
|
||||
session_id?: string
|
||||
[claim: string]: unknown
|
||||
}
|
||||
|
||||
interface TokenIssuerConfig {
|
||||
issuer?: string
|
||||
defaultExpiresIn?: string // e.g. "1h"
|
||||
clock?: Clock
|
||||
}
|
||||
|
||||
interface CreateTokenOptions {
|
||||
subject: string
|
||||
tenantId: string
|
||||
expiresIn?: string
|
||||
domains?: string[]
|
||||
role?: string
|
||||
permissions?: string[]
|
||||
sessionId?: string
|
||||
issuer?: string
|
||||
additionalClaims?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface TokenVerifierConfig {
|
||||
issuer?: string
|
||||
audience?: string[]
|
||||
clockTolerance?: number // seconds
|
||||
jwksUri?: string
|
||||
cacheMaxAge?: number // ms; default 5 min
|
||||
clock?: Clock
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
interface VerifyTokenOptions {
|
||||
requiredClaims?: string[]
|
||||
requiredRole?: string
|
||||
requiredPermissions?: string[]
|
||||
requiredTenantId?: string
|
||||
requiredDomains?: string[]
|
||||
}
|
||||
|
||||
interface VerifyTokenResult {
|
||||
valid: boolean
|
||||
payload?: TenantJWTPayload
|
||||
error?: string
|
||||
errorCode?: string
|
||||
}
|
||||
|
||||
interface JWKSEndpointOptions {
|
||||
keyStore: TenantKeyStore
|
||||
cacheMaxAge?: number
|
||||
clock?: Clock
|
||||
}
|
||||
|
||||
interface JWKSEndpointConfig {
|
||||
path?: string
|
||||
includeExpired?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
### Key generation
|
||||
|
||||
```ts
|
||||
function generateKeyPair(algorithm?: KeyAlgorithm, options?: KeyGenOptions): Promise<KeyPair>
|
||||
function serializeKeyPair(keyPair: KeyPair): Promise<SerializedKeyPair>
|
||||
function deserializeKeyPair(serialized: SerializedKeyPair): Promise<KeyPair>
|
||||
function keyPairToJWK(keyPair: KeyPair): Promise<JWK>
|
||||
function importPublicKeyFromJWK(jwk: JWK): Promise<unknown>
|
||||
function calculateKeyExpiration(createdAt: Date, ttlMs: number): Date
|
||||
function isKeyPairExpired(keyPair: KeyPair, clock?: Clock): boolean
|
||||
function getAlgorithmFamily(algorithm: KeyAlgorithm): "EC" | "OKP" | "RSA"
|
||||
```
|
||||
|
||||
### Token issuance
|
||||
|
||||
```ts
|
||||
class TokenIssuer {
|
||||
constructor(config: TokenIssuerConfig)
|
||||
createToken(keyPair: KeyPair, options: CreateTokenOptions): Promise<string>
|
||||
}
|
||||
|
||||
function createTokenIssuer(config: TokenIssuerConfig): TokenIssuer
|
||||
function signToken(keyPair: KeyPair, options: CreateTokenOptions, config?: TokenIssuerConfig): Promise<string>
|
||||
```
|
||||
|
||||
### Token verification
|
||||
|
||||
```ts
|
||||
class TokenVerifier {
|
||||
constructor(config: TokenVerifierConfig)
|
||||
verifyToken(token: string, options?: VerifyTokenOptions): Promise<VerifyTokenResult>
|
||||
clearCache(): void
|
||||
}
|
||||
|
||||
function createTokenVerifier(config: TokenVerifierConfig): TokenVerifier
|
||||
function verifyTokenWithKey(token: string, key: unknown, options?: VerifyTokenOptions): Promise<VerifyTokenResult>
|
||||
```
|
||||
|
||||
### JWKS endpoint
|
||||
|
||||
```ts
|
||||
interface TenantKeyStore {
|
||||
getActiveKeys(tenantId?: string): Promise<KeyPair[]>
|
||||
getKeyById(keyId: string): Promise<KeyPair | null>
|
||||
addKey(keyPair: KeyPair): Promise<void>
|
||||
removeKey(keyId: string): Promise<void>
|
||||
}
|
||||
|
||||
class InMemoryKeyStore implements TenantKeyStore {
|
||||
constructor(clock?: Clock)
|
||||
}
|
||||
|
||||
class JWKSEndpoint {
|
||||
constructor(options: JWKSEndpointOptions)
|
||||
getJWKS(config?: JWKSEndpointConfig): Promise<JWKS>
|
||||
handleRequest(request: unknown): Promise<{ status: number; body: string; headers: Record<string, string> }>
|
||||
}
|
||||
|
||||
function createInMemoryKeyStore(clock?: Clock): InMemoryKeyStore
|
||||
function createJWKSEndpoint(options: JWKSEndpointOptions): JWKSEndpoint
|
||||
function formatJWKSResponse(keys: JWK[]): JWKS
|
||||
```
|
||||
|
||||
### Error codes
|
||||
|
||||
```ts
|
||||
type JwtTenantErrorCode =
|
||||
| "JWT_INVALID_SIGNATURE"
|
||||
| "JWT_EXPIRED"
|
||||
| "JWT_INVALID_CLAIMS"
|
||||
| "JWT_MISSING_TENANT"
|
||||
| "JWT_TENANT_MISMATCH"
|
||||
| "JWT_KEY_NOT_FOUND"
|
||||
| "JWT_JWKS_FETCH_FAILED"
|
||||
| "JWT_ALGORITHM_UNSUPPORTED"
|
||||
| "JWT_SERIALIZATION_FAILED"
|
||||
|
||||
class JwtTenantError extends SuaveplanError<JwtTenantErrorCode>
|
||||
function registerJwtTenantErrorCodes(): void
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## New additions for genesis
|
||||
|
||||
### 1. Clock injection throughout
|
||||
|
||||
All `Date.now()` calls replaced by injectable `clock.now()` (from `@suaveplan/types`). Affects `TokenIssuer` (iat/exp), `TokenVerifier` (clock-tolerance check), `InMemoryKeyStore` (expiry checks), `PermissionCache`. Default uses `Date.now()` when no clock is injected.
|
||||
|
||||
### 2. AbortSignal on JWKS fetch
|
||||
|
||||
`TokenVerifier` and `JWKSEndpoint` accept an `AbortSignal` in their configs. Remote JWKS fetch passes the signal through to `fetch`. Abort triggers `JwtTenantError` with code `JWT_JWKS_FETCH_FAILED`.
|
||||
|
||||
### 3. Retry on JWKS fetch
|
||||
|
||||
`TokenVerifier` uses `@suaveplan/retry` with exponential backoff for transient JWKS fetch failures. Max 3 attempts by default; configurable via `TokenVerifierConfig.retryOptions`.
|
||||
|
||||
### 4. OTel instrumentation
|
||||
|
||||
- `jwt.sign` span + `jwt.tokens_signed_total` counter (label `algorithm`)
|
||||
- `jwt.verify` span + `jwt.tokens_verified_total` counter (label `valid: bool`, `algorithm`)
|
||||
- `jwt.jwks_fetch` span + `jwt.jwks_fetch_total` counter (label `cached: bool`)
|
||||
- `jwt.key_generate` span + histogram `jwt.keygen_duration_ms` (label `algorithm`)
|
||||
- JWKS cache: `jwt.jwks_cache_hit_total` / `jwt.jwks_cache_miss_total` counters
|
||||
|
||||
### 5. Zod schemas
|
||||
|
||||
`TokenIssuerConfigSchema`, `CreateTokenOptionsSchema`, `TokenVerifierConfigSchema`, `VerifyTokenOptionsSchema`, `JWKSEndpointOptionsSchema`, `KeyGenOptionsSchema`, `SerializedKeyPairSchema`.
|
||||
|
||||
### 6. Property-based tests (fast-check)
|
||||
|
||||
- Sign → verify round-trip: for any valid `CreateTokenOptions`, `verifyToken(signToken(kp, opts))` returns `valid === true` and payload matches input.
|
||||
- Expired token detection: token with `exp` in the past always returns `valid === false`.
|
||||
- Tenant claim preservation: `payload.tenant_id` always equals `options.tenantId` after round-trip.
|
||||
- Serialization round-trip: `deserializeKeyPair(serializeKeyPair(kp))` yields functionally equivalent key pair (same keyId, algorithm, can sign/verify).
|
||||
|
||||
### 7. Benchmark suite
|
||||
|
||||
- `generateKeyPair` for ES256, ES384, EdDSA — 1 000 iterations each.
|
||||
- `signToken` (ES256) — 10 000 iterations.
|
||||
- `verifyToken` with warm in-memory key — 10 000 iterations.
|
||||
- `verifyToken` with JWKS cache hit vs miss — 10 000 iterations each.
|
||||
|
||||
---
|
||||
|
||||
## File layout
|
||||
|
||||
```text
|
||||
universal/auth/jwt-tenant/
|
||||
package.json
|
||||
tsconfig.json
|
||||
vitest.config.ts
|
||||
src/
|
||||
index.ts
|
||||
error-codes.ts
|
||||
constants/
|
||||
index.ts # DEFAULT_EXPIRES_IN, ROLE_HIERARCHY, algorithm families
|
||||
types/
|
||||
jwt-tenant.types.ts
|
||||
jwt-tenant.types.md
|
||||
jwks/
|
||||
jwks-endpoint/
|
||||
jwks-endpoint.ts
|
||||
jwks-endpoint.md
|
||||
jwks-endpoint.types.ts
|
||||
jwks-endpoint.test.ts
|
||||
index.ts
|
||||
in-memory-key-store/
|
||||
in-memory-key-store.ts
|
||||
in-memory-key-store.md
|
||||
in-memory-key-store.test.ts
|
||||
index.ts
|
||||
keys/
|
||||
key-generator/
|
||||
key-generator.ts
|
||||
key-generator.md
|
||||
key-generator.test.ts
|
||||
index.ts
|
||||
token/
|
||||
token-issuer/
|
||||
token-issuer.ts
|
||||
token-issuer.md
|
||||
token-issuer.types.ts
|
||||
token-issuer.test.ts
|
||||
index.ts
|
||||
token-verifier/
|
||||
token-verifier.ts
|
||||
token-verifier.md
|
||||
token-verifier.types.ts
|
||||
token-verifier.test.ts
|
||||
index.ts
|
||||
schemas/
|
||||
jwt-tenant.schemas.ts
|
||||
jwt-tenant.schemas.md
|
||||
jwt-tenant.schemas.test.ts
|
||||
__benchmarks__/
|
||||
jwt-tenant.bench.ts
|
||||
tests/
|
||||
integration.test.ts
|
||||
property.test.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Impact
|
||||
|
||||
**Downstream packages**: `@suaveplan/auth` may use `TokenVerifier` and `TokenIssuer` for session token management.
|
||||
|
||||
**Breaking changes from main-branch API**: None. All main-branch exported symbols are preserved. `jose` types in public interface positions are replaced with opaque `unknown` wrapped in documented generic aliases (same as main branch pattern).
|
||||
|
||||
**Workspace dep prerequisites**: `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/crypto`, `@suaveplan/crypto-keys`, `@suaveplan/retry` must be published to Verdaccio before this package can be built or tested.
|
||||
185
openspec/archive/2026-04-24-enterprise-jwt-tenant/tasks.md
Normal file
185
openspec/archive/2026-04-24-enterprise-jwt-tenant/tasks.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# Tasks: enterprise-jwt-tenant
|
||||
|
||||
**Change ID**: enterprise-jwt-tenant
|
||||
**Package**: `@suaveplan/jwt-tenant`
|
||||
**Target path**: `universal/auth/jwt-tenant`
|
||||
**Prerequisite packages on Verdaccio**: `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/crypto`, `@suaveplan/crypto-keys`, `@suaveplan/retry`
|
||||
|
||||
---
|
||||
|
||||
## 1. Scaffold
|
||||
|
||||
- [x] Create directory `universal/auth/jwt-tenant/` with `src/`, `tests/`, `src/__benchmarks__/`, and all feature subdirectories per the layout in `proposal.md`.
|
||||
- [x] Write `package.json` at version `0.1.0` with:
|
||||
- `"type": "module"`, `"sideEffects": false`
|
||||
- `exports` map with top-level `"."` plus subpaths: `"./jwks/jwks-endpoint/jwks-endpoint"`, `"./jwks/in-memory-key-store/in-memory-key-store"`, `"./keys/key-generator/key-generator"`, `"./token/token-issuer/token-issuer"`, `"./token/token-verifier/token-verifier"`, `"./schemas/jwt-tenant.schemas"`, `"./types/jwt-tenant.types"`
|
||||
- `dependencies`: `@suaveplan/error workspace:^`, `@suaveplan/types workspace:^`, `@suaveplan/crypto workspace:^`, `@suaveplan/crypto-keys workspace:^`, `@suaveplan/retry workspace:^`, `jose catalog:auth`, `zod catalog:forms`, `@opentelemetry/api catalog:observability`, `tslib catalog:build`
|
||||
- `devDependencies`: `vitest catalog:testing`, `@vitest/coverage-istanbul catalog:testing`, `fast-check catalog:testing`, `mitata catalog:testing`, `@suaveplan/testing workspace:^`
|
||||
- [x] Write `tsconfig.json` extending `@suaveplan/typescript-config/library.json`.
|
||||
- [x] Write `vitest.config.ts` using `createTestConfig()` from `@suaveplan/testing/config` (pool: `forks`).
|
||||
- [x] Add `lint`, `typecheck`, `test`, `test:coverage`, `build`, `docs:check`, `bench` scripts to `package.json`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Types and errors
|
||||
|
||||
### 2a. Types (`src/types/jwt-tenant.types.ts` + `.md`)
|
||||
|
||||
- [x] Define all public interfaces and type aliases from `proposal.md`: `KeyAlgorithm`, `KeyPair`, `SerializedKeyPair`, `JWK`, `JWKS`, `TenantJWTPayload`, `TokenIssuerConfig`, `CreateTokenOptions`, `TokenVerifierConfig`, `VerifyTokenOptions`, `VerifyTokenResult`, `JWKSEndpointOptions`, `JWKSEndpointConfig`, `TenantKeyStore`, `KeyGenOptions`.
|
||||
- [x] Add genesis-only optional fields: `clock?: Clock` on `TokenIssuerConfig`, `TokenVerifierConfig`, `JWKSEndpointOptions`; `signal?: AbortSignal` and `retryOptions?: RetryOptions` on `TokenVerifierConfig`.
|
||||
- [x] Write `jwt-tenant.types.md` (≥200 words, API reference for every type).
|
||||
|
||||
### 2b. Error codes (`src/error-codes.ts`)
|
||||
|
||||
- [x] Define `JwtTenantErrorCode` union — nine codes: `JWT_INVALID_SIGNATURE`, `JWT_EXPIRED`, `JWT_INVALID_CLAIMS`, `JWT_MISSING_TENANT`, `JWT_TENANT_MISMATCH`, `JWT_KEY_NOT_FOUND`, `JWT_JWKS_FETCH_FAILED`, `JWT_ALGORITHM_UNSUPPORTED`, `JWT_SERIALIZATION_FAILED`.
|
||||
- [x] Define `JWT_TENANT_ERROR_METADATA` with `httpStatus`, `severity`, `retryable`, `userMessageKey`, `category`, `description` per code.
|
||||
- [x] Export `registerJwtTenantErrorCodes()` as idempotent eager side-effect.
|
||||
- [x] Export `JwtTenantError extends SuaveplanError<JwtTenantErrorCode>`.
|
||||
|
||||
### 2c. Zod schemas (`src/schemas/jwt-tenant.schemas.ts` + `.md`)
|
||||
|
||||
- [x] Write Zod schemas: `TokenIssuerConfigSchema`, `CreateTokenOptionsSchema`, `TokenVerifierConfigSchema`, `VerifyTokenOptionsSchema`, `JWKSEndpointOptionsSchema`, `KeyGenOptionsSchema`, `SerializedKeyPairSchema`.
|
||||
- [x] Write `jwt-tenant.schemas.md`.
|
||||
- [x] Write `jwt-tenant.schemas.test.ts` — parse valid/invalid for each schema.
|
||||
|
||||
---
|
||||
|
||||
## 3. Key generation (`src/keys/key-generator/`)
|
||||
|
||||
- [x] Implement `generateKeyPair(algorithm?, options?)`:
|
||||
- Use `jose`'s `generateKeyPair` under the hood.
|
||||
- Accept optional `{ clock?: Clock; idGenerator?: IdGenerator }` in `KeyGenOptions` for deterministic `keyId` and `createdAt`.
|
||||
- Instrument with `jwt.key_generate` span and `jwt.keygen_duration_ms` histogram.
|
||||
- [x] Implement `serializeKeyPair(keyPair)` — exports private + public key as JWK via `jose`; returns `SerializedKeyPair`.
|
||||
- [x] Implement `deserializeKeyPair(serialized)` — imports keys back via `jose`; throws `JwtTenantError` with `JWT_SERIALIZATION_FAILED` on failure.
|
||||
- [x] Implement `keyPairToJWK(keyPair)` — public key only (no `d` parameter in output).
|
||||
- [x] Implement `importPublicKeyFromJWK(jwk)`.
|
||||
- [x] Implement `calculateKeyExpiration(createdAt, ttlMs)`.
|
||||
- [x] Implement `isKeyPairExpired(keyPair, clock?)`.
|
||||
- [x] Implement `getAlgorithmFamily(algorithm)`.
|
||||
- [x] Write `key-generator.test.ts` — generation for each supported algorithm, serialize/deserialize round-trip, JWK public-only assertion (no private key leakage), expiry check with FakeClock, algorithm family mapping. Each `it` has `{ timeout: 30000 }`.
|
||||
- [x] Write `key-generator.md`.
|
||||
|
||||
---
|
||||
|
||||
## 4. In-memory key store (`src/jwks/in-memory-key-store/`)
|
||||
|
||||
- [x] Implement `InMemoryKeyStore`:
|
||||
- `addKey(keyPair)` — stores in internal Map keyed by `keyId`.
|
||||
- `getKeyById(keyId)` — returns `null` if not found or key is expired (lazy check via `clock`).
|
||||
- `getActiveKeys(tenantId?)` — returns non-expired keys; if `tenantId` provided, filter by `payload.tenant_id` metadata (if stored).
|
||||
- `removeKey(keyId)`.
|
||||
- Constructor accepts optional `clock?: Clock`.
|
||||
- [x] Write `in-memory-key-store.test.ts` — add/get/remove keys, expiry with FakeClock, multi-tenant filtering, getActiveKeys excludes expired. Each `it` has `{ timeout: 5000 }`.
|
||||
- [x] Write `in-memory-key-store.md`.
|
||||
|
||||
---
|
||||
|
||||
## 5. JWKS endpoint (`src/jwks/jwks-endpoint/`)
|
||||
|
||||
- [x] Implement `JWKSEndpoint(options)`:
|
||||
- `getJWKS(config?)` — calls `keyStore.getActiveKeys()`, maps to JWKs via `keyPairToJWK`, returns `JWKS`. Optionally includes expired keys if `config.includeExpired`.
|
||||
- `handleRequest(request)` — responds with `{ status: 200, body: JSON.stringify(jwks), headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=...' } }`.
|
||||
- Add `jwt.jwks_cache_hit_total` / `jwt.jwks_cache_miss_total` OTel counters (cache = whether the JWKS response was computed fresh or returned from an internal TTL cache).
|
||||
- [x] Implement `createInMemoryKeyStore`, `createJWKSEndpoint`, `formatJWKSResponse`.
|
||||
- [x] Write `jwks-endpoint.test.ts` — get JWKS with active/expired keys, `handleRequest` response shape, cache headers, empty key store. Each `it` has `{ timeout: 5000 }`.
|
||||
- [x] Write `jwks-endpoint.md`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Token issuer (`src/token/token-issuer/`)
|
||||
|
||||
- [x] Implement `TokenIssuer`:
|
||||
- `createToken(keyPair, options)`:
|
||||
- Build `TenantJWTPayload` from `CreateTokenOptions`.
|
||||
- Use injectable `clock.now()` for `iat`; compute `exp` from `expiresIn` string via duration parser.
|
||||
- Use `jose`'s `SignJWT` to sign.
|
||||
- Instrument with `jwt.sign` span and `jwt.tokens_signed_total` counter (label `algorithm`).
|
||||
- Throw `JwtTenantError` with `JWT_ALGORITHM_UNSUPPORTED` if algorithm is not in the supported set.
|
||||
- [x] Implement `createTokenIssuer(config)` factory.
|
||||
- [x] Implement standalone `signToken(keyPair, options, config?)`.
|
||||
- [x] Write `token-issuer.test.ts` — sign with each algorithm, payload fields preserved (tenantId, domains, role, permissions, sessionId, additionalClaims), expiry duration parsing, clock injection for deterministic iat/exp, unsupported algorithm error. Each `it` has `{ timeout: 30000 }`.
|
||||
- [x] Write `token-issuer.md`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Token verifier (`src/token/token-verifier/`)
|
||||
|
||||
- [x] Implement `TokenVerifier`:
|
||||
- `verifyToken(token, options?)`:
|
||||
- Decode protected header to get `kid`.
|
||||
- Resolve key from local key store (if `config.keyStore` is set) or fetch remote JWKS (if `config.jwksUri` is set).
|
||||
- Remote JWKS fetch: use `jose`'s `createRemoteJWKSet`; cache result for `config.cacheMaxAge` ms (TTL checked via injectable `clock`); pass `config.signal` to abort. Retry on network failure using `@suaveplan/retry`.
|
||||
- Verify signature, exp/nbf/iss/aud claims via `jose`'s `jwtVerify`.
|
||||
- Apply `VerifyTokenOptions` post-checks (requiredClaims, requiredRole, requiredPermissions, requiredTenantId, requiredDomains).
|
||||
- Map `jose` errors to `JwtTenantError` codes.
|
||||
- Instrument with `jwt.verify` span and `jwt.tokens_verified_total` counter (labels `valid`, `algorithm`).
|
||||
- Instrument JWKS fetch with `jwt.jwks_fetch` span and counter (label `cached`).
|
||||
- `clearCache()` — wipes the JWKS cache.
|
||||
- [x] Implement `createTokenVerifier(config)` factory.
|
||||
- [x] Implement `verifyTokenWithKey(token, key, options?)`.
|
||||
- [x] Write `token-verifier.test.ts` (≤500 lines) — valid token, expired token, wrong issuer, wrong audience, missing tenant, tenant mismatch, required role not present, required permissions subset, JWKS cache hit/miss with FakeClock, AbortSignal cancellation, retry on transient failure. `{ timeout: 30000 }`.
|
||||
- [x] Write `token-verifier.md`.
|
||||
|
||||
---
|
||||
|
||||
## 8. OTel instrumentation (cross-cutting review)
|
||||
|
||||
- [x] Confirm all spans and counters from `proposal.md §4` are wired in each module.
|
||||
- [x] Add `tests/otel.test.ts` — in-memory exporter; assert counters increment for sign, verify (valid + invalid), keygen, JWKS cache hit/miss.
|
||||
|
||||
---
|
||||
|
||||
## 9. Property-based tests (`tests/property.test.ts`)
|
||||
|
||||
Use `fast-check`. All test timeouts `{ timeout: 30000 }`.
|
||||
|
||||
- [x] **Sign → verify round-trip**: for any valid `CreateTokenOptions`, `verifyToken(signToken(kp, opts))` returns `valid === true` with matching `tenant_id`.
|
||||
- [x] **Expired token detection**: token with `exp` in the past (FakeClock advanced) returns `valid === false`.
|
||||
- [x] **Serialization round-trip**: `deserializeKeyPair(serializeKeyPair(kp))` yields a key pair with the same `keyId` and `algorithm` that can sign/verify a round-trip token.
|
||||
- [x] **Tenant claim preservation**: `payload.tenant_id === options.tenantId` for all valid sign/verify pairs.
|
||||
|
||||
---
|
||||
|
||||
## 10. Benchmarks (`src/__benchmarks__/jwt-tenant.bench.ts`)
|
||||
|
||||
Use `mitata`.
|
||||
|
||||
- [x] `keygen_ES256` / `keygen_EdDSA`: 1 000 iterations each.
|
||||
- [x] `signToken_ES256`: 10 000 iterations.
|
||||
- [x] `verifyToken_cached`: 10 000 iterations (warm in-memory key store, no JWKS fetch).
|
||||
- [x] `verifyToken_cache_hit_vs_miss`: 10 000 iterations each with JWKS cache enabled vs fresh fetch path.
|
||||
- [x] Document placeholder numbers in `README.md` under `## Benchmarks`.
|
||||
|
||||
---
|
||||
|
||||
## 11. Docs
|
||||
|
||||
Per-file `.md` (all ≥200 words):
|
||||
|
||||
- [x] `src/types/jwt-tenant.types.md`
|
||||
- [x] `src/keys/key-generator/key-generator.md`
|
||||
- [x] `src/jwks/in-memory-key-store/in-memory-key-store.md`
|
||||
- [x] `src/jwks/jwks-endpoint/jwks-endpoint.md`
|
||||
- [x] `src/token/token-issuer/token-issuer.md`
|
||||
- [x] `src/token/token-verifier/token-verifier.md`
|
||||
- [x] `src/schemas/jwt-tenant.schemas.md`
|
||||
|
||||
Root README:
|
||||
|
||||
- [x] Write `universal/auth/jwt-tenant/README.md`: purpose, install, quick-start (generate key + sign + verify), multi-tenant JWKS endpoint guide, modules table, benchmarks placeholder.
|
||||
|
||||
---
|
||||
|
||||
## 12. Verify and publish
|
||||
|
||||
- [x] Run `bunx turbo lint --filter=@suaveplan/jwt-tenant` — zero errors.
|
||||
- [x] Run `bunx turbo typecheck --filter=@suaveplan/jwt-tenant` — zero errors.
|
||||
- [x] Run `bunx turbo test --filter=@suaveplan/jwt-tenant` — all tests pass.
|
||||
- [x] Run `bunx turbo test:coverage --filter=@suaveplan/jwt-tenant` — 100/100/100/100.
|
||||
- [x] Run `bun run docs:check --filter=@suaveplan/jwt-tenant` — zero errors.
|
||||
- [x] Confirm no `TODO`, `FIXME`, `@deprecated`, untyped `any`, `ts-ignore`, or `.skip` in `src/` or `tests/`.
|
||||
- [x] Confirm `"sideEffects": false` in `package.json`.
|
||||
- [x] Run `bun scripts/publish.ts --package @suaveplan/jwt-tenant`.
|
||||
- [x] Update `CLAUDE.md` package listing to include `@suaveplan/jwt-tenant`.
|
||||
- [x] Archive: move `openspec/changes/enterprise-jwt-tenant/` to `openspec/archive/2026-04-24-enterprise-jwt-tenant/` and fold spec delta into `openspec/specs/jwt-tenant/spec.md`.
|
||||
Reference in New Issue
Block a user