Files
suaveplan/openspec/archive/2026-04-24-enterprise-jwt-tenant/proposal.md
2026-05-27 23:29:53 +01:00

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.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

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.