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

11 KiB

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

  • Create directory universal/auth/jwt-tenant/ with src/, tests/, src/__benchmarks__/, and all feature subdirectories per the layout in proposal.md.
  • 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:^
  • Write tsconfig.json extending @suaveplan/typescript-config/library.json.
  • Write vitest.config.ts using createTestConfig() from @suaveplan/testing/config (pool: forks).
  • 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)

  • 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.
  • Add genesis-only optional fields: clock?: Clock on TokenIssuerConfig, TokenVerifierConfig, JWKSEndpointOptions; signal?: AbortSignal and retryOptions?: RetryOptions on TokenVerifierConfig.
  • Write jwt-tenant.types.md (≥200 words, API reference for every type).

2b. Error codes (src/error-codes.ts)

  • 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.
  • Define JWT_TENANT_ERROR_METADATA with httpStatus, severity, retryable, userMessageKey, category, description per code.
  • Export registerJwtTenantErrorCodes() as idempotent eager side-effect.
  • Export JwtTenantError extends SuaveplanError<JwtTenantErrorCode>.

2c. Zod schemas (src/schemas/jwt-tenant.schemas.ts + .md)

  • Write Zod schemas: TokenIssuerConfigSchema, CreateTokenOptionsSchema, TokenVerifierConfigSchema, VerifyTokenOptionsSchema, JWKSEndpointOptionsSchema, KeyGenOptionsSchema, SerializedKeyPairSchema.
  • Write jwt-tenant.schemas.md.
  • Write jwt-tenant.schemas.test.ts — parse valid/invalid for each schema.

3. Key generation (src/keys/key-generator/)

  • 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.
  • Implement serializeKeyPair(keyPair) — exports private + public key as JWK via jose; returns SerializedKeyPair.
  • Implement deserializeKeyPair(serialized) — imports keys back via jose; throws JwtTenantError with JWT_SERIALIZATION_FAILED on failure.
  • Implement keyPairToJWK(keyPair) — public key only (no d parameter in output).
  • Implement importPublicKeyFromJWK(jwk).
  • Implement calculateKeyExpiration(createdAt, ttlMs).
  • Implement isKeyPairExpired(keyPair, clock?).
  • Implement getAlgorithmFamily(algorithm).
  • 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 }.
  • Write key-generator.md.

4. In-memory key store (src/jwks/in-memory-key-store/)

  • 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.
  • 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 }.
  • Write in-memory-key-store.md.

5. JWKS endpoint (src/jwks/jwks-endpoint/)

  • 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).
  • Implement createInMemoryKeyStore, createJWKSEndpoint, formatJWKSResponse.
  • Write jwks-endpoint.test.ts — get JWKS with active/expired keys, handleRequest response shape, cache headers, empty key store. Each it has { timeout: 5000 }.
  • Write jwks-endpoint.md.

6. Token issuer (src/token/token-issuer/)

  • 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.
  • Implement createTokenIssuer(config) factory.
  • Implement standalone signToken(keyPair, options, config?).
  • 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 }.
  • Write token-issuer.md.

7. Token verifier (src/token/token-verifier/)

  • 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.
  • Implement createTokenVerifier(config) factory.
  • Implement verifyTokenWithKey(token, key, options?).
  • 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 }.
  • Write token-verifier.md.

8. OTel instrumentation (cross-cutting review)

  • Confirm all spans and counters from proposal.md §4 are wired in each module.
  • 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 }.

  • Sign → verify round-trip: for any valid CreateTokenOptions, verifyToken(signToken(kp, opts)) returns valid === true with matching tenant_id.
  • Expired token detection: token with exp in the past (FakeClock advanced) returns valid === false.
  • Serialization round-trip: deserializeKeyPair(serializeKeyPair(kp)) yields a key pair with the same keyId and algorithm that can sign/verify a round-trip token.
  • Tenant claim preservation: payload.tenant_id === options.tenantId for all valid sign/verify pairs.

10. Benchmarks (src/__benchmarks__/jwt-tenant.bench.ts)

Use mitata.

  • keygen_ES256 / keygen_EdDSA: 1 000 iterations each.
  • signToken_ES256: 10 000 iterations.
  • verifyToken_cached: 10 000 iterations (warm in-memory key store, no JWKS fetch).
  • verifyToken_cache_hit_vs_miss: 10 000 iterations each with JWKS cache enabled vs fresh fetch path.
  • Document placeholder numbers in README.md under ## Benchmarks.

11. Docs

Per-file .md (all ≥200 words):

  • src/types/jwt-tenant.types.md
  • src/keys/key-generator/key-generator.md
  • src/jwks/in-memory-key-store/in-memory-key-store.md
  • src/jwks/jwks-endpoint/jwks-endpoint.md
  • src/token/token-issuer/token-issuer.md
  • src/token/token-verifier/token-verifier.md
  • src/schemas/jwt-tenant.schemas.md

Root README:

  • 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

  • Run bunx turbo lint --filter=@suaveplan/jwt-tenant — zero errors.
  • Run bunx turbo typecheck --filter=@suaveplan/jwt-tenant — zero errors.
  • Run bunx turbo test --filter=@suaveplan/jwt-tenant — all tests pass.
  • Run bunx turbo test:coverage --filter=@suaveplan/jwt-tenant — 100/100/100/100.
  • Run bun run docs:check --filter=@suaveplan/jwt-tenant — zero errors.
  • Confirm no TODO, FIXME, @deprecated, untyped any, ts-ignore, or .skip in src/ or tests/.
  • Confirm "sideEffects": false in package.json.
  • Run bun scripts/publish.ts --package @suaveplan/jwt-tenant.
  • Update CLAUDE.md package listing to include @suaveplan/jwt-tenant.
  • 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.