10 KiB
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
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
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
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
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
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
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.signspan +jwt.tokens_signed_totalcounter (labelalgorithm)jwt.verifyspan +jwt.tokens_verified_totalcounter (labelvalid: bool,algorithm)jwt.jwks_fetchspan +jwt.jwks_fetch_totalcounter (labelcached: bool)jwt.key_generatespan + histogramjwt.keygen_duration_ms(labelalgorithm)- JWKS cache:
jwt.jwks_cache_hit_total/jwt.jwks_cache_miss_totalcounters
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))returnsvalid === trueand payload matches input. - Expired token detection: token with
expin the past always returnsvalid === false. - Tenant claim preservation:
payload.tenant_idalways equalsoptions.tenantIdafter round-trip. - Serialization round-trip:
deserializeKeyPair(serializeKeyPair(kp))yields functionally equivalent key pair (same keyId, algorithm, can sign/verify).
7. Benchmark suite
generateKeyPairfor ES256, ES384, EdDSA — 1 000 iterations each.signToken(ES256) — 10 000 iterations.verifyTokenwith warm in-memory key — 10 000 iterations.verifyTokenwith JWKS cache hit vs miss — 10 000 iterations each.
File layout
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.