11 KiB
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/withsrc/,tests/,src/__benchmarks__/, and all feature subdirectories per the layout inproposal.md. - Write
package.jsonat version0.1.0with:"type": "module","sideEffects": falseexportsmap 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:builddevDependencies:vitest catalog:testing,@vitest/coverage-istanbul catalog:testing,fast-check catalog:testing,mitata catalog:testing,@suaveplan/testing workspace:^
- Write
tsconfig.jsonextending@suaveplan/typescript-config/library.json. - Write
vitest.config.tsusingcreateTestConfig()from@suaveplan/testing/config(pool:forks). - Add
lint,typecheck,test,test:coverage,build,docs:check,benchscripts topackage.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?: ClockonTokenIssuerConfig,TokenVerifierConfig,JWKSEndpointOptions;signal?: AbortSignalandretryOptions?: RetryOptionsonTokenVerifierConfig. - Write
jwt-tenant.types.md(≥200 words, API reference for every type).
2b. Error codes (src/error-codes.ts)
- Define
JwtTenantErrorCodeunion — 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_METADATAwithhttpStatus,severity,retryable,userMessageKey,category,descriptionper 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'sgenerateKeyPairunder the hood. - Accept optional
{ clock?: Clock; idGenerator?: IdGenerator }inKeyGenOptionsfor deterministickeyIdandcreatedAt. - Instrument with
jwt.key_generatespan andjwt.keygen_duration_mshistogram.
- Use
- Implement
serializeKeyPair(keyPair)— exports private + public key as JWK viajose; returnsSerializedKeyPair. - Implement
deserializeKeyPair(serialized)— imports keys back viajose; throwsJwtTenantErrorwithJWT_SERIALIZATION_FAILEDon failure. - Implement
keyPairToJWK(keyPair)— public key only (nodparameter 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. Eachithas{ 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 bykeyId.getKeyById(keyId)— returnsnullif not found or key is expired (lazy check viaclock).getActiveKeys(tenantId?)— returns non-expired keys; iftenantIdprovided, filter bypayload.tenant_idmetadata (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. Eachithas{ timeout: 5000 }. - Write
in-memory-key-store.md.
5. JWKS endpoint (src/jwks/jwks-endpoint/)
- Implement
JWKSEndpoint(options):getJWKS(config?)— callskeyStore.getActiveKeys(), maps to JWKs viakeyPairToJWK, returnsJWKS. Optionally includes expired keys ifconfig.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_totalOTel 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,handleRequestresponse shape, cache headers, empty key store. Eachithas{ timeout: 5000 }. - Write
jwks-endpoint.md.
6. Token issuer (src/token/token-issuer/)
- Implement
TokenIssuer:createToken(keyPair, options):- Build
TenantJWTPayloadfromCreateTokenOptions. - Use injectable
clock.now()foriat; computeexpfromexpiresInstring via duration parser. - Use
jose'sSignJWTto sign. - Instrument with
jwt.signspan andjwt.tokens_signed_totalcounter (labelalgorithm). - Throw
JwtTenantErrorwithJWT_ALGORITHM_UNSUPPORTEDif algorithm is not in the supported set.
- Build
- 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. Eachithas{ 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.keyStoreis set) or fetch remote JWKS (ifconfig.jwksUriis set). - Remote JWKS fetch: use
jose'screateRemoteJWKSet; cache result forconfig.cacheMaxAgems (TTL checked via injectableclock); passconfig.signalto abort. Retry on network failure using@suaveplan/retry. - Verify signature, exp/nbf/iss/aud claims via
jose'sjwtVerify. - Apply
VerifyTokenOptionspost-checks (requiredClaims, requiredRole, requiredPermissions, requiredTenantId, requiredDomains). - Map
joseerrors toJwtTenantErrorcodes. - Instrument with
jwt.verifyspan andjwt.tokens_verified_totalcounter (labelsvalid,algorithm). - Instrument JWKS fetch with
jwt.jwks_fetchspan and counter (labelcached).
- Decode protected header to get
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 §4are 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))returnsvalid === truewith matchingtenant_id. - Expired token detection: token with
expin the past (FakeClock advanced) returnsvalid === false. - Serialization round-trip:
deserializeKeyPair(serializeKeyPair(kp))yields a key pair with the samekeyIdandalgorithmthat can sign/verify a round-trip token. - Tenant claim preservation:
payload.tenant_id === options.tenantIdfor 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.mdunder## Benchmarks.
11. Docs
Per-file .md (all ≥200 words):
src/types/jwt-tenant.types.mdsrc/keys/key-generator/key-generator.mdsrc/jwks/in-memory-key-store/in-memory-key-store.mdsrc/jwks/jwks-endpoint/jwks-endpoint.mdsrc/token/token-issuer/token-issuer.mdsrc/token/token-verifier/token-verifier.mdsrc/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, untypedany,ts-ignore, or.skipinsrc/ortests/. - Confirm
"sideEffects": falseinpackage.json. - Run
bun scripts/publish.ts --package @suaveplan/jwt-tenant. - Update
CLAUDE.mdpackage listing to include@suaveplan/jwt-tenant. - Archive: move
openspec/changes/enterprise-jwt-tenant/toopenspec/archive/2026-04-24-enterprise-jwt-tenant/and fold spec delta intoopenspec/specs/jwt-tenant/spec.md.