# 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 } 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 function serializeKeyPair(keyPair: KeyPair): Promise function deserializeKeyPair(serialized: SerializedKeyPair): Promise function keyPairToJWK(keyPair: KeyPair): Promise function importPublicKeyFromJWK(jwk: JWK): Promise 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 } function createTokenIssuer(config: TokenIssuerConfig): TokenIssuer function signToken(keyPair: KeyPair, options: CreateTokenOptions, config?: TokenIssuerConfig): Promise ``` ### Token verification ```ts class TokenVerifier { constructor(config: TokenVerifierConfig) verifyToken(token: string, options?: VerifyTokenOptions): Promise clearCache(): void } function createTokenVerifier(config: TokenVerifierConfig): TokenVerifier function verifyTokenWithKey(token: string, key: unknown, options?: VerifyTokenOptions): Promise ``` ### JWKS endpoint ```ts interface TenantKeyStore { getActiveKeys(tenantId?: string): Promise getKeyById(keyId: string): Promise addKey(keyPair: KeyPair): Promise removeKey(keyId: string): Promise } class InMemoryKeyStore implements TenantKeyStore { constructor(clock?: Clock) } class JWKSEndpoint { constructor(options: JWKSEndpointOptions) getJWKS(config?: JWKSEndpointConfig): Promise handleRequest(request: unknown): Promise<{ status: number; body: string; headers: Record }> } 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 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.